Skip to main content

Mobile Development9 min read

Build a Quiz App in Flutter: Full Guide

Build a quiz app in Flutter from scratch: question banks, fair shuffling, scoring, timers and saved progress, with working Dart code you can reuse.

  • #flutter
  • #quiz-app
  • #dart
  • #state-management
Build a Quiz App in Flutter: Full Guide
Advertisement

Quiz apps are one of the most useful projects a Flutter developer can build. The pattern shows up everywhere: exam prep, language learning, driving theory, onboarding quizzes, and trivia games. Underneath the friendly interface, a quiz app is a compact engineering problem — a question bank, a fair way to pick and order questions, state that survives taps and rotations, scoring, and progress that persists between sessions. I built the practice test engine for AI US Citizenship Test 2026, an app that prepares green card holders for the US naturalization interview. The app covers 100 civics questions, the English test, and the N-400 interview, with an AI tutor that explains answers in plain words, realistic mock interviews, and an audio study mode. The quiz engine at its core is the same one any quiz app needs, and this guide walks through building it from scratch with working Dart code.

Model the question bank first

Every quiz starts with data, not widgets. A question needs its text, a list of options, the index of the correct option, and an optional explanation shown after the user answers. Model it as an immutable class with a fromJson factory so the bank can live in a JSON file:

class Question {
  const Question({
    required this.text,
    required this.options,
    required this.correctIndex,
    this.explanation,
  });

  final String text;
  final List<String> options;
  final int correctIndex;
  final String? explanation;

  factory Question.fromJson(Map<String, dynamic> json) {
    return Question(
      text: json['text'] as String,
      options: List<String>.from(json['options'] as List),
      correctIndex: json['correctIndex'] as int,
      explanation: json['explanation'] as String?,
    );
  }
}

Keeping the bank in JSON has a practical advantage: content and code stay separate. A subject-matter expert can add questions without touching Dart, and the same file can feed a practice mode, a timed test, and a review screen. Validate the data on load — every question should have at least two options, and correctIndex must point at a real option. A single bad index crashes the scoring logic later, so fail loudly at load time instead of silently during a test.

Load the bank from an asset

Ship the questions as a JSON asset bundled with the app. That keeps the quiz working offline, which matters for a study app — users revise on the train, in waiting rooms, and anywhere else with no signal. Load it once when the quiz screen starts:

Future<List<Question>> loadQuestions(String assetPath) async {
  final raw = await rootBundle.loadString(assetPath);
  final decoded = jsonDecode(raw) as List;
  return decoded
      .map((item) => Question.fromJson(item as Map<String, dynamic>))
      .toList();
}

Register the file under assets: in pubspec.yaml. For a large bank, split it by category or chapter into several files and load only the set the user picked. A civics bank with hundreds of questions loads fast either way, but a 10 MB JSON file parsed on the main isolate will drop frames — for very large banks, parse on a background isolate with compute.

Shuffle fairly, and never repeat a question

A quiz that shows the same questions in the same order every time stops testing anything after the second run. Shuffle a copy of the bank, take the first N questions, and keep the original list untouched:

Advertisement
List<Question> buildQuiz(
  List<Question> bank, {
  int count = 10,
  int? seed,
}) {
  final questions = List<Question>.of(bank)..shuffle(Random(seed));
  return questions.take(count).toList();
}

Two details matter here. First, copy before shuffling: List.of(bank) creates a new list, so the bank keeps its original order for the review screen and for the next session. Second, shuffle once per quiz, not once per build. If the shuffle lives inside build(), every rebuild reorders the questions under the user's finger. Do it in initState or in the controller that owns the quiz state. Passing a seed makes a shuffle reproducible, which is handy for tests and for a mock interview mode where the teacher and the student need the same question order. In production, leave the seed out and let Random() draw from the system entropy source.

Hold quiz state in a small controller

A quiz has a clear lifecycle: ready, answering, finished. Encoding that as an enum keeps the UI honest — the results screen cannot appear while a question is still unanswered, and the answer buttons cannot be tapped after the quiz ends. A ChangeNotifier holds the current index, the answers map, and the score:

enum QuizPhase { ready, answering, finished }

class QuizController extends ChangeNotifier {
  QuizController(this.questions);

  final List<Question> questions;
  final Map<int, int> answers = {};
  int currentIndex = 0;
  QuizPhase phase = QuizPhase.ready;

  Question get current => questions[currentIndex];

  void answer(int optionIndex) {
    answers[currentIndex] = optionIndex;
    notifyListeners();
  }

  void next() {
    if (currentIndex < questions.length - 1) {
      currentIndex++;
    } else {
      phase = QuizPhase.finished;
    }
    notifyListeners();
  }

  int get score => answers.entries
      .where((entry) => questions[entry.key].correctIndex == entry.value)
      .length;
}

Store answers as a map from question index to chosen option index, not as a list of booleans. The map makes the review screen trivial: iterate the questions, look up each answer, and show the correct option next to the one the user picked. It also lets the user go back and change an answer before finishing, which a boolean list cannot express. Expose the controller with a ChangeNotifierProvider above the quiz screen. Widgets that only show the question text can listen to it; the progress bar and the score display rebuild when the state changes, and nothing else does.

Score at the end, not during the run

Score the quiz when it finishes, from the answers map, instead of incrementing a counter on every tap. The two approaches agree when everything works, but the derived score stays correct when a user changes an answer, when a question is skipped, and when the app restores a half-finished session. Derived state cannot drift out of sync with its source. Show the result as more than a number. A pass mark — for example, 6 out of 10 — tells the user whether they are ready, and a per-question review tells them what to study next. Include the explanation from the question model on the review screen: a wrong answer without an explanation is a missed teaching moment, and for a test-prep app the explanation is the product. In the citizenship app, the AI tutor takes this further and explains civics answers in plain words when the short explanation is not enough.

Save progress on the device

Users abandon a quiz halfway and come back the next day. Save the best score and the last completed set with shared_preferences, and only write when the new score beats the stored one:

Future<void> saveBestScore(int score) async {
  final prefs = await SharedPreferences.getInstance();
  final best = prefs.getInt('best_score') ?? 0;
  if (score > best) {
    await prefs.setInt('best_score', score);
  }
}
Advertisement

For richer state — the answers of an unfinished quiz, a streak of study days, per-category scores — use a local database such as Drift or Isar instead of piling keys into preferences. The rule of thumb: preferences for a handful of settings and best scores, a database for anything shaped like a table. Keep everything on-device unless the app genuinely needs an account. The citizenship app stores practice progress locally, and the same approach fits most quiz apps: no login wall, no sync conflicts, and the quiz works the moment the app opens. If you later add cloud backup, make it opt-in and sync only after the local write succeeds.

Add a per-question timer for exam mode

A practice mode can be untimed, but a mock exam needs a clock. A Timer.periodic ticks once a second, and when it reaches zero the question counts as unanswered and the quiz moves on:

Timer? _timer;
int secondsLeft = 30;

void _startTimer() {
  _timer?.cancel();
  secondsLeft = 30;
  _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
    secondsLeft--;
    if (secondsLeft <= 0) {
      timer.cancel();
      next(); // Unanswered counts as wrong.
    }
    notifyListeners();
  });
}

Cancel the timer in dispose and restart it on every next(). A timer that keeps firing after the screen is gone leaks, and a timer that is not restarted gives the second question the first question's remaining seconds. Show the countdown as a thin progress bar above the question — a number alone is easy to miss, and a shrinking bar communicates urgency without stealing attention.

Mistakes to avoid

The most common bug in a first quiz app is shuffling inside build(), which reorders the questions on every rebuild. The second is storing the selected answer in the widget's local state instead of the controller: the selection vanishes on rotation or when the user navigates to the review screen and back. The third is scoring by incrementing a counter in the tap handler, which breaks the moment an answer can change. A subtler one is loading the question bank on every screen. Load it once, pass the list down, and keep a single source of truth. And validate the bank at startup in debug mode with an assert: every question needs at least two options, a valid correctIndex, and a non-empty question text. Bad content is far cheaper to catch in development than in a user's test session.

Putting it together

A quiz app is a small set of well-separated pieces: a question model and a JSON bank, a shuffle that runs once, a controller holding an explicit phase and an answers map, a score derived from that map, local persistence for progress, and an optional timer for exam mode. Each piece is easy to test on its own, and none of them needs a backend. Start with ten questions in a single JSON file and the controller above. Add categories, review screens, and timed mock exams one at a time, testing each against the real question bank. The same structure scales from a trivia game to a serious exam-prep tool — the citizenship app's practice test engine, mock interviews, and audio study mode all sit on top of this exact foundation. If you want to round out the experience, pair the quiz with ZIP-code autofill for location-specific questions, offline audio playback for a study mode that works without a connection, and local notifications for daily study reminders. Get in touch and let's talk about your project.

Frequently asked questions

How should I store quiz questions in Flutter?
Keep them in a JSON asset file and model each question as an immutable Dart class with text, options, correctIndex, and an optional explanation. Load the file once with rootBundle.loadString, parse it with jsonDecode, and convert each item with a fromJson factory.
How do I shuffle quiz questions without repeats?
Copy the bank with List.of, shuffle the copy once per quiz with Random, and take the first N questions. Never shuffle inside build, because every rebuild would reorder the questions while the user is answering.
How do I calculate the score in a Flutter quiz app?
Store answers in a map from question index to the chosen option index, then derive the score by counting entries where the chosen option matches the question's correctIndex. Deriving the score keeps it correct when a user changes or skips an answer.
How do I save quiz progress in Flutter?
Use shared_preferences for best scores and simple progress, writing only when the new score beats the stored one. For richer state such as unfinished quizzes or per-category scores, use a local database such as Drift or Isar instead.

Written by Muhammad Uzair

Flutter Developer & Mobile Engineering Team Lead.

Work with me
Advertisement
All articles
  • Mobile Development

    8 min read

    Color-Blind Friendly Games in Flutter

    Build a color-blind friendly game in Flutter: pair every color with a symbol, add aiming aids players can read, and ship settings players control.

  • Mobile Development

    7 min read

    Build a Pomodoro Focus Timer in Flutter

    Build a drift-free Pomodoro timer in Flutter with local notifications for session transitions, persistent session history, and calm deep-work analytics.

  • Mobile Development

    6 min read

    Flutter ZIP-Code Autofill That Just Works

    Add ZIP-code autofill to your Flutter app: string-safe validation, debounced lookups, stale-response cancellation, and defensive parsing from a production app.