Flutter: come costruire un gioco a quiz

AGGIORNAMENTO (01/06/2019): puoi trovare una versione alternativa usando il pacchetto di ricostruzione qui.

introduzione

In questo articolo, vorrei mostrarti come ho creato questo esempio di gioco a quiz con Flutter e il pacchetto Frideos (dai un'occhiata a questi due esempi per scoprire come funziona example1, example2). È un gioco abbastanza semplice ma copre vari argomenti interessanti.

L'app ha quattro schermate:

  • Una pagina principale in cui l'utente sceglie una categoria e inizia il gioco.
  • Una pagina delle impostazioni in cui l'utente può selezionare il numero di domande, il tipo di database (locale o remoto), il limite di tempo per ogni domanda e la difficoltà.
  • Una pagina di curiosità in cui vengono visualizzate le domande, il punteggio, il numero di correzioni, errori e non risposte.
  • Una pagina di riepilogo che mostra tutte le domande con le risposte corrette / errate.

Questo è il risultato finale:

Puoi vedere una gif migliore qui.
  • Parte 1: installazione del progetto
  • Parte 2: architettura dell'app
  • Parte 3: API e JSON
  • Parte 4: Homepage e altre schermate
  • Parte 5: TriviaBloc
  • Parte 6: Animazioni
  • Parte 7: pagina di riepilogo
  • Conclusione
Parte 1 - Installazione del progetto

1 - Crea un nuovo progetto flutter:

flutter crea your_project_name

2 - Modifica il file "pubspec.yaml" e aggiungi i pacchetti http e frideos:

dipendenze:
  svolazzare:
    sdk: flutter
  http: ^ 0.12.0
  affreschi: ^ 0.6.0

3- Elimina il contenuto del file main.dart

4- Creare la struttura del progetto come la seguente immagine:

Dettagli struttura

  • API: ecco i file delle freccette per gestire l'API del "Database aperto trivia" e un'API finta per i test locali: api_interface.dart, mock_api.dart, trivia_api.dart.
  • Blocs: il posto dell'unico BLoC dell'app trivia_bloc.dart.
  • Modelli: appstate.dart, category.dart, models.dart, question.dart, theme.dart, trivia_stats.dart.
  • Schermate: main_page.dart, settings_page.dart, summary_page.dart, trivia_page.dart.
Parte 2 - Architettura dell'app

Nel mio ultimo articolo, ho scritto su diversi modi per inviare e condividere dati su più widget e pagine. In questo caso, useremo un approccio un po 'più avanzato: un'istanza di una classe singleton denominata appState verrà fornita alla struttura dei widget utilizzando un provider InheritedWidget (AppStateProvider), questo conterrà lo stato dell'app, alcune attività logica e l'istanza dell'unico BLoC che gestisce la "parte del quiz" dell'app. Quindi, alla fine, sarà una sorta di mix tra il pattern singleton e BLoC.

All'interno di ogni widget è possibile ottenere l'istanza della classe AppState chiamando:

final appState = AppStateProvider.of  (contesto);

1 - main.dart

Questo è il punto di ingresso dell'app. La classe App è un widget senza stato in cui viene dichiarata l'istanza della classe AppState e dove, utilizzando AppStateProvider, questo viene quindi fornito alla struttura dei widget. L'istanza di appState verrà eliminata, chiudendo tutti i flussi, nel metodo dispose della classe AppStateProvider.

Il widget MaterialApp è racchiuso in un widget ValueBuilder in modo che, ogni volta che viene selezionato un nuovo tema, l'intero albero dei widget venga ricostruito, aggiornando il tema.

2 - Gestione statale

Come detto prima, l'istanza di appState contiene lo stato dell'app. Questa classe verrà utilizzata per:

  • Impostazioni: tema corrente utilizzato, caricarlo / salvarlo con SharedPreferences. Implementazione API, finta o remota (utilizzando l'API di opentdb.com). Il tempo fissato per ogni domanda.
  • Mostra la scheda corrente: pagina principale, curiosità, riepilogo.
  • Caricamento delle domande.
  • (se sull'API remota) Memorizza le impostazioni di categoria, numero e difficoltà delle domande.

Nel costruttore della classe:

  • _createThemes crea i temi dell'app.
  • _loadCategories carica le categorie delle domande da scegliere nel menu a discesa della pagina principale.
  • countdown è uno StreamedTransformed del pacchetto frideos di tipo , utilizzato per ottenere dal campo di testo il valore per impostare il conto alla rovescia.
  • questionsAmount contiene il numero di domande da mostrare durante il gioco a quiz (per impostazione predefinita 5).
  • L'istanza di classTriviaBloc viene inizializzata, passando ad essa i flussi gestiscono il conto alla rovescia, l'elenco delle domande e la pagina da mostrare.
Parte 3 - API e JSON

Per consentire all'utente di scegliere tra un database locale e uno remoto, ho creato l'interfaccia QuestionApi con due metodi e due classi che lo implementano: MockApi e TriviaApi.

Domande sulla classe astratta
  Future  getCategories (categorie StreamedList );
  
  Future  getQuestions (
    {StreamedList  domande,
     numero int,
     Categoria categoria,
     Domanda Difficoltà,
     Tipo QuestionType});
}

L'implementazione di MockApi è impostata per impostazione predefinita (può essere modificata nella pagina delle impostazioni dell'app) in appState:

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

Mentre apiType è solo un enum per gestire la modifica del database nella pagina delle impostazioni:

enum ApiType {finto, remoto}

mock_api.dart:

trivia_api.dart:

1 - Selezione API

Nella pagina delle impostazioni l'utente può selezionare quale database utilizzare tramite un menu a discesa:

ValueBuilder  (
  in streaming: appState.apiType,
  costruttore: (contesto, istantanea) {
    return DropdownButton  (
      valore: snapshot.data,
      onChanged: appState.setApiType,
      elementi: [
        const DropdownMenuItem  (
          valore: ApiType.mock,
          figlio: testo ("Demo"),
        ),
        const DropdownMenuItem  (
          valore: ApiType.remote,
          figlio: testo ("opentdb.com"),
       ),
    ]);
}),

Ogni volta che viene selezionato un nuovo database, il metodo setApiType modificherà l'implementazione dell'API e le categorie verranno aggiornate.

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

2 - Categorie

Per ottenere l'elenco delle categorie chiamiamo questo URL:

https://opentdb.com/api_category.php

Estratto di risposta:

{"trivia_categories": [{"id": 9, "name": "General Knowledge"}, {"id": 10, "name": "Entertainment: Books"}]

Quindi, dopo aver decodificato JSON usando la funzione jsonDecode del dart: convert library:

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

abbiamo questa struttura:

  • jsonResponse ['trivia_categories']: elenco di categorie
  • jsonResponse ['trivia_categories'] [INDEX] ['id']: id della categoria
  • jsonResponse ['trivia_categories'] [INDEX] ['name']: nome della categoria

Quindi il modello sarà:

categoria Categoria {
  Categoria ({this.id, this.name});
  factory Category.fromJson (Mappa  json) {
    categoria di ritorno (id: json ["id"], nome: json ["nome"]);
  }
  int id;
  Nome della stringa;
}

3 - Domande

Se chiamiamo questo URL:

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

questa sarà la risposta:

{"response_code": 0, "results": [{"categoria": "Entertainment: Music", "type": "multiple", "difficoltà": "medium", "question": "What artista francese \ / band è noto per suonare sullo strumento midi & quot; Launchpad & quot;? "," correct_answer ":" Madeon "," wrong_answers ": [" Daft Punk "," Disclosure "," David Guetta "]}, {" categoria ":" Sport "," tipo ":" multiplo "," difficoltà ":" medio "," domanda ":" Chi ha vinto il Campionato Nazionale College Football Playoff (CFP) 2015? "," Correct_answer ":" Ohio State Buckeyes "," wrong_answers ": [" Alabama Crimson Tide "," Clemson Tigers "," Wisconsin Badgers "]}]}

In questo caso, decodificando il JSON, abbiamo questa struttura:

  • jsonResponse ['risultati']: elenco di domande.
  • jsonResponse ['results'] [INDEX] ['category']: la categoria della domanda.
  • jsonResponse ['results'] [INDEX] ['type']: tipo di domanda, multipla o booleana.
  • jsonResponse ['results'] [INDEX] ['question']: la domanda.
  • jsonResponse ['results'] [INDEX] ['correct_answer']: la risposta corretta.
  • jsonResponse ['results'] [INDEX] ['wrong_answers']: elenco delle risposte errate.

Modello:

class QuestionModel {
  QuestionModel ({this.question, this.correctAnswer, this.incorrectAnswers});
  factory QuestionModel.fromJson (Mappa  json) {
    return QuestionModel (
      domanda: json ["domanda"],
      correctAnswer: json ["correct_answer"],
      wrongAnswers: (json ["wrong_answers") come Elenco)
        .map ((answer) => answer.toString ())
        .elencare());
  }
  Domanda di stringa;
  String correctRisposta;
  Elenco  wrongAnswers;
}

4 - Classe TriviaApi

La classe implementa i due metodi dell'interfaccia QuestionsApi, getCategories e getQuestions:

  • Ottenere le categorie

Nella prima parte, il JSON viene decodificato quindi utilizzando il modello, viene analizzato ottenendo un elenco di tipo Categoria, infine, il risultato viene dato alle categorie (una StreamedList di tipo Categoria utilizzata per popolare l'elenco delle categorie nella pagina principale ).

final jsonResponse = convert.jsonDecode (response.body);
risultato finale = (jsonResponse ["trivia_categories"] come elenco)
.map ((category) => Category.fromJson (categoria));
categorie.valore = [];
categorie
..addAll (risultato)
..addElement (Categoria (id: 0, nome: "Qualsiasi categoria"));
  • Ottenere le domande

Qualcosa di simile accade per le domande, ma in questo caso, utilizziamo un modello (Domanda) per "convertire" la struttura originale (QuestionModel) di JSON in una struttura più comoda da utilizzare nell'app.

final jsonResponse = convert.jsonDecode (response.body);
risultato finale = (jsonResponse ["risultati"] come elenco)
.map ((question) => QuestionModel.fromJson (question));
questions.value = risultato
.map ((question) => Question.fromQuestionModel (question))
.elencare();

5 - Classe di domande

Come detto nel paragrafo precedente, l'app utilizza una struttura diversa per le domande. In questa classe abbiamo quattro proprietà e due metodi:

Domanda di classe {
  Domanda ({this.question, this.answers, this.correctAnswerIndex});
  factory Question.fromQuestionModel (modello QuestionModel) {
    Elenco finale  risposte = []
      ..add (model.correctAnswer)
      ..addAll (model.incorrectAnswers)
      ..shuffle ();
    indice finale = answer.indexOf (model.correctAnswer);
    domanda di ritorno (domanda: model.question, risposte: risposte, correctAnswerIndex: indice);
  }
  Domanda di stringa;
  Elenca le risposte ;
  int correctAnswerIndex;
  int sceltoAnswerIndex;
  bool isCorrect (String string) {
    return answer.indexOf (answer) == correctAnswerIndex;
  }
  bool isChosen (String string) {
    return answer.indexOf (answer) == chosenAnswerIndex;
  }
}

In fabbrica, l'elenco delle risposte viene prima popolato con tutte le risposte e quindi mescolato in modo che l'ordine sia sempre diverso. Qui otteniamo anche l'indice della risposta corretta in modo da poterlo assegnare a correctAnswerIndex tramite il costruttore della domanda. I due metodi vengono utilizzati per determinare se la risposta passata come parametro è quella corretta o quella scelta (saranno meglio spiegati in uno dei paragrafi successivi).

Parte 4 - Pagina iniziale e altre schermate

1 - Widget HomePage

Nell'AppState è possibile visualizzare una proprietà denominata tabControllert che è un StreamedValue di tipo AppTab (un enum), utilizzato per lo streaming della pagina da mostrare nel widget HomePage (stateless). Funziona in questo modo: ogni volta che un set AppTabis diverso, il widget ValueBuilder ricostruisce lo schermo che mostra la nuova pagina.

  • Classe HomePage:
Widget build (contesto BuildContext) {
  final appState = AppStateProvider.of  (contesto);
  
  return ValueBuilder (
    in streaming: appState.tabController,
    costruttore: (contesto, istantanea) => Impalcatura (
      appBar: snapshot.data! = AppTab.main? null: AppBar (),
      drawer: DrawerWidget (),
      body: _switchTab (snapshot.data, appState),
      ),
  );
}

N.B. In questo caso, l'appBar verrà visualizzata solo nella pagina principale.

  • Metodo _switchTab:
Widget _switchTab (scheda AppTab, AppState appState) {
  switch (tab) {
    case AppTab.main:
      return MainPage ();
      rompere;
    case AppTab.trivia:
      return TriviaPage ();
      rompere;
    case AppTab.summary:
      return SummaryPage (stats: appState.triviaBloc.stats);
      rompere;
    predefinito:
    return MainPage ();
  }
}

2 - Impostazioni Pagina

Nella pagina Impostazioni è possibile scegliere il numero di domande da mostrare, la difficoltà, la quantità di tempo per il conto alla rovescia e il tipo di database da utilizzare. Nella pagina principale puoi quindi selezionare una categoria e infine iniziare il gioco. Per ognuna di queste impostazioni, utilizzo StreamedValue in modo che il widget ValueBuilder possa aggiornare la pagina ogni volta che viene impostato un nuovo valore.

Parte 5 - TriviaBloc

La logica di business dell'app è nell'unico BLoC chiamato TriviaBloc. Esaminiamo questa lezione.

Nel costruttore abbiamo:

TriviaBloc ({this.countdownStream, this.questions, this.tabController}) {
// Ottenere le domande dall'API
  questions.onChange ((data) {
    if (data.isNotEmpty) {
      domande finali = data..shuffle ();
     _startTrivia (domande);
    }
  });
  countdownStream.outTransformed.listen ((data) {
     conto alla rovescia = int.parse (dati) * 1000;
  });
}

Qui la proprietà questions (una StreamedList di tipo Domanda) ascolta le modifiche, quando un elenco di domande viene inviato allo stream viene chiamato il metodo _startTrivia, avviando il gioco.

Invece, il conto alla rovescia ascolta semplicemente le modifiche nel valore del conto alla rovescia nella pagina Impostazioni in modo da poter aggiornare la proprietà del conto alla rovescia utilizzata nella classe TriviaBloc.

  • _startTrivia (Elenca dati)

Questo metodo avvia il gioco. Fondamentalmente, reimposta lo stato delle proprietà, imposta la prima domanda da mostrare e dopo un secondo chiama il metodo playTrivia.

void _startTrivia (Elenco  dati) {
  indice = 0;
  triviaState.value.questionIndex = 1;
  // Per mostrare la pagina principale e i pulsanti di riepilogo
  triviaState.value.isTriviaEnd = false;
  // Ripristina le statistiche
  stats.reset ();
  // Per impostare la domanda iniziale (in questo caso il conto alla rovescia
  // l'animazione della barra non verrà avviata).
  currentQuestion.value = data.first;
  Timer (Durata (millisecondi: 1000), () {
    // Impostando questo flag su true quando si cambia la domanda
    // inizia l'animazione della barra del conto alla rovescia.
    triviaState.value.isTriviaPlaying = true;
  
    // Riproduci nuovamente la prima domanda con la barra del conto alla rovescia
    // animazione.
    currentQuestion.value = data [indice];
  
    playTrivia ();
  });
}

triviaState è uno StreamedValue di tipo TriviaState, una classe utilizzata per gestire lo stato della curiosità.

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

Quando viene chiamato questo metodo, un timer aggiorna periodicamente il timer e verifica se il tempo trascorso è maggiore dell'impostazione del conto alla rovescia, in questo caso, annulla il timer, contrassegna la domanda corrente come non risposta e chiama il metodo _nextQuestion per mostrare una nuova domanda .

void playTrivia () {
  timer = Timer.periodic (Durata (millisecondi: refreshTime), (Timer t) {
    currentTime.value = refreshTime * t.tick;
    if (currentTime.value> countdown) {
      currentTime.value = 0;
      timer.cancel ();
      notAnswered (currentQuestion.value);
     _prossima domanda();
    }
  });
}
  • notAnswered (domanda domanda)

Questo metodo chiama il metodo addNoAnswer dell'istanza stats della classe TriviaStats per ogni domanda senza risposta, al fine di aggiornare le statistiche.

void notRisposto (domanda con domanda) {
  stats.addNoAnswer (domanda);
}
  • _prossima domanda()

In questo metodo, l'indice delle domande viene aumentato e se ci sono altre domande nell'elenco, viene inviata una nuova domanda allo stream currentQuestion in modo che ValueBuilder aggiorni la pagina con la nuova domanda. Altrimenti, viene chiamato il metodo _endTriva, che termina il gioco.

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

Qui il timer viene annullato e la bandiera è TriviaEnd impostata su true. Dopo 1,5 secondi dalla fine del gioco, viene visualizzata la pagina di riepilogo.

void _endTrivia () {
  // RIPRISTINA
  timer.cancel ();
  currentTime.value = 0;
  triviaState.value.isTriviaEnd = true;
  triviaState.refresh ();
  stopTimer ();
  Timer (Durata (millisecondi: 1500), () {
     // questo viene resettato qui per non innescare l'inizio di
     // animazione del conto alla rovescia in attesa della pagina di riepilogo.
     triviaState.value.isAnswerChosen = false;
     // Mostra la pagina di riepilogo dopo 1,5 secondi
     tabController.value = AppTab.summary;
     // Cancella l'ultima domanda in modo che non appaia
     // nel prossimo gioco
     currentQuestion.value = null;
  });
}
  • checkAnswer (domanda domanda, risposta stringa)

Quando l'utente fa clic su una risposta, questo metodo controlla se è corretto e chiama il metodo per aggiungere un punteggio positivo o negativo alle statistiche. Quindi il timer viene ripristinato e viene caricata una nuova domanda.

void checkAnswer (domanda domanda, risposta stringa) {
  if (! triviaState.value.isTriviaEnd) {
     question.chosenAnswerIndex = question.answers.indexOf (risposta);
     if (question.isCorrect (answer)) {
       stats.addCorrect (domanda);
     } altro {
       stats.addWrong (domanda);
     }
     timer.cancel ();
     currentTime.value = 0;
    _prossima domanda();
  }
}
  • stopTimer ()

Quando viene chiamato questo metodo, l'ora viene annullata e il flag isAnswerChosen impostato su true per indicare al conto alla rovescia di interrompere l'animazione.

void stopTimer () {
  // Ferma il timer
  timer.cancel ();
  // Impostando questo flag su true, l'animazione del conto alla rovescia si interromperà
  triviaState.value.isAnswerChosen = true;
  triviaState.refresh ();
}
  • onChosenAnswer (risposta stringa)

Quando viene scelta una risposta, il timer viene annullato e l'indice della risposta viene salvato nella proprietà sceltoAnswerIndex dell'istanza replyAnimation della classe AnswerAnimation. Questo indice viene utilizzato per inserire l'ultima risposta nello stack dei widget per evitare che sia coperta da tutte le altre risposte.

void onChosenAnswer (risposta stringa) {
  chosenAnswer = risposta;
  stopTimer ();
  // Imposta la risposta selezionata in modo che il widget di risposta possa inserirla per ultima nel
  // stack.
  
  risposteAnimation.value.chosenAnswerIndex =
  currentQuestion.value.answers.indexOf (risposta);
  answersAnimation.refresh ();
}

Classe di risposta:

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

Al termine dell'animazione delle risposte, il flag isAnswerChosen è impostato su false, per consentire all'animazione del conto alla rovescia di ricominciare, quindi ha chiamato il metodo checkAnswer per verificare se la risposta è corretta.

void onChosenAnwserAnimationEnd () {
  // Reimposta il flag in modo che l'animazione del conto alla rovescia possa iniziare
  triviaState.value.isAnswerChosen = false;
  triviaState.refresh ();
  checkAnswer (currentQuestion.value, chosenAnswer);
}
  • Classe TriviaStats

I metodi di questa classe vengono utilizzati per assegnare il punteggio. Se l'utente seleziona la risposta corretta, il punteggio viene aumentato di dieci punti e le domande correnti aggiunte all'elenco delle correzioni in modo che possano essere visualizzate nella pagina di riepilogo, se una risposta non è corretta, il punteggio viene ridotto di quattro, infine se nessuna risposta il punteggio è diminuito di due punti.

class TriviaStats {
  TriviaStats () {
    corregge = [];
    wrongs = [];
    noAnswered = [];
    punteggio = 0;
  }
Elenca  corregge;
  Elencare  errati;
  Elenco  noAnswered;
  punteggio int;
void addCorrect (domanda domanda) {
    corrects.add (domanda);
    punteggio + = 10;
  }
void addWrong (domanda con domande) {
    wrongs.add (domanda);
    punteggio - = 4;
  }
void addNoAnswer (domanda con domande) {
    noAnswered.add (domanda);
    punteggio - = 2;
  }
void reset () {
    corregge = [];
    wrongs = [];
    noAnswered = [];
    punteggio = 0;
  }
}
Parte 6 - Animazioni

In questa app abbiamo due tipi di animazioni: la barra animata sotto le risposte indica il tempo rimanente per rispondere e l'animazione riprodotta quando viene scelta una risposta.

1 - Animazione della barra del conto alla rovescia

Questa è un'animazione piuttosto semplice. Il widget prende come parametro la larghezza della barra, la durata e lo stato del gioco. L'animazione inizia ogni volta che il widget viene ricostruito e si interrompe se viene scelta una risposta.

Il colore iniziale è verde e gradualmente diventa rosso, segnalando che il tempo sta per finire.

2 - Animazione delle risposte

Questa animazione viene avviata ogni volta che viene scelta una risposta. Con un semplice calcolo della posizione delle risposte, ognuna di esse viene spostata progressivamente nella posizione della risposta scelta. Per fare in modo che la risposta scelta rimanga in cima allo stack, questa viene scambiata con l'ultimo elemento dell'elenco dei widget.

// Scambia l'ultimo oggetto con la risposta selezionata in modo che possa farlo
// essere mostrato come l'ultimo in pila.
final last = widgets.last;
final scelto = widget [widget.answerAnimation.chosenAnswerIndex]; final sceltoIndex = widgets.indexOf (scelto);
widgets.last = scelto;
widgets [chosenIndex] = last;
Return Container (
   figlio: Stack (
      bambini: widget,
   ),
);

Il colore delle caselle diventa verde se la risposta è corretta e rosso se è errata.

var newColor;
if (isCorrect) {
  newColor = Colors.green;
} altro {
  newColor = Colors.red;
}
colorAnimation = ColorTween (
  inizio: answerBoxColor,
  fine: newColor,
) .Animate (controllore);
await controller.forward ();
Parte 7 - Pagina di riepilogo

1 - SummaryPage

Questa pagina prende come parametro un'istanza della classe TriviaStats, che contiene l'elenco delle domande corrette, degli errori e di quelle senza risposta scelta e crea un ListView che mostra ogni domanda nel posto giusto. La domanda corrente viene quindi passata al widget SummaryAnswers che crea l'elenco delle risposte.

2 - Riepilogo Risposte

Questo widget prende come parametro l'indice della domanda e la domanda stessa e crea l'elenco delle risposte. La risposta corretta è colorata in verde, mentre se l'utente ha scelto una risposta errata, questa è evidenziata in rosso, mostrando sia la risposta corretta che quella errata.

Conclusione

Questo esempio è di gran lunga perfetto o definitivo, ma può essere un buon punto di partenza con cui lavorare. Ad esempio, può essere migliorato creando una pagina delle statistiche con il punteggio di ogni partita giocata o una sezione in cui l'utente può creare domande e categorie personalizzate (questi possono essere un ottimo esercizio per esercitarsi con i database). Spero che questo possa essere utile, sentiti libero di proporre miglioramenti, suggerimenti o altro.

Puoi trovare il codice sorgente in questo repository GitHub.