DEV Community

Cover image for Build a Quiz App in Flutter: Full Guide
Muhammad Uzair
Muhammad Uzair

Posted on Originally published at uzairxdev.online

Build a Quiz App in Flutter: Full Guide

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:

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

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

factory Question.fromJson(Map json) {
return Question(
text: json['text'] as String,
options: List.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:

\dart
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:

\dart
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:

\`dart
enum QuizPhase { ready, answering, finished }

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

final List questions;
final Map 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:

\dart
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);
}
}
\
\

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:

\`dart
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.


Originally published at uzairxdev.online

Top comments (0)