Flutter: comment construire un jeu de quiz

UPDATE (01/06/2019): vous pouvez trouver une version alternative en utilisant le paquet de reconstruction ici.

introduction

Dans cet article, je voudrais vous montrer comment j'ai construit cet exemple de jeu-questionnaire avec Flutter et le paquet Frideos (consultez ces deux exemples pour savoir comment cela fonctionne exemple1, exemple2). C'est un jeu assez simple mais il couvre divers arguments intéressants.

L'application dispose de quatre écrans:

  • Une page principale où l’utilisateur choisit une catégorie et commence la partie.
  • Une page de paramètres dans laquelle l'utilisateur peut sélectionner le nombre de questions, le type de base de données (locale ou distante), le temps imparti pour chaque question et la difficulté.
  • Une page de trivia où sont affichées les questions, le score, le nombre de corrections, de fautes et de réponses.
  • Une page de résumé qui montre toutes les questions avec les bonnes / mauvaises réponses.

C'est le résultat final:

Vous pouvez voir un meilleur gif ici.
  • Partie 1: Configuration du projet
  • Partie 2: Architecture de l'application
  • Partie 3: API et JSON
  • Partie 4: Page d'accueil et autres écrans
  • Partie 5: TriviaBloc
  • Partie 6: Animations
  • Partie 7: page de résumé
  • Conclusion
Partie 1 - Configuration du projet

1 - Créer un nouveau projet Flutter:

Flutter créer votre_nom_projet

2 - Editez le fichier “pubspec.yaml” et ajoutez les paquets http et frideos:

dépendances:
  battement:
    sdk: flutter
  http: ^ 0.12.0
  frideos: ^ 0.6.0

3- Supprimer le contenu du fichier main.dart

4- Créez la structure du projet comme image suivante:

Détails de la structure

  • API: il s’agira des fichiers de fléchettes permettant de gérer l’API de la «base de données Open trivia» et d’une fausse API pour les tests locaux: api_interface.dart, mock_api.dart, trivia_api.dart.
  • Blocs: l'emplacement du seul BLoC de l'application trivia_bloc.dart.
  • Modèles: appstate.dart, category.dart, models.dart, question.dart, theme.dart, trivia_stats.dart.
  • Écrans: main_page.dart, settings_page.dart, summary_page.dart, trivia_page.dart.
Partie 2 - Architecture de l'application

Dans mon dernier article, j'avais décrit différentes méthodes pour envoyer et partager des données sur plusieurs widgets et pages. Dans ce cas, nous allons utiliser une approche un peu plus avancée: une instance d’une classe singleton appelée appState sera fournie à l’arborescence des widgets en utilisant un fournisseur InheritedWidget (AppStateProvider), qui conservera l’état de l’application, logique, et l’instance du seul BLoC qui gère la partie «quiz» de l’application. Donc, à la fin, ce sera une sorte de mélange entre le motif singleton et le motif BLoC.

Dans chaque widget, il est possible d'obtenir l'instance de la classe AppState en appelant:

final appState = AppStateProvider.of  (contexte);

1 - main.dart

C'est le point d'entrée de l'application. La classe App est un widget sans état où elle est déclarée l'instance de la classe AppState et où, à l'aide de AppStateProvider, elle est ensuite fournie à l'arborescence des widgets. L'instance appState sera supprimée, fermant tous les flux, selon la méthode de la classe AppStateProvider.

Le widget MaterialApp est encapsulé dans un widget ValueBuilder de sorte que, chaque fois qu'un nouveau thème est sélectionné, l'arborescence complète des widgets se reconstruit, mettant à jour le thème.

2 - Gestion de l'Etat

Comme indiqué précédemment, l'instance appState conserve l'état de l'application. Cette classe sera utilisée pour:

  • Paramètres: thème actuel utilisé, chargez-le / enregistrez-le avec les SharedPreferences. Implémentation de l'API, fictive ou distante (à l'aide de l'API de opentdb.com). L'heure fixée pour chaque question.
  • Affichage de l'onglet actuel: page d'accueil, trivia, résumé.
  • Chargement des questions.
  • (si sur une API distante) Stocke les paramètres de la catégorie, du nombre et de la difficulté des questions.

Dans le constructeur de la classe:

  • _createThemes construit les thèmes de l'application.
  • _loadCategories charge les catégories de questions à choisir dans la liste déroulante de la page principale.
  • countdown est un StreamedTransformed du paquet frideos de type , utilisé pour extraire de la zone de texte la valeur permettant de définir le compte à rebours.
  • questionsAmount contient le nombre de questions à afficher pendant le jeu-questionnaire (par défaut 5).
  • L'instance de classTriviaBloc est initialisée en lui transmettant les flux, le handle du compte à rebours, la liste des questions et la page à afficher.
Partie 3 - API et JSON

Pour permettre à l'utilisateur de choisir entre une base de données locale ou distante, j'ai créé l'interface QuestionApi avec deux méthodes et deux classes qui l'implémentent: MockApi et TriviaApi.

classe abstraite QuestionsAPI {
  Future  getCategories (StreamedList  catégories);
  
  Future  getQuestions (
    {StreamedList  questions,
     nombre int,
     Catégorie catégorie,
     QuestionDifficulté difficulté,
     Type de question});
}

L'implémentation de MockApi est définie par défaut (elle peut être modifiée dans la page des paramètres de l'application) dans l'appState:

// API
QuestionsAPI api = MockAPI ();
final apiType = StreamedValue  (initialData: ApiType.mock);

Alors que apiType est juste une énumération pour gérer le changement de la base de données sur la page des paramètres:

énumération ApiType {mock, remote}

mock_api.dart:

trivia_api.dart:

1 - Sélection de l'API

Dans la page des paramètres, l'utilisateur peut sélectionner la base de données à utiliser via un menu déroulant:

ValueBuilder  (
  en streaming: appState.apiType,
  constructeur: (contexte, instantané) {
    Renvoie DropdownButton  (
      valeur: snapshot.data,
      onChanged: appState.setApiType,
      articles: [
        const DropdownMenuItem  (
          valeur: ApiType.mock,
          child: Text (‘Demo’),
        ),
        const DropdownMenuItem  (
          valeur: ApiType.remote,
          child: Text («opentdb.com»),
       ),
    ]);
}),

Chaque fois qu'une nouvelle base de données est sélectionnée, la méthode setApiType change l'implémentation de l'API et les catégories sont mises à jour.

void setApiType (type ApiType) {
  if (apiType.value! = type) {
    apiType.value = type;
    if (type == ApiType.mock) {
      api = MockAPI ();
    } autre {
      api = TriviaAPI ();
    }
    _loadCategories ();
  }
}

2 - Catégories

Pour obtenir la liste des catégories, nous appelons cette URL:

https://opentdb.com/api_category.php

Extrait de réponse:

{"trivia_categories": [{"id": 9, "nom": "Connaissances générales"}, {"id": 10, "nom": "Divertissement: Livres"}]

Donc, après avoir décodé le JSON en utilisant la fonction jsonDecode de la bibliothèque dart: convert:

final jsonResponse = convert.jsonDecode (response.body);

nous avons cette structure:

  • jsonResponse ['trivia_categories']: liste des catégories
  • jsonResponse ['trivia_categories'] [INDEX] ['id']: id de la catégorie
  • jsonResponse ['trivia_categories'] [INDEX] ['name']: nom de la catégorie

Donc, le modèle sera:

classe Catégorie {
  Catégorie ({this.id, this.name});
  fabrique Category.fromJson (Map  json) {
    return Category (id: json [‘id’], nom: json [‘name’]);
  }
  int id;
  Nom de chaîne;
}

3 - Questions

Si nous appelons cette URL:

https://opentdb.com/api.php?amount=2&difficulty=medium&type=multiple

ce sera la réponse:

{"code_réponse": 0, "résultats": [{"catégorie": "Divertissement: musique", "type": "multiples", "difficulté": "moyen", "question": "Qu'est-ce qu'un artiste français \ / groupe est connu pour jouer sur l'instrument midi "Launchpad"? ", correct_answer": "Madeon", "incorrect_answers": ["Daft Punk", "Disclosure", "David Guetta"]}, {"category": " Sports "," type ":" multiple "," difficulté ":" moyen "," question ":" Qui a remporté le championnat national de football universitaire (CFP)? "," Correct_answer ":" Ohio State Buckeyes "," incorrect_answers ": [" Alabama Crimson Tide "," Tigres Clemson "," Badgers du Wisconsin "]}]}}

Dans ce cas, en décodant le JSON, nous avons cette structure:

  • jsonResponse ['results']: liste de questions.
  • jsonResponse ['résultats'] [INDEX] ['catégorie']: la catégorie de la question.
  • jsonResponse ['results'] [INDEX] ['type']: type de question, multiple ou booléen.
  • jsonResponse ['results'] [INDEX] ['question']: la question.
  • jsonResponse ['résultats'] [INDEX] ['correct_answer']: la réponse correcte.
  • jsonResponse ['results'] [INDEX] ['incorrect_answers']: liste des réponses incorrectes.

Modèle:

classe QuestionModel {
  QuestionModel ({this.question, this.correctAnswer, this.incorrectAnswers});
  QuestionModel.fromJson (Carte  json) {
    retourne QuestionModel (
      question: json [«question»],
      correctAnswer: json ['correct_answer'],
      incorrectAnswers: (json ['incorrect_answers'] en tant que liste)
        .map ((réponse) => answer.toString ())
        .lister());
  }
  Question de chaîne;
  String correctAnswer;
  Liste  incorrectAnswers;
}

4 - Classe TriviaApi

La classe implémente les deux méthodes de l'interface QuestionsApi, getCategories et getQuestions:

  • Obtenir les catégories

Dans la première partie, le JSON est décodé puis en utilisant le modèle, il est analysé pour obtenir une liste de type Catégorie, enfin, le résultat est donné aux catégories (une StreamedList de type Catégorie utilisée pour renseigner la liste des catégories dans la page principale ).

final jsonResponse = convert.jsonDecode (response.body);
résultat final = (jsonResponse [‘trivia_categories’] en tant que liste)
.map ((catégorie) => Catégorie.fromJson (catégorie));
catégories.value = [];
catégories
..addAll (résultat)
..addElement (Category (id: 0, name: ‘Any category’));
  • Obtenir les questions

Quelque chose de similaire se produit pour les questions, mais dans ce cas, nous utilisons un modèle (Question) pour «convertir» la structure d'origine (QuestionModel) du JSON en une structure plus pratique à utiliser dans l'application.

final jsonResponse = convert.jsonDecode (response.body);
résultat final = (jsonResponse ['résultats'] sous forme de liste)
.map ((question) => QuestionModel.fromJson (question));
questions.value = result
.map ((question) => Question.fromQuestionModel (question))
.lister();

5 - Classe de questions

Comme indiqué dans le paragraphe précédent, l'application utilise une structure différente pour les questions. Dans cette classe, nous avons quatre propriétés et deux méthodes:

Question de classe {
  Question ({this.question, this.answers, this.correctAnswerIndex});
  factory Question.fromQuestionModel (modèle QuestionModel) {
    Liste finale  answers = []
      ..add (model.correctAnswer)
      ..addAll (model.incorrectAnswers)
      .. shuffle ();
    final index = answers.indexOf (model.correctAnswer);
    return Question (question: modèle.question, réponses: réponses, correctAnswerIndex: index);
  }
  Question de chaîne;
  Répertoriez  les réponses;
  int correctAnswerIndex;
  int selectedAnswerIndex;
  bool isCorrect (Réponse de chaîne) {
    return answers.indexOf (answer) == correctAnswerIndex;
  }
  bool isChosen (Réponse en chaîne) {
    retourne answers.indexOf (answer) == selectedAnswerIndex;
  }
}

Dans l’usine, la liste des réponses est d’abord remplie avec toutes les réponses, puis mélangée pour que l’ordre soit toujours différent. Ici, nous obtenons même l'index de la réponse correcte afin que nous puissions l'assigner à correctAnswerIndex via le constructeur Question. Les deux méthodes sont utilisées pour déterminer si la réponse passée en paramètre est la bonne ou celle choisie (elles seront mieux expliquées dans l'un des paragraphes suivants).

Partie 4 - Page d'accueil et autres écrans

1 - Widget HomePage

Dans AppState, vous pouvez voir une propriété nommée tabControllert qui est une StreamedValue de type AppTab (une enum), utilisée pour diffuser la page à afficher dans le widget HomePage (sans état). Cela fonctionne de la manière suivante: chaque fois qu’un ensemble AppTabis différent, le widget ValueBuilder reconstruit l’écran affichant la nouvelle page.

  • Classe d'accueil:
Construction du widget (contexte BuildContext) {
  final appState = AppStateProvider.of  (contexte);
  
  retourne ValueBuilder (
    streamé: appState.tabController,
    constructeur: (context, snapshot) => Scaffold (
      appBar: snapshot.data! = AppTab.main? null: AppBar (),
      tiroir: DrawerWidget (),
      body: _switchTab (snapshot.data, appState),
      ),
  )
}

N.B. Dans ce cas, l'appBar ne sera affiché que sur la page principale.

  • méthode _switchTab:
Widget _switchTab (onglet AppTab, AppState appState) {
  commutateur (onglet) {
    case AppTab.main:
      renvoyer MainPage ();
      Pause;
    case AppTab.trivia:
      renvoyer TriviaPage ();
      Pause;
    case AppTab.summary:
      return SummaryPage (stats: appState.triviaBloc.stats);
      Pause;
    défaut:
    renvoyer MainPage ();
  }
}

2 - SettingsPage

Dans la page Paramètres, vous pouvez choisir le nombre de questions à afficher, la difficulté, la durée du compte à rebours et le type de base de données à utiliser. Dans la page principale, vous pouvez sélectionner une catégorie et enfin commencer le jeu. Pour chacun de ces paramètres, j'utilise StreamedValue afin que le widget ValueBuilder puisse actualiser la page chaque fois qu'une nouvelle valeur est définie.

Partie 5 - TriviaBloc

La logique commerciale de l'application se trouve dans le seul BLoC nommé TriviaBloc. Voyons cette classe.

Dans le constructeur nous avons:

TriviaBloc ({this.countdownStream, this.questions, this.tabController}) {
// Obtenir les questions de l'API
  questions.onChange ((data) {
    if (data.isNotEmpty) {
      questions finales = data..shuffle ();
     _startTrivia (questions);
    }
  });
  countdownStream.outTransformed.listen ((data) {
     compte à rebours = int.parse (données) * 1000;
  });
}

Ici, la propriété questions (une StreamedList de type Question) écoute les modifications, lorsqu'une liste de questions est envoyée au flux appelé par la méthode _startTrivia à partir du jeu.

À la place, countdownStream écoute simplement les modifications apportées à la valeur du compte à rebours dans la page Paramètres afin de pouvoir mettre à jour la propriété de compte à rebours utilisée dans la classe TriviaBloc.

  • _startTrivia (liste données)

Cette méthode commence le jeu. Fondamentalement, il réinitialise l'état des propriétés, définit la première question à afficher et, après un seconde, appelle la méthode playTrivia.

void _startTrivia (Liste  data) {
  indice = 0;
  triviaState.value.questionIndex = 1;
  // Pour afficher la page principale et les boutons de résumé
  triviaState.value.isTriviaEnd = false;
  // Réinitialiser les statistiques
  stats.reset ();
  // Pour définir la question initiale (dans ce cas, le compte à rebours
  // l'animation de la barre ne démarre pas).
  currentQuestion.value = data.first;
  Minuterie (Durée (millisecondes: 1000), () {
    // Affecter la valeur true à cet indicateur lors du changement de question
    // l'animation de la barre de compte à rebours commence.
    triviaState.value.isTriviaPlaying = true;
  
    // Diffuser à nouveau la première question avec la barre de compte à rebours
    // animation.
    currentQuestion.value = data [index];
  
    playTrivia ();
  });
}

triviaState est une StreamedValue de type TriviaState, une classe utilisée pour gérer l'état du questionnaire.

classe TriviaState {
  bool isTriviaPlaying = false;
  bool isTriviaEnd = false;
  bool isAnswerChosen = false;
  int questionIndex = 1;
}
  • playTrivia ()

Lorsque cette méthode est appelée, un minuteur met à jour périodiquement le minuteur et vérifie si le temps écoulé est supérieur au réglage du compte à rebours. Dans ce cas, il annule le minuteur, marque la question en cours comme non répondue et appelle la méthode _nextQuestion pour afficher une nouvelle question. .

void playTrivia () {
  timer = Minuteur.periodique (Durée (millisecondes: refreshTime), (Minuteur t) {
    currentTime.value = refreshTime * t.tick;
    if (currentTime.value> countdown) {
      currentTime.value = 0;
      timer.cancel ();
      notAnswered (currentQuestion.value);
     _question suivante();
    }
  });
}
  • non répondu (question question)

Cette méthode appelle la méthode addNoAnswer de l'instance de statistiques de la classe TriviaStats pour chaque question sans réponse, afin de mettre à jour les statistiques.

vide pas de réponse (question question) {
  stats.addNoAnswer (question);
}
  • _question suivante()

Dans cette méthode, l'index des questions est augmenté et s'il y a d'autres questions dans la liste, une nouvelle question est envoyée au flux currentQuestion afin que ValueBuilder mette à jour la page avec la nouvelle question. Sinon, la méthode _endTriva est appelée, mettant fin à la partie.

void _nextQuestion () {
  index ++;
   if (index 
  • endTrivia ()

Ici, la minuterie est annulée et le drapeau isTriviaEnd est défini sur true. Après 1,5 seconde après la fin du jeu, la page de résumé est affichée.

void _endTrivia () {
  // RÉINITIALISER
  timer.cancel ();
  currentTime.value = 0;
  triviaState.value.isTriviaEnd = true;
  triviaState.refresh ();
  stopTimer ();
  Minuterie (Durée (millisecondes: 1500), () {
     // ceci est réinitialisé ici pour ne pas déclencher le début du
     // animation du compte à rebours en attendant la page de résumé.
     triviaState.value.isAnswerChosen = false;
     // Afficher la page de résumé après 1.5s
     tabController.value = AppTab.summary;
     // Efface la dernière question pour qu'elle n'apparaisse pas
     // dans le prochain match
     currentQuestion.value = null;
  });
}
  • checkAnswer (question question, réponse en chaîne)

Lorsque l'utilisateur clique sur une réponse, cette méthode vérifie si elle est correcte et appelle la méthode pour ajouter un score positif ou négatif aux statistiques. Ensuite, le chronomètre est réinitialisé et une nouvelle question est chargée.

void checkAnswer (Question question, réponse en chaîne) {
  if (! triviaState.value.isTriviaEnd) {
     question.chosenAnswerIndex = question.answers.indexOf (réponse);
     if (question.isCorrect (answer)) {
       stats.addCorrect (question);
     } autre {
       stats.addWrong (question);
     }
     timer.cancel ();
     currentTime.value = 0;
    _question suivante();
  }
}
  • stopTimer ()

Lorsque cette méthode est appelée, l'heure est annulée et l'indicateur isAnswerChosen est défini sur true pour indiquer au compte à rebours d'arrêter l'animation.

annuler stopTimer () {
  // Arrête le chronomètre
  timer.cancel ();
  // En définissant cet indicateur sur true, l'animation du compte à rebours s'arrête
  triviaState.value.isAnswerChosen = true;
  triviaState.refresh ();
}
  • onChosenAnswer (Réponse de chaîne)

Lorsqu'une réponse est choisie, le minuteur est annulé et l'index de la réponse est enregistré dans la propriété selectedAnswerIndex de l'occurrence answersAnimation de la classe AnswerAnimation. Cet index est utilisé pour placer cette réponse en dernier sur la pile de widgets pour éviter qu'elle ne soit couverte par toutes les autres réponses.

void onChosenAnswer (Réponse de chaîne) {
  selectedAnswer = answer;
  stopTimer ();
  // Configure la réponse choisie afin que le widget de réponse puisse la mettre en dernier sur la liste.
  // pile.
  
  answersAnimation.value.chosenAnswerIndex =
  currentQuestion.value.answers.indexOf (answer);
  answersAnimation.refresh ();
}

AnswerAnimation classe:

class AnswerAnimation {
  AnswerAnimation ({this.chosenAnswerIndex, this.startPlaying});
  int selectedAnswerIndex;
  bool startPlaying = false;
}
  • onChosenAnswerAnimationEnd ()

Lorsque l'animation des réponses se termine, l'indicateur isAnswerChosen est défini sur false, afin de permettre à l'animation du compte à rebours de redémarrer, puis est appelée la méthode checkAnswer pour vérifier si la réponse est correcte.

void onChosenAnwserAnimationEnd () {
  // Réinitialise le drapeau pour que l'animation du compte à rebours puisse commencer
  triviaState.value.isAnswerChosen = false;
  triviaState.refresh ();
  checkAnswer (currentQuestion.value, selectedAnswer);
}
  • Classe TriviaStats

Les méthodes de cette classe sont utilisées pour attribuer le score. Si l'utilisateur sélectionne la bonne réponse, le score est augmenté de dix points et les questions actuelles ajoutées à la liste des corrections afin que celles-ci puissent être affichées dans la page de résumé. Si une réponse n'est pas correcte, le score est réduit de quatre, enfin si pas de réponse, le score est diminué de deux points.

classe TriviaStats {
  TriviaStats () {
    corrige = [];
    torts = [];
    noAnswered = [];
    score = 0;
  }
Liste  corrige;
  Liste  torts;
  Liste  noAnswered;
  int score;
void addCorrect (Question question) {
    corrects.add (question);
    score + = 10;
  }
void addWrong (Question question) {
    faux.ajouter (question);
    score - = 4;
  }
void addNoAnswer (Question question) {
    noAnswered.add (question);
    score - = 2;
  }
void reset () {
    corrige = [];
    torts = [];
    noAnswered = [];
    score = 0;
  }
}
Partie 6 - Animations

Dans cette application, nous avons deux types d'animations: la barre animée en dessous des réponses indique le temps restant pour répondre, et l'animation jouée lorsqu'une réponse est choisie.

1 - Animation de la barre de compte à rebours

C'est une animation assez simple. Le widget prend en paramètre la largeur de la barre, la durée et l'état du jeu. L'animation commence chaque fois que le widget est reconstruit et s'arrête si une réponse est choisie.

La couleur initiale est verte et devient progressivement rouge, indiquant que l'heure est sur le point de se terminer.

2 - Animation des réponses

Cette animation est lancée chaque fois qu'une réponse est choisie. Avec un simple calcul de la position des réponses, chacune d’elles est progressivement déplacée vers la position de la réponse choisie. Pour que la réponse choisie reste en haut de la pile, elle est remplacée par le dernier élément de la liste des widgets.

// Echange le dernier élément avec la réponse choisie afin qu'il puisse
// être affiché en dernier sur la pile.
dernier dernier = widgets.last;
final choisi = widgets [widget.answerAnimation.chosenAnswerIndex]; selectedIndex final = widgets.indexOf (choisi);
widgets.last = choisi;
widgets [selectedIndex] = last;
conteneur de retour (
   enfant: Stack (
      enfants: widgets,
   ),
)

La couleur des cases devient verte si la réponse est correcte et rouge si elle est incorrecte.

var newColor;
if (isCorrect) {
  newColor = Colors.green;
} autre {
  newColor = Colors.red;
}
colorAnimation = ColorTween (
  commencer: answerBoxColor,
  fin: newColor,
) .animate (contrôleur);
attendez controller.forward ();
Partie 7 - page de résumé

1 - Sommaire

Cette page prend en paramètre une instance de la classe TriviaStats, qui contient la liste des questions correctes, des erreurs et des réponses sans réponse, et construit un ListView montrant chaque question au bon endroit. La question actuelle est ensuite transmise au widget SummaryAnswers qui construit la liste des réponses.

2 - Résumé Réponses

Ce widget prend en paramètre l'index de la question et la question elle-même, et construit la liste des réponses. La réponse correcte est colorée en vert, tandis que si l'utilisateur choisit une réponse incorrecte, celle-ci est mise en surbrillance en rouge, indiquant les réponses correctes et incorrectes.

Conclusion

Cet exemple est loin d’être parfait ou définitif, mais c’est un bon point de départ. Par exemple, vous pouvez l’améliorer en créant une page de statistiques avec le score de chaque partie ou une section dans laquelle l’utilisateur peut créer des questions et des catégories personnalisées (cela peut être un excellent exercice pour s’entraîner avec les bases de données). J'espère que cela peut être utile, n'hésitez pas à proposer des améliorations, des suggestions ou autres.

Vous pouvez trouver le code source dans ce référentiel GitHub.