Bienvenue dans ce tutoriel consacré à la création d’une application mobile avec Flutter étape par étape.

Flutter est un framework open-source développé par Google qui permet de créer des applications pour les appareils mobiles de manière rapide et intuitive.

Dans ce tutoriel, je vous propose une formation gratuite qui vous guidera pas à pas dans la création de votre première application mobile avec Flutter.

Nous couvrirons toutes les étapes de la création de cette application, depuis le design de la page d’accueil jusqu’à la page de détail de notre concept.

La première page sera notre page d’accueil dans laquelle nous pourrons consulter les différents jeux disponibles, et la deuxième la page de détail d’un jeu.

Si vous êtes débutant ou simplement intéressé par la création d’applications mobiles avec Flutter, alors ce tutoriel est fait pour vous !

Nous allons donc séparer ce tutoriel en trois parties:

  1. Création et configuration de l’application
  2. Design de la page d’accueil de notre application
  3. Design de la page de détail du jeu vidéo

N’hésitez pas également à télécharger le code source de l’application pour la tester rapidement sur un émulateur.

1. Création et configuration de l’application

Dans cette partie, nous allons créer un projet Flutter et configurer les éléments principaux de notre application, comme les images et les packages que nous utiliserons par la suite.

1.1 Création du projet Flutter

Ici, nous allons tout simplement utiliser la commande Flutter permettant de créer un nouveau projet et ensuite préparer notre application.

Nous ouvrons d'abord le terminal et nous nous positionnons ensuite dans notre dossier de développement pour lancer la commande:

cd development
flutter create gamestore

Une fois que le projet est créé, nous pouvons l’ouvrir avec notre éditeur VS Code afin de le modifier.

Toutes les étapes d'installation de Flutter et de configuration de VS Code sont disponibles dans le programme gratuit Flutter.

1.2 Importation des images

Dans notre application, nous aurons besoin d'importer une série d'images en rapport avec le thème du jeu vidéo.

Pour cela, nous allons créer un dossier 'assets' dans lequel nous mettons le sous-dossier 'images'.

Ce dossier contiendra donc les images que nous utiliserons dans toute notre application:

Vous pouvez télécharger ce dossier 'assets' avec toutes les images à l'intérieur en cliquant sur ce lien ou sur le bouton ci-dessous:

Ensuite, rendez-vous dans le fichier pubspec.yaml afin de déclarer le dossier comportant nos images comme ceci:

assets:
  - assets/images/

Vous pouvez ensuite entrer la commande suivante pour prendre en compte l’ajout des images dans notre application:

flutter pub get

1.3 Installation du package readmore

Pour cette application, nous allons utiliser le package readmore qui donne accès au widget ReadMoreText() permettant d'agrandir et de réduire une zone de texte: https://pub.dev/packages/readmore

Pour cela, nous devons ajouter la dépendance du package readmore dans le fichier pubspec.yaml comme ceci:

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^1.0.2
  readmore: ^2.2.0 # <== dépendance à ajouter

Une fois le package importé, enregistrez le fichier et le téléchargement devrait se faire automatiquement, sinon entrez à nouveau la commande suivante:

flutter pub get

Voilà pour l'importation de notre package, nous allons maintenant commencer à construire notre application Flutter.

1.4 Rédaction du fichier main.dart

On commence par se rendre dans le fichier principal main.dart et nous supprimons tout son contenu, pour construire notre design en partant de zéro.

On continue en déclarant la fonction main(), qui lancera le code de notre application, et la fonction runApp(), qui chargera la classe MyApp():

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

void main() {
  runApp(const MyApp());
}

La classe MyApp() va contenir la méthode build() qui permettra de dessiner l’interface utilisateur avec le widget MaterialApp() (application utilisant le Material Design).

Nous donnons un titre à notre application avec le champ 'title', et nous mettons également l’attribut debugShowCheckedModeBanner à false pour supprimer la bannière de débogage.

Enfin, nous avons le champ 'home' qui va nous permettre de définir le widget qui sera la page d’accueil de l’application:

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

  @override
  Widget build(BuildContext context) {
    SystemChrome.setSystemUIOverlayStyle(
      const SystemUiOverlayStyle(statusBarColor: Colors.transparent),
    );
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Gamestore',
      home: HomePage(),
    );
  }
}

Ici, nous appelons le widget HomePage() qui représentera notre page d’accueil que nous allons créer dans la prochaine partie.

Comme vous pouvez le voir dans le code plus haut, nous modifions la couleur de la barre d'état de notre application afin qu'elle soit transparente:

SystemChrome.setSystemUIOverlayStyle(
  const SystemUiOverlayStyle(statusBarColor: Colors.transparent),
);

Cela permettra à notre application d'être en plein écran et de visualiser le contenu tout en haut de nos pages.

2. Création de la page d’accueil

Dans cette deuxième partie, nous allons créer le design de notre page d’accueil en détaillant les différentes sections qui la composent.

La page d’accueil sera constituée des jeux disponibles et d’un menu de navigation en bas.

Pour bien structurer notre projet, chaque page de l'application aura un dossier dédié avec également un sous dossier pour les widgets:

├── lib
│   ├── pages
│   │   ├── home
│   │   │   ├── widgets
│   │   │   └── home.dart
│   │   └── detail
│   │       ├── widgets
│   │       └── detail.dart
│   └── main.dart
├── pubspec.lock
├── pubspec.yaml

Nous placerons les dossiers de chaque page dans un dossier nommé 'pages'.

Notre page d'accueil sera donc placé dans le dossier 'lib/pages/home/' et sera nommée home.dart

2.1. Architecture de la HomePage()

Nous allons diviser notre page en plusieurs widgets qui seront créés indépendamment avant d’être appelés dans notre HomePage().

Pour commencer, nous déclarons notre classe HomePage() comme un StatelessWidget qui va contenir la page d’accueil de notre application:

import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF5F67EA),
      body: SingleChildScrollView(
        child: Stack(
          children: [
            Transform(),
            Positioned(),
            Column(
              children: [
                const HeaderSection(),
                const SearchSection(),
                CategorySection(),
              ],
            )
          ],
        ),
      ),
      bottomNavigationBar: NavigationBar(),
    );
  }
}

Nous avons ici la structure de base de notre page d'accueil, avec les champs body et bottomNavigationBar mais sans appBar.

Nous allons donc subdiviser notre design en plusieurs sections comme vous pouvez le voir dans le code complet de notre HomePage:

import 'package:flutter/material.dart';
import 'package:gamestore/pages/home/widgets/category.dart';
import 'package:gamestore/pages/home/widgets/header.dart';
import 'package:gamestore/pages/home/widgets/search.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF5F67EA),
      body: SingleChildScrollView(
        child: Stack(
          children: [
            Transform(
              transform: Matrix4.identity()..rotateZ(20),
              origin: const Offset(150, 50),
              child: Image.asset(
                'assets/images/bg_liquid.png',
                width: 200,
              ),
            ),
            Positioned(
              right: 0,
              top: 200,
              child: Transform(
                transform: Matrix4.identity()..rotateZ(20),
                origin: const Offset(180, 100),
                child: Image.asset(
                  'assets/images/bg_liquid.png',
                  width: 200,
                ),
              ),
            ),
            Column(
              children: [
                const HeaderSection(),
                const SearchSection(),
                CategorySection(),
              ],
            )
          ],
        ),
      ),
      bottomNavigationBar: NavigationBar(),
    );
  }

  Widget NavigationBar() {
    return Container(
      color: const Color(0xfff6f8ff),
      child: Container(
        decoration: BoxDecoration(
          boxShadow: [
            BoxShadow(
                color: Colors.grey.withOpacity(0.2),
                spreadRadius: 5,
                blurRadius: 10),
          ],
        ),
        child: ClipRRect(
          borderRadius: const BorderRadius.only(
            topLeft: Radius.circular(30),
            topRight: Radius.circular(30),
          ),
          child: BottomNavigationBar(
            selectedItemColor: const Color(0xFF5F67EA),
            selectedFontSize: 12,
            unselectedFontSize: 12,
            unselectedItemColor: Colors.grey.withOpacity(0.7),
            type: BottomNavigationBarType.fixed,
            items: [
              const BottomNavigationBarItem(
                label: 'home',
                icon: Icon(
                  Icons.home_rounded,
                  size: 50,
                ),
              ),
              BottomNavigationBarItem(
                label: "Application",
                icon: Container(
                  margin: const EdgeInsets.all(5),
                  padding: const EdgeInsets.all(5),
                  decoration: BoxDecoration(
                    color: Colors.grey.withOpacity(0.2),
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: const Icon(
                    Icons.more_horiz_outlined,
                    size: 30,
                    color: Colors.grey,
                  ),
                ),
              ),
              BottomNavigationBarItem(
                label: "Film",
                icon: Container(
                  margin: const EdgeInsets.all(5),
                  padding: const EdgeInsets.all(5),
                  decoration: BoxDecoration(
                    color: Colors.grey.withOpacity(0.2),
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: const Icon(
                    Icons.play_arrow_rounded,
                    size: 30,
                    color: Colors.grey,
                  ),
                ),
              ),
              BottomNavigationBarItem(
                label: "Book",
                icon: Container(
                  margin: const EdgeInsets.all(5),
                  padding: const EdgeInsets.all(5),
                  decoration: BoxDecoration(
                    color: Colors.grey.withOpacity(0.2),
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: const Icon(
                    Icons.auto_stories_rounded,
                    size: 30,
                    color: Colors.grey,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Tout le corps de notre page d'accueil est placé dans le widget SingleChildScrollView() afin de permettre le défilement de la page sur l'axe vertical.

Par la suite, nous positionnons tous les autres éléments à l'intérieur du widget Stack().

2.2 Mise en place des formes géométriques

Comme vous pouvez le voir sur notre page d'accueil, nous avons deux formes géométriques placées tout en haut.

Pour produire ce résultat, nous allons utiliser les widgets Transform() et Positioned().

Ces deux images seront déclarées comme les deux premiers enfants de notre widget Stack():

Transform(
  transform: Matrix4.identity()..rotateZ(20),
  origin: const Offset(150, 50),
  child: Image.asset(
    'assets/images/bg_liquid.png',
    width: 200,
  ),
),
Positioned(
  right: 0,
  top: 200,
  child: Transform(
    transform: Matrix4.identity()..rotateZ(20),
    origin: const Offset(180, 100),
    child: Image.asset(
      'assets/images/bg_liquid.png',
      width: 200,
    ),
  ),
),

Pour la première image, nous positionnons tout en haut en réalisant une rotation de 20 degrés grâce au widget Transform().

Pour la deuxième image, nous nous positionnons à droite et réalisons la même transformation.

Le troisième widget enfant contenu dans notre widget Stack() est une Column(), qui va contenir toutes les différentes sections de notre page d'accueil:

Column(
  children: [
    const HeaderSection(),
    const SearchSection(),
    CategorySection(),
  ],
)

2.3 Création de la HeaderSection

Cette section est la première de notre widget Column(), et nous permettra de définir l'entête de notre page, notamment les deux textes à gauche et l'avatar à droite:

À partir de maintenant, nous travaillons dans le dossier 'widgets' pour créer chacune de nos sections.

Nous créons donc un fichier header.dart dans le répertoire '/pages/home/widgets/':

Voilà le code complet de notre HeaderSection():

import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.only(
        top: MediaQuery.of(context).padding.top,
        left: 25,
        right: 25,
      ),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: const [
              Text(
                "Welcome,",
                style: TextStyle(
                  fontSize: 22,
                  color: Colors.white,
                  fontWeight: FontWeight.bold,
                ),
              ),
              SizedBox(height: 5),
              Text(
                "What would you like to play?",
                style: TextStyle(
                  fontSize: 19,
                  color: Colors.white,
                ),
              ),
            ],
          ),
          CircleAvatar(
            child: Image.asset(
              'assets/images/avatar.png',
              fit: BoxFit.cover,
            ),
          )
        ],
      ),
    );
  }
}

Notre section est contenue dans un Container() auquel nous donnons trois valeurs de padding.

Comme vous pouvez le voir, nous définissons le padding top avec:

MediaQuery.of(context).padding.top

Ceci parce que, nous voulons que notre Container() puisse commencer après la barre d'état ou barre de statut.

Le code MediaQuery.of(context).padding.top, renvoie donc la hauteur de la barre d'état ou à la hauteur de cette zone en rouge:

Comment widget enfant à notre Container(), nous utilisons une Row() pour placer à l'intérieur une Column() qui va contenir nos deux widgets Text().

Comme deuxième enfant a notre Row(), nous ajoutons l'image avatar avec le widget CircleAvatar().

2.4 Création de la SearchSection

Cette section est la deuxième de notre widget Column(), et nous permettra de créer la barre de recherche présente dans notre page.

Nous créons donc le fichier search.dart dans le répertoire '/pages/home/widgets/':

Le code complet de notre section de recherche:

import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(
        horizontal: 25,
        vertical: 30,
      ),
      child: Stack(
        children: [
          TextField(
            cursorColor: const Color(0xFF5F67EA),
            decoration: InputDecoration(
              fillColor: const Color(0xFFF6F8FF),
              filled: true,
              border: OutlineInputBorder(
                borderRadius: BorderRadius.circular(20),
                borderSide: const BorderSide(
                  width: 0,
                  style: BorderStyle.none,
                ),
              ),
              contentPadding: const EdgeInsets.symmetric(
                horizontal: 20,
                vertical: 20,
              ),
              prefixIcon: const Icon(
                Icons.search_outlined,
                size: 30,
              ),
              hintText: "Search game",
              hintStyle: TextStyle(
                fontSize: 14,
                color: Colors.grey.withOpacity(0.7),
              ),
            ),
          ),
          Positioned(
            bottom: 10,
            right: 12,
            child: Container(
              padding: const EdgeInsets.all(5),
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(10),
                color: const Color(0xFF5F67EA),
              ),
              child: const Icon(
                Icons.mic_outlined,
                color: Colors.white,
                size: 25,
              ),
            ),
          )
        ],
      ),
    );
  }
}

Notre section est placée dans un Container() ayant pour widget enfant un widget Stack().

C'est dans le widget Stack() que nous positionnons notre barre de recherche en utilisant le widget TextField().

Par la suite, nous positionnons un Container() contenant l'icône du micro à l'intérieur.

2.5 Création de la CategorySection

Cette section est la troisième de notre widget Column() et nous permettra de présenter les catégories de jeux dans notre page:

Nous créons donc un fichier category.dart dans le répertoire '/pages/home/widgets/'.

Le code complet de notre CategorySection:

import 'package:flutter/material.dart';
import 'package:gamestore/pages/home/widgets/newest.dart';
import 'package:gamestore/pages/home/widgets/popular.dart';

class CategorySection extends StatelessWidget {
  CategorySection({super.key});

  final categories = [
    {
      'icon': Icons.track_changes_outlined,
      'color': const Color(0xFF605CF4),
      'title': 'Arcabe'
    },
    {
      'icon': Icons.sports_motorsports_outlined,
      'color': const Color(0xFFFC77A6),
      'title': 'Racing'
    },
    {
      'icon': Icons.casino_outlined,
      'color': const Color(0xFF4391FF),
      'title': 'Strategy'
    },
    {
      'icon': Icons.sports_esports,
      'color': const Color(0xFF7182f2),
      'title': 'More'
    },
  ];

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        color: Color(0xFFF6F8FF),
        borderRadius: BorderRadius.only(
          topLeft: Radius.circular(20),
          topRight: Radius.circular(20),
        ),
      ),
      child: Column(
        children: [
          Container(
            height: 140,
            padding: const EdgeInsets.all(25),
            child: ListView.separated(
              scrollDirection: Axis.horizontal,
              itemBuilder: (_, index) => Column(
                children: [
                  Container(
                    padding: const EdgeInsets.all(10),
                    decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(20),
                        color: categories[index]['color'] as Color),
                    child: Icon(
                      categories[index]['icon'] as IconData,
                      color: Colors.white,
                      size: 40,
                    ),
                  ),
                  const SizedBox(height: 10),
                  Text(
                    categories[index]['title'] as String,
                    style: TextStyle(
                      color: Colors.black.withOpacity(0.7),
                      fontWeight: FontWeight.bold,
                      fontSize: 16,
                    ),
                  )
                ],
              ),
              separatorBuilder: (_, index) => const SizedBox(width: 33),
              itemCount: categories.length,
            ),
          ),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 25),
            width: 410,
            child: const Text(
              'Popular game',
              style: TextStyle(
                fontWeight: FontWeight.bold,
                fontSize: 20,
              ),
            ),
          ),
          PopularGame(),
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 25),
            width: 410,
            child: const Text(
              'Newest game',
              style: TextStyle(
                fontWeight: FontWeight.bold,
                fontSize: 20,
              ),
            ),
          ),
          NewestGame(),
        ],
      ),
    );
  }
}

Au niveau des importations, vous pouvez remarquer les deux fichiers newest.dart et popular.dart

Ce sont les fichiers qui vont comporter deux autres widgets que nous allons créer dans la suite de ce tutoriel.

Au début de notre classe, nous définissons une liste de catégories de jeux que nous stockons dans la variable categories:

final categories = [
  {
    'icon': Icons.track_changes_outlined,
    'color': const Color(0xFF605CF4),
    'title': 'Arcabe'
   },
  [...],
  {
    'icon': Icons.sports_esports,
    'color': const Color(0xFF7182f2),
    'title': 'More'
    },
];

La CategorySection a trois sous-sections et elles sont toutes contenues dans un Container() de fond blanc avec les bords supérieurs arrondis.

Ce Container() possède comme enfant un widget Colum() dans lequel les trois parties de la section sont introduites.

La première est définie par le widget ListView(), dans cette partie, nous utilisons la liste définie plus haut afin d'afficher le nom de chaque catégorie et l'icône symbolisant la catégorie:

ListView.separated(
  padding: const EdgeInsets.symmetric(horizontal: 25),
  scrollDirection: Axis.horizontal,
  itemBuilder: (_, index) => Column(
    children: [
      const SizedBox(height: 25),
      Container(
        padding: const EdgeInsets.all(10),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(20),
          color: categories[index]['color'] as Color),
        child: Icon(
          categories[index]['icon'] as IconData,
          color: Colors.white,
          size: 40,
        ),
      ),
      const SizedBox(height: 10),
      Text(
        categories[index]['title'] as String,
        style: TextStyle(
          color: Colors.black.withOpacity(0.7),
          fontWeight: FontWeight.bold,
          fontSize: 16,
        ),
      )
    ],
  ),
  separatorBuilder: (_, index) => const SizedBox(width: 33),
  itemCount: categories.length,
),

Le widget ListView() nous permet donc de définir une liste d'élément qui défile suivant un axe précis, dans notre cas, l'axe horizontal.

Nous définissons plus précisément la direction du défilement avec le champ:

scrollDirection: Axis.horizontal

Nous utilisons ListView.separate afin d'insérer un espace (avec une SizedBox) entre les éléments de notre liste:

separatorBuilder: (_, index) => const SizedBox(width: 33),

Dans la deuxième partie de cette section CategorySection, nous avons le titre de la partie définie avec le widget Text() et en dessus un widget qui liste les jeux populaires PopularGame.

2.5.1 Création du widget PopularGame

Il fait partie de la deuxième partie de notre section et pour le définir, nous créons un nouveau fichier popular.dart dans le répertoire '/lib/pages/home/widgets/'.

Dans à ce widget, nous affichons ici les images de nouveaux jeux grâce au widget Listview():

Les images sont placées ici dans les widgets Card() puis ClipRRect() et le défilement de notre liste se fait également via l'axe horizontal.

Le code complet du widget PopularGame() est le suivant:

import 'package:flutter/material.dart';
import 'package:gamestore/models/game.dart';
import 'package:gamestore/pages/detail/detail.dart';

class PopularGame extends StatelessWidget {
  PopularGame({super.key});

  final List<Game> games = Game.games();

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 200,
      child: ListView.separated(
        padding: const EdgeInsets.symmetric(
          horizontal: 25,
          vertical: 20,
        ),
        scrollDirection: Axis.horizontal,
        itemBuilder: (context, index) => GestureDetector(
          onTap: () => Navigator.of(context).push(
            MaterialPageRoute(
              builder: (context) => DetailPage(games[index]),
            ),
          ),
          child: Card(
            elevation: 5,
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(15),
            ),
            child: Container(
              padding: const EdgeInsets.all(5),
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(15),
              ),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(15),
                child: Image.asset(games[index].bgImage),
              ),
            ),
          ),
        ),
        separatorBuilder: (context, index) => const SizedBox(width: 10),
        itemCount: games.length,
      ),
    );
  }
}

Pour commencer, nous récupérons tout d'abord la liste de nos jeux populaires à afficher:

final List<Game> games = Game.games();

Ce qu'il faut noter également par rapport au widget ListView() est qu'il nous permet de parcourir notre liste de jeux que nous avons définie grâce au model Game définit dans le répertoire '/models/'.

Pour que chaque élément de notre liste soit cliquable, et ouvre la page de détail, nous utilisons le widget GestureDetector():

itemBuilder: (context, index) => GestureDetector(
  onTap: () => Navigator.of(context).push(
    MaterialPageRoute(
      builder: (context) => DetailPage(games[index]),
    ),
  ),
  ...

Notre classe modèle Game est placée dans le répertoire 'lib/models/' et dans le fichier game.dart:

class Game {
  String bgImage;
  String icon;
  String name;
  String type;
  num score;
  num download;
  num review;
  String description;
  List<String> images;

  Game(
    this.bgImage,
    this.icon,
    this.name,
    this.type,
    this.score,
    this.download,
    this.review,
    this.description,
    this.images,
  );

  static List<Game> games() {
    return [
      Game(
        'assets/images/ori1.jpg',
        'assets/images/ori_logo.png',
        'Ori and The Blind Forest',
        'Adventure',
        4.8,
        382,
        324,
        "Ori is stranger to peril, but when a fateful flight puts the owlet ku in har'ms way.Ori is stranger to peril, but when a fateful flight puts the owlet ku in har'ms way.Ori is stranger to peril, but when a fateful flight puts the owlet ku in har'ms way.",
        [
          'assets/images/ori2.jpg',
          'assets/images/ori3.jpg',
          'assets/images/ori4.jpg',
        ],
      ),
      Game(
        'assets/images/rl1.jpg',
        'assets/images/rl_logo.png',
        'Rayman Legends',
        'Adventure',
        4.7,
        226,
        148,
        "Rayman is stranger to peril, but when a fateful flight puts the owlet ku in har'ms way.Ori is stranger to peril, but when a fateful flight puts the owlet ku in har'ms way.Ori is stranger to peril, but when a fateful flight puts the owlet ku in har'ms way.",
        [
          'assets/images/rl2.jpg',
          'assets/images/rl3.jpg',
          'assets/images/rl4.jpg',
          'assets/images/rl5.jpg',
        ],
      ),
    ];
  }
}

2.5.2 Création du widget NewestGame

La troisième partie de notre SectionCategory nous permet d'afficher les nouveaux jeux de notre application:

Pour le définir, nous créons le nouveau fichier newest.dart dans le répertoire 'lib/pages/home/widgets/'.

Container(
  padding: const EdgeInsets.symmetric(horizontal: 25),
  width: 410,
  child: const Text(
    'Newest game',
    style: TextStyle(
      fontWeight: FontWeight.bold,
      fontSize: 20,
    ),
  ),
),
NewestGame(),

Voilà le code complet de widget NewestGame():

import 'package:flutter/material.dart';
import 'package:gamestore/models/game.dart';

class NewestGame extends StatelessWidget {
  NewestGame({super.key});

  // Recuperation de la Liste des jeux a partir du model
  final List<Game> games = Game.games();

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(25),
      child: Column(
        children: games
            .map(
              (game) => Container(
                padding: const EdgeInsets.all(15),
                margin: const EdgeInsets.only(bottom: 20),
                decoration: BoxDecoration(
                  color: Colors.white,
                  borderRadius: BorderRadius.circular(15),
                ),
                child: Row(
                  children: [
                    ClipRRect(
                      borderRadius: BorderRadius.circular(5),
                      child: Image.asset(
                        game.icon,
                        width: 60,
                      ),
                    ),
                    const SizedBox(width: 10),
                    Expanded(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(
                            game.name,
                            style: const TextStyle(
                              fontSize: 18,
                              fontWeight: FontWeight.bold,
                            ),
                          ),
                          const SizedBox(height: 5),
                          Row(
                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
                            children: [
                              Column(
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: [
                                  Text(
                                    game.type,
                                    style: TextStyle(
                                      color: Colors.grey.withOpacity(0.8),
                                    ),
                                  ),
                                  const SizedBox(height: 2),
                                  /* Etoille Review - Notation */
                                  Row(
                                    children: [
                                      const Icon(
                                        Icons.star,
                                        size: 15,
                                        color: Colors.amber,
                                      ),
                                      const Icon(
                                        Icons.star,
                                        size: 15,
                                        color: Colors.amber,
                                      ),
                                      const Icon(
                                        Icons.star,
                                        size: 15,
                                        color: Colors.amber,
                                      ),
                                      const Icon(
                                        Icons.star,
                                        size: 15,
                                        color: Colors.amber,
                                      ),
                                      Icon(
                                        Icons.star,
                                        size: 15,
                                        color: Colors.grey.withOpacity(0.3),
                                      ),
                                    ],
                                  )
                                ],
                              ),
                              /**  Bouton Install*/
                              Container(
                                padding: const EdgeInsets.symmetric(
                                  vertical: 5,
                                  horizontal: 15,
                                ),
                                decoration: BoxDecoration(
                                  color: const Color(0xFF5F67EA),
                                  borderRadius: BorderRadius.circular(15),
                                ),
                                child: const Text(
                                  'Install',
                                  style: TextStyle(
                                    color: Colors.white,
                                  ),
                                ),
                              )
                            ],
                          )
                        ],
                      ),
                    ),
                  ],
                ),
              ),
            )
            .toList(),
      ),
    );
  }
}

Pour commencer, nous déclarons une List de Map contenant toutes les informations d'un jeu:

final List<Game> games = Game.games();

Par la suite, nous allons parcourir notre liste pour afficher les différentes informations à leur place dans un widget Column().

Chaque ligne est placée dans une Row() de deux colonnes, la première contient la photo du jeu et la deuxième un widget Expanded() avec une imbrication de Row() et de Colum().

Enfin, chaque ligne est entourée par le widget Container() qui va nous permettre de styliser chaque bloc d'élément.

2.6 Création de notre bottomNavigationBar

On continue la création de notre page avec l’élément qui se positionne aussi de manière fixe et absolue dans notre écran, la bottomNavigationBar.

Pour ce dernier, nous créons la méthode NavigationBar() que nous appelons dans le champ bottomNavigationBar de notre Scaffold():

Widget NavigationBar() {
    return Container(
      color: const Color(0xfff6f8ff),
      child: Container(
        decoration: BoxDecoration(
          boxShadow: [
            BoxShadow(
                color: Colors.grey.withOpacity(0.2),
                spreadRadius: 5,
                blurRadius: 10),
          ],
        ),
        child: ClipRRect(
          borderRadius: const BorderRadius.only(
            topLeft: Radius.circular(30),
            topRight: Radius.circular(30),
          ),
          child: BottomNavigationBar(
            selectedItemColor: const Color(0xFF5F67EA),
            selectedFontSize: 12,
            unselectedFontSize: 12,
            unselectedItemColor: Colors.grey.withOpacity(0.7),
            type: BottomNavigationBarType.fixed,
            items: [
              const BottomNavigationBarItem(
                label: 'home',
                icon: Icon(
                  Icons.home_rounded,
                  size: 50,
                ),
              ),
              BottomNavigationBarItem(
                label: "Application",
                icon: Container(
                  margin: const EdgeInsets.all(5),
                  padding: const EdgeInsets.all(5),
                  decoration: BoxDecoration(
                    color: Colors.grey.withOpacity(0.2),
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: const Icon(
                    Icons.more_horiz_outlined,
                    size: 30,
                    color: Colors.grey,
                  ),
                ),
              ),
              BottomNavigationBarItem(
                label: "Film",
                icon: Container(
                  margin: const EdgeInsets.all(5),
                  padding: const EdgeInsets.all(5),
                  decoration: BoxDecoration(
                    color: Colors.grey.withOpacity(0.2),
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: const Icon(
                    Icons.play_arrow_rounded,
                    size: 30,
                    color: Colors.grey,
                  ),
                ),
              ),
              BottomNavigationBarItem(
                label: "Book",
                icon: Container(
                  margin: const EdgeInsets.all(5),
                  padding: const EdgeInsets.all(5),
                  decoration: BoxDecoration(
                    color: Colors.grey.withOpacity(0.2),
                    borderRadius: BorderRadius.circular(10),
                  ),
                  child: const Icon(
                    Icons.auto_stories_rounded,
                    size: 30,
                    color: Colors.grey,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

Nous voilà donc rendu au terme de la création de notre page d’accueil, nous pouvons donc passer à la création de la page de Détail.

3. Création de la page de Detail

Dans cette troisième partie du tutoriel, nous allons compléter le design de notre application avec la page de détail de chaque jeu.

Cette page affichera les différentes informations de chaque jeu:

3.1 Architecture de la DetailPage

Le contenu de la page de Détail sera placé dans un fichier différent, afin de bien organiser notre code et le rendre lisible.

Pour créer notre page de détail, nous allons créer un nouveau dossier dans le répertoire pages nommé detail.

Dans le dossier detail, nous créons ensuite le fichier que nous allons appeler “detail.dart”.

Cette page doit aussi être importée dans notre fichier popular.dart afin de permettre la navigation entre les deux pages, ce qui a été fait précédemment.

Une fois notre page créée, nous commençons par importer le package Material qui est indispensable à la création de notre contenu Flutter:

import 'package:flutter/material.dart';

Pour construire notre page de détail, nous allons utiliser le widget Flutter CustomScrollView: https://api.flutter.dev/flutter/widgets/CustomScrollView-class.html

Dans cette partie du tutoriel, nous créons des effets de défilement personnalisés avec le widget CustomScrollView() et son Sliver.

Nous utilisons ici le widget CustomScrollView() car nous souhaitons avoir plus de contrôle sur le contenu de défilement de notre ScrollView().

Le widget CustomScrollView() vous permet d'utiliser directement des Sliver() pour créer des effets de défilement tels que des listes, des grilles et des en-têtes extensibles.

Comme enfant de notre CustomScrollView(), nous définissons une liste de widget en utilisant la propriété slivers.

Ce qu'il faut retenir, c'est que qu'un sliver est une partie d'une zone de défilement que vous pouvez configurer afin qu'elle puisse se comporter d'une certaine manière.

En utilisant des slivers, nous pouvons facilement créer une multitude d'effets de défilement, et ils sont utilisés par toutes les vues défilantes de Flutter:

import 'package:flutter/material.dart';
import 'package:gamestore/models/game.dart';
import 'package:gamestore/pages/detail/widgets/detail_sliver.dart';
import 'package:gamestore/pages/detail/widgets/info.dart';

class DetailPage extends StatelessWidget {
  const DetailPage(this.game, {super.key});

  final Game game;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          SliverPersistentHeader(
            delegate: DetailSliverDelegate(
              game: game,
              expandedHeight: 360,
              roundedContainerHeight: 30,
            ),
          ),
          SliverToBoxAdapter(
            child: GameInfo(game),
          )
        ],
      ),
    );
  }
}

Par exemple, le widget ListView() utilise SliverList() et le widget GridView() utilise SliverGrid().

Dans notre cas, nous créons un effet de zoom de la photo principale du jeu lors du défilement.

Dans notre slivers, nous ajoutons donc les widgets SliverPersistentHeader() et SliverToBoxAdapter().

Le widget SliverPersistentHeader() nous permet donc de définir le header de notre page qui sera constitué en grande partie de l'image principale du jeu.

Le SliverPersistentHeader() a un paramètre requis delegate, pour le fournir, nous créons une classe appelée DetailSliverDelegate() qui étend SliverPersistentHeaderDelegate() et remplace ses méthodes:

SliverPersistentHeader(
  delegate: DetailSliverDelegate(
    game: game,
    expandedHeight: 360,
    roundedContainerHeight: 30,
  ),
),

3.2 Creation du widget DetailSliverDelagate

Pour cela, nous créons le fichier detail_sliver.dart dans le répertoire 'lib/pages/detail/widgets/'.

Ce widget va donc afficher principalement la photo de mise en avant du jeu, ainsi que de définir le bouton de retour et de définir le Container() du bas avec les bords supérieurs arrondis.

Notre classe définie a trois attributs game, expandedHeight et roundedContainerHeight qui seront utilisés par la suite:

import 'package:flutter/material.dart';
import 'package:gamestore/models/game.dart';

class DetailSliverDelegate extends SliverPersistentHeaderDelegate {
  final Game game;
  final double expandedHeight;
  final double roundedContainerHeight;

  DetailSliverDelegate({
    required this.game,
    required this.expandedHeight,
    required this.roundedContainerHeight,
  });

  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Stack(
      children: [
        Image.asset(
          game.bgImage,
          width: MediaQuery.of(context).size.width,
          height: expandedHeight,
          fit: BoxFit.cover,
        ),
        Positioned(
          child: GestureDetector(
            onTap: () => Navigator.of(context).pop(),
            child: Container(
              margin: EdgeInsets.only(
                top: MediaQuery.of(context).padding.top,
                left: 25,
                right: 25,
              ),
              padding: const EdgeInsets.all(10),
              decoration: BoxDecoration(
                color: Colors.grey.withOpacity(0.5),
                shape: BoxShape.circle,
              ),
              child: const Icon(
                Icons.arrow_back_ios_outlined,
                color: Colors.white,
              ),
            ),
          ),
        ),
        Positioned(
          top: expandedHeight - roundedContainerHeight - shrinkOffset,
          child: Container(
            alignment: Alignment.center,
            width: MediaQuery.of(context).size.width,
            height: roundedContainerHeight,
            decoration: const BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.only(
                topLeft: Radius.circular(30),
                topRight: Radius.circular(30),
              ),
            ),
            child: Container(
              alignment: Alignment.center,
              width: 60,
              height: 5,
              color: const Color(0xFF5F67EA),
            ),
          ),
        )
      ],
    );
  }

  @override
  double get maxExtent => expandedHeight;

  @override
  double get minExtent => 0;

  @override
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
    return false;
  }
}

Vous pouvez voir un paramètre shrinkOffset dans la méthode de build, il s'agit d'une distance de [maxExtent] vers [minExtent] représentant la taille actuelle par laquelle le sliver a été rétréci.

Ces deux distances sont précisées à la fin de la création de notre widget, notamment en redéfinissant leurs méthodes appropriées maxExtent et minExtent respectivement.

Notre widget retourne donc un widget Stack() avec comme premier enfant notre image comme mentionné plus haut, dont la hauteur sera fournie à l'appel du widget:

Image.asset(
  game.bgImage,
  width: MediaQuery.of(context).size.width,
  height: expandedHeight,
  fit: BoxFit.cover,
),

Par la suite, nous positionnons le bouton de retour à la page précédente:

Positioned(
  child: GestureDetector(
    onTap: () => Navigator.of(context).pop(),
    child: Container(
      margin: EdgeInsets.only(
        top: MediaQuery.of(context).padding.top,
        left: 25,
        right: 25,
      ),
      padding: const EdgeInsets.all(10),
      decoration: BoxDecoration(
        color: Colors.grey.withOpacity(0.5),
        shape: BoxShape.circle,
      ),
      child: const Icon(
        Icons.arrow_back_ios_outlined,
        color: Colors.white,
      ),
    ),
  ),
),

Pour continuer, nous positionnons donc notre Container() avec les bords supérieurs arrondis ainsi que les éléments le constituant:

Positioned(
  top: expandedHeight - roundedContainerHeight - shrinkOffset,
  child: Container(
    alignment: Alignment.center,
    width: MediaQuery.of(context).size.width,
    height: roundedContainerHeight,
    decoration: const BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.only(
        topLeft: Radius.circular(30),
        topRight: Radius.circular(30),
      ),
    ),
    child: Container(
      alignment: Alignment.center,
      width: 60,
      height: 5,
      color: const Color(0xFF5F67EA),
    ),
  ),
)

Pour déterminer sa position de départ, nous faisons recours aux différentes variable définies plus haut: expandedHeight, roundedContainerHeight et shrinkOffset.

Le premier enfant de notre CustomScrollView() étant créé, nous passons donc à la suite de notre tutoriel donc le but sera de créer son deuxième enfant SliverToBoxAdapter().

SliverToBoxAdapter() est un sliver qui vous permet de déformer tout autre widget qui n'est pas un sliver et de l'utiliser dans une CustomScrollView().

C'est un sliver très utile pour créer des écrans de défilement complexes avec des widgets plus avancés.

Comme enfant a ce SliverToBoxAdapter(), nous allons donner le widget GameInfo() que nous allons créer dans un instant.

3.3 Création du widget GameInfo

Pour créer notre widget, nous commençons par créer le fichier info.dart dans le répertoire 'lib/pages/detail/widgets/'.

Une fois créé, nous commençons par importer tous les fichiers nécessaires, puis retournons dans notre méthode build un Container() avec pour enfant une Column().

C'est cette Column() qui va contenir toutes les sections de cette partie de notre page de détail:

import 'package:flutter/material.dart';
import 'package:gamestore/models/game.dart';
import 'package:gamestore/pages/detail/widgets/description.dart';
import 'package:gamestore/pages/detail/widgets/gallery.dart';
import 'package:gamestore/pages/detail/widgets/header.dart';
import 'package:gamestore/pages/detail/widgets/review.dart';

class GameInfo extends StatelessWidget {
  final Game game;

  const GameInfo(this.game, {super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: Column(
        children: [
          HeaderSection(game),
          GallerySection(game),
          DescriptionSection(game),
          ReviewSection(game)
        ],
      ),
    );
  }
}

3.2 Creation de la HeaderSection

Ici, il est question de présenter les premières informations sur le jeu comme le montre notre design.

Pour commencer, nous créons donc le fichier header.dart dans le répertoire 'lib/pages/detail/widgets/'

Le code complet de cette HeaderSection est le suivant:

import 'package:flutter/material.dart';
import 'package:gamestore/models/game.dart';

class HeaderSection extends StatelessWidget {
  final Game game;
  const HeaderSection(this.game, {super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 25),
      child: Row(
        children: [
          Image.asset(
            game.icon,
            width: 80,
          ),
          const SizedBox(width: 15),
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                game.name,
                style: const TextStyle(
                  fontWeight: FontWeight.bold,
                  fontSize: 20,
                ),
              ),
              const SizedBox(height: 10),
              Text(
                game.type,
                style: TextStyle(
                  color: Colors.grey.withOpacity(0.5),
                ),
              ),
              const SizedBox(height: 5),
              Row(
                children: [
                  Row(
                    children: [
                      const Icon(
                        Icons.star,
                        color: Colors.amber,
                        size: 15,
                      ),
                      Text(
                        game.score.toString(),
                        style: const TextStyle(
                          fontWeight: FontWeight.bold,
                          fontSize: 12,
                        ),
                      ),
                    ],
                  ),
                  const SizedBox(width: 30),
                  Row(
                    children: [
                      const Icon(
                        Icons.star,
                        color: Colors.red,
                        size: 15,
                      ),
                      Text(
                        '${game.download.toString()} k',
                        style: const TextStyle(
                          fontWeight: FontWeight.bold,
                          fontSize: 12,
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ],
          ),
        ],
      ),
    );
  }
}

Nous commençons par importer le modèle Game qui contient tout sur un jeu donné.

Nous nous contentons ici d'afficher ses données en nous servant de la variable game déclarée en début de notre fichier.

Notre section en englobé par un Container() ayant pour enfant une Row() qui nous permet de placer les différents éléments.

Le premier enfant de notre Row() est donc une image représentant le logo du jeu et son deuxième enfant, une Colum() qui imbrique d'autre Colum() et Row() afin de bien placer nos différentes informations sur le jeu.

Les widgets Text() et Icon() sont également utilisés pour reproduire notre design.

3.3 Création de la GallerySection

On continue avec l’implémentation de notre page de détail en créant cette section de galerie d'images:

Le widget GallerySection() contiendra notamment une ListView() constitué des images du jeu et permettra de les scroller à l'horizontal:

import 'package:flutter/material.dart';
import 'package:gamestore/models/game.dart';

class GallerySection extends StatelessWidget {
  final Game game;
  const GallerySection(this.game, {super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 200,
      padding: const EdgeInsets.symmetric(vertical: 20),
      child: ListView.separated(
        padding: const EdgeInsets.symmetric(horizontal: 25),
        scrollDirection: Axis.horizontal,
        itemBuilder: (context, index) => SizedBox(
          width: 250,
          child: ClipRRect(
            borderRadius: BorderRadius.circular(15),
            child: Image.asset(
              game.images[index],
              fit: BoxFit.cover,
            ),
          ),
        ),
        separatorBuilder: (context, index) => const SizedBox(width: 15),
        itemCount: game.images.length,
      ),
    );
  }
}

Cette section contient exclusivement une ListView.separated() permettant de construire notre galerie d'image.

3.4 Création de la DescriptionSection

Cette section est la troisième de notre widget GameInfo(), et nous permettra de définir le texte de description du jeu.

Pour faire cela, nous utilisons le widget ReadMoreText() qui nous permet de définir le texte limité en taille pour l'affichage.

Le widget ReadMoreText() est fourni par le package readmore que nous avons installé dans la première partie de ce tutoriel.

Le code complet de ce widget DescriptionSection():

import 'package:flutter/material.dart';
import 'package:gamestore/models/game.dart';
import 'package:readmore/readmore.dart';

class DescriptionSection extends StatelessWidget {
  final Game game;
  const DescriptionSection(this.game, {super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.maxFinite,
      padding: const EdgeInsets.symmetric(horizontal: 25),
      child: ReadMoreText(
        game.description,
        trimLines: 2,
        colorClickableText: const Color(0xFF5F67EA),
        trimMode: TrimMode.Line,
        trimCollapsedText: 'more',
        trimExpandedText: ' reduire',
        style: TextStyle(
          color: Colors.grey.withOpacity(0.7),
          height: 1.5,
        ),
      ),
    );
  }
}

3.5 Création de la ReviewSection

On termine avec la ReviewSection qui nous permet d’afficher les avis sur le jeu et le bouton d'installation.

Pour commencer, nous déclarons notre attribut game qui représente notre modèle.

Par la suite, nous allons tout simplement retourner un Container() dans le build, qui aura comme enfant une Column() avec tous les éléments de cette section.

Notre Column() est constituée de deux Row() et d'une SizedBox():

  • La première Row() nous sert pour le Text() du titre et le Text() à droite du titre
  • La deuxième Row() elle nous permet de positionner le score des reviews et les icônes de couleur jaune

Le SizedBox() lui permet de définir la zone du bouton d'installation du jeu ElevatedButton().

Le code complet de notre ReviewSection() est le suivant:

import 'package:flutter/material.dart';
import 'package:gamestore/models/game.dart';

class ReviewSection extends StatelessWidget {
  final Game game;
  const ReviewSection(this.game, {super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(25),
      child: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: const [
              Text(
                'Ratings and review',
                style: TextStyle(
                  fontWeight: FontWeight.bold,
                ),
              ),
              Text(
                'view',
                style: TextStyle(
                  fontSize: 12,
                  color: Colors.grey,
                ),
              ),
            ],
          ),
          const SizedBox(height: 15),
          Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                game.score.toString(),
                style: const TextStyle(
                  fontWeight: FontWeight.bold,
                  fontSize: 48,
                ),
              ),
              const SizedBox(width: 15),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Row(
                    children: [
                      const Icon(
                        Icons.star,
                        size: 25,
                        color: Colors.amber,
                      ),
                      const Icon(
                        Icons.star,
                        size: 25,
                        color: Colors.amber,
                      ),
                      const Icon(
                        Icons.star,
                        size: 25,
                        color: Colors.amber,
                      ),
                      const Icon(
                        Icons.star,
                        size: 25,
                        color: Colors.amber,
                      ),
                      Icon(Icons.star,
                          size: 25, color: Colors.grey.withOpacity(0.3)),
                    ],
                  ),
                  const SizedBox(height: 5),
                  Text(
                    '${game.review.toString()} review',
                    style: const TextStyle(
                      color: Colors.grey,
                    ),
                  ),
                ],
              ),
            ],
          ),
          const SizedBox(height: 5),
          SizedBox(
            width: double.maxFinite,
            height: 40,
            child: ElevatedButton(
              style: ElevatedButton.styleFrom(
                elevation: 0,
                backgroundColor: const Color(0xFF5F67EA),
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(20),
                ),
              ),
              onPressed: () {},
              child: const Text(
                'Install',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 18,
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Conclusion

Nous sommes donc rendus au terme de ce tutoriel Flutter dans lequel nous avons abordé de nombreuses notions de design et de nouveau widget proposé par Flutter.

Ce type de tutoriel vous permet de compléter la formation Flutter Révolution avec du contenu plus orienté sur le design pratique avec Flutter.

C’est aussi l’occasion pour un plus grand nombre de personnes de découvrir Flutter et de tester en direct son fonctionnement.

Si ce n’est pas déjà fait, commencez le cours gratuit Flutter pour installer Flutter et le configurer sur votre ordinateur.

Rejoignez le cours complet Flutter Révolution si vous souhaitez créer des applications complètes avec Firebase, Google Maps et Stripe.