paint-brush
Here's How to Learn Data Structures the Fun Way With Flutterby@dhruvam
New Story

Here's How to Learn Data Structures the Fun Way With Flutter

by Dhruvam23mMarch 4th, 2025
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

This article combines theory with hands-on implementation in Flutter. It is inspired by Google’s *Applied CS with Android. In just 3–4 hours, you’ll gain a deeper understanding of these fundamental data structures.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Here's How to Learn Data Structures the Fun Way With Flutter
Dhruvam HackerNoon profile picture
0-item

Build games and learn Computer Science.


Data structures form the foundation of efficient software development, yet traditional learning methods often make them feel abstract and disconnected from real-world applications. This article takes a different approach — combining theory with hands-on implementation in Flutter to make learning both engaging and practical.


Inspired by Google’s Applied CS with Android, this adaptation for Flutter provides an interactive way to understand Arrays, HashSets, and HashMaps. In just 3–4 hours, you’ll gain a deeper understanding of these fundamental data structures while applying them in a meaningful context.


Whether you’re a beginner looking to strengthen your CS fundamentals or an experienced developer aiming to refine your skills, this guide offers an efficient and enjoyable way to master essential data structures. Let’s get started.

The Objectives of This Article:

  1. Familiarize yourself with how dictionaries can be used to store data (in this case words).
  2. Use hash maps to store groupings of words, which are anagrams.
  3. Be able to explain the limitations that some data structures face when working with large data sets.

Preparation:

We will use a few data structures in the workshop activity, so please review Lists, HashSets, and HashMaps. You should be able to confidently insert, delete, access, and check the existence of elements using these data structures in Dart.


This is a small introduction to the data structures, HashSets and HashMap.


A small starter exercise warm-up:

As an example activity using HashMaps, create a program (not necessarily a Flutter app — command-line is fine) that will take in a three-letter country code (see ISO-3166) and return the full name of the country to which it belongs.


For example:


Input | Output
----- | ----------------------------------------------------
 GBR  | United Kingdom of Great Britain and Northern Ireland
 IDN  | Indonesia
 IND  | India


As an extension, if the input is greater than 3 letters, consider it as the name of a country, and return the three-letter code for it. Write a helpful error message if the input is neither a valid code nor a country name.


Let us begin.


Anagrams

An anagram is a word formed by rearranging the letters of another word. For example, cinema is an anagram of iceman.

The mechanics of the game are as follows:

  1. The game provides the user with a word from the dictionary.


  2. The user tries to create as many words as possible that contain all the letters of the given word plus one additional letter. Note that adding the extra letter at the beginning or the end without reordering the other letters is invalid. For example, if the game picks the word ‘ore’ as a starter, the user might guess ‘rose’ or ‘zero’ but not ‘sore’.


  3. The user can give up and see the words they did not guess.




We have provided you with some starter code that contains a 10,000-word dictionary and handles the UI portions of this game and you will be responsible for writing the AnagramBlocclass that handles all word manipulations.


Tour of the Code

The starter code is composed of three main dart classes:


anagrams_page.dart

This is simple code that represents the screen we see above. We will use a bloc for state management for the screen. We will start by setting up the game by firing an event to the bloc and defining what will happen when the screen responds to different game states.


import 'package:anagrams/anagrams/bloc/anagram_bloc.dart';
import 'package:anagrams/anagrams/domain/word.dart';
import 'package:anagrams/l10n/l10n.dart';
import 'package:bloc_presentation/bloc_presentation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => AnagramBloc()
        ..add(
          SetupAnagrams(
            DefaultAssetBundle.of(context),
          ),
        ),
      child: const AnagramsView(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final l10n = context.l10n;
    return Scaffold(
      appBar: AppBar(title: Text(l10n.anagramAppBarTitle)),
      body: BlocBuilder<AnagramBloc, AnagramState>(
        builder: (context, state) {
          switch (state.status) {
            case AnagramGameStatus.gameError:
              return const Center(
                child: Text('An error occurred'),
              );
            case AnagramGameStatus.loaded:
              return Padding(
                padding: const EdgeInsets.all(20),
                child: ListView(
                  children: const [
                    _SelectedWord(),
                    SizedBox(height: 20),
                    _AnagramsTextField(),
                    SizedBox(height: 10),
                    _GuessListView(),
                  ],
                ),
              );
            case AnagramGameStatus.initial:
              return const Center(
                child: CircularProgressIndicator(),
              );
          }
        },
      ),
      floatingActionButton: const _NextWordButton(),
    );
  }
}

class _SelectedWord extends StatelessWidget {
  const _SelectedWord();

  @override
  Widget build(BuildContext context) {
    return BlocSelector<AnagramBloc, AnagramState, String>(
      selector: (state) => state.currentWord,
      builder: (context, currentWord) {
        return Text.rich(
          TextSpan(
            text: 'Find as many words as possible that can be '
                'formed by adding one letter to ',
            children: [
              TextSpan(
                text: currentWord.toUpperCase(),
                style: const TextStyle(fontWeight: FontWeight.bold),
              ),
              TextSpan(
                text: ' (but that do not contain the substring'
                    ' ${currentWord.toUpperCase()}).',
              ),
            ],
          ),
        );
      },
    );
  }
}

class _AnagramsTextField extends StatelessWidget {
  const _AnagramsTextField();

  @override
  Widget build(BuildContext context) {
    final controller = TextEditingController();

    return TextField(
      controller: controller,
      decoration: const InputDecoration(
        hintText: 'Enter an anagram',
        border: OutlineInputBorder(),
      ),
      keyboardType: TextInputType.text,
      textInputAction: TextInputAction.done,
      onSubmitted: (value) {
        controller.clear();
        context.read<AnagramBloc>().add(ProcessWord(value));
      },
    );
  }
}

class _GuessListView extends StatelessWidget {
  const _GuessListView();

  @override
  Widget build(BuildContext context) {
    return BlocSelector<AnagramBloc, AnagramState, List<Word>>(
      selector: (state) => state.guesses,
      builder: (context, guesses) {
        return Column(
          children: guesses.map((word) {
            return ListTile(
              minTileHeight: 0,
              contentPadding: EdgeInsets.zero,
              visualDensity: VisualDensity.compact,
              title: Text(word.value),
              leading: Icon(
                word.isAnagram ? Icons.check : Icons.close,
                color: word.isAnagram ? Colors.green : Colors.red,
              ),
            );
          }).toList(),
        );
      },
    );
  }
}

class _GameResult extends StatelessWidget {
  const _GameResult(this.currentWord, this.result);

  final List<Word> result;
  final String currentWord;

  @override
  Widget build(BuildContext context) {
    return ListView(
      shrinkWrap: true,
      children: [
        const SizedBox(height: 20),
        Padding(
          padding: const EdgeInsets.symmetric(
            horizontal: 20,
          ),
          child: Text(
            'Game Result for $currentWord',
            style: const TextStyle(fontSize: 20),
          ),
        ),
        Padding(
          padding: const EdgeInsets.all(20),
          child: SizedBox(
            width: double.infinity,
            child: DataTable(
              decoration: BoxDecoration(
                border: Border.all(
                  color: Colors.grey.shade400,
                ),
                borderRadius: BorderRadius.circular(10),
              ),
              columns: const [
                DataColumn(label: Text('Possible Anagrams')),
                DataColumn(label: Text('Your Guesses')),
              ],
              rows: result.map((word) {
                return DataRow(
                  cells: [
                    DataCell(Text(word.value)),
                    DataCell(
                      Center(
                        child: Icon(
                          word.isAnagram ? Icons.check : Icons.close,
                          color: word.isAnagram ? Colors.green : Colors.red,
                        ),
                      ),
                    ),
                  ],
                );
              }).toList(),
            ),
          ),
        ),
      ],
    );
  }
}

class _NextWordButton extends StatelessWidget {
  const _NextWordButton();

  @override
  Widget build(BuildContext context) {
    final l10n = context.l10n;
    return BlocPresentationListener<AnagramBloc, AnagramPresenterEvent>(
      listener: (context, event) {
        if (event is FinishGuess) {
          // show a bottom sheet with the anagrams that were not guessed
          showModalBottomSheet<void>(
            context: context,
            useSafeArea: true,
            builder: (context) {
              return _GameResult(event.currentWord, event.result);
            },
          );
        }
      },
      child: FloatingActionButton.extended(
        onPressed: () async {
          context.read<AnagramBloc>().add(ResetGame());
        },
        label: Text(l10n.nextWordButton),
      ),
    );
  }
}


  • _SelectWord : shows the chosen word for the game to form anagrams from.


  • _AnagramsTextField : takes in the word and fires an event to process the word the user has typed.


  • _GuessListView : shows the guesses the user has entered and whether they are correct or not.


  • _NextWordButton: resets the game and presents the user with all the anagrams of the current word and which one the user has guessed.


anagram_states.dart


enum AnagramGameStatus { initial, loaded, gameError }

const minNumAnagrams = 5;
const defaultWordLength = 3;
const maxDefaultWordLength = 7;

@immutable
final class AnagramState extends Equatable {
  factory AnagramState({
    AnagramGameStatus status = AnagramGameStatus.initial,
    List<String> words = const [],
    String currentWord = '',
    List<String> anagrams = const [],
    List<Word> guesses = const [],
    HashSet<String>? wordSet,
    HashMap<String, List<String>>? anagramMap,
    HashMap<int, List<String>>? sizeToWords,
    int wordLength = defaultWordLength,
  }) {
    return AnagramState._(
      status: status,
      words: words,
      currentWord: currentWord,
      anagrams: anagrams,
      guesses: guesses,
      wordSet: wordSet ?? HashSet<String>(),
      anagramMap: anagramMap ?? HashMap<String, List<String>>(),
      sizeToWords: sizeToWords ?? HashMap<int, List<String>>(),
      wordLength: wordLength,
    );
  }
  const AnagramState._({
    required this.status,
    required this.words,
    required this.currentWord,
    required this.anagrams,
    required this.guesses,
    required this.wordSet,
    required this.anagramMap,
    required this.sizeToWords,
    this.wordLength = defaultWordLength,
  });

  // The current status of the game
  final AnagramGameStatus status;

  // All the words in the game
  final List<String> words;

  // Currently chosen word of the game to form anagrams
  final String currentWord;

  // All the anagrams for the current word
  final List<String> anagrams;

  // All the guesses user has made
  final List<Word> guesses;

  // A set of all the words in the game
  final HashSet<String> wordSet;

  // A map of anagrams for each word
  final HashMap<String, List<String>> anagramMap;

  // Stores the words in increasing order of their length
  final HashMap<int, List<String>> sizeToWords;

  final int wordLength;

  AnagramState copyWith({
    AnagramGameStatus? status,
    List<String>? words,
    String? currentWord,
    List<String>? anagrams,
    List<Word>? guesses,
    HashSet<String>? wordSet,
    HashMap<String, List<String>>? anagramMap,
    HashMap<int, List<String>>? sizeToWords,
    int? wordLength,
  }) {
    return AnagramState(
      status: status ?? this.status,
      words: words ?? this.words,
      currentWord: currentWord ?? this.currentWord,
      anagrams: anagrams ?? this.anagrams,
      guesses: guesses ?? this.guesses,
      wordSet: wordSet ?? this.wordSet,
      anagramMap: anagramMap ?? this.anagramMap,
      sizeToWords: sizeToWords ?? this.sizeToWords,
      wordLength: wordLength ?? this.wordLength,
    );
  }

  @override
  List<Object?> get props => [
    status,
    words,
    currentWord,
    anagrams,
    guesses,
    wordSet,
    anagramMap,
    sizeToWords,
    wordLength,
  ];
}


AnagramState holds all the state variables required to run the game.

  • status: holds whether the game is loading (initial), loaded, or some error thrown.
  • words: Lists all the words from the file word.txt that are loaded from the file.
  • anagrams : holds all the anagrams for the chosen word.
  • currentword: chosen word from a word list and word to form anagrams from.
  • guesses: All the choices that the user enters and whether they are right or wrong.


We will get to the rest of the details, later in the article.


anagram_bloc.dart


import 'dart:async';
import 'dart:collection';
import 'dart:convert';

import 'package:anagrams/anagrams/domain/word.dart';
import 'package:bloc_presentation/bloc_presentation.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

part 'anagram_events.dart';

part 'anagram_states.dart';

class AnagramBloc extends Bloc<AnagramEvent, AnagramState>
    with BlocPresentationMixin<AnagramState, AnagramPresenterEvent> {
  AnagramBloc() : super(AnagramState()) {
    on<SetupAnagrams>(_onSetupAnagrams);
    on<ProcessWord>(_onProcessWord);
    on<ResetGame>(_onResetGame);
  }

  Future<void> _onSetupAnagrams(
    SetupAnagrams event,
    Emitter<AnagramState> emit,
  ) async {
    try {
      // this should not be done here,
      // but for the sake of simplicity, we will do it here
      final wordsFile =
          await event.defaultAssetBundle.loadString('assets/words.txt');
      // read each line in the file
      final words = const LineSplitter().convert(wordsFile);

      // change the state of the game
      emit(
        state.copyWith(
          status: AnagramGameStatus.loaded,
          words: words,
        ),
      );
      // reset the game
      _onRestartGame(emit);
    } catch (e) {
      emit(
        state.copyWith(
          status: AnagramGameStatus.gameError,
        ),
      );
    }
  }

  Future<void> _onProcessWord(
    ProcessWord event,
    Emitter<AnagramState> emit,
  ) async {
    try {
      final word = event.word.trim().toLowerCase();
      if (word.isEmpty) {
        return;
      }
      if (_isGoodWord(word) && state.anagrams.contains(word)) {
        // remove the word from the list of anagrams
        // add the word to the list of guesses
        emit(
          state.copyWith(
            anagrams: state.anagrams..remove(word),
            guesses: [...state.guesses, Word(word, isAnagram: true)],
          ),
        );
        // if there are no more anagrams, the game is over
        // call _onResetGame to reset the game
        if (state.anagrams.isEmpty) {
          add(ResetGame());
        }
      } else {
        emit(
          state.copyWith(
            guesses: [...state.guesses, Word(word)],
          ),
        );
      }
    } catch (e) {
      // show an error message
    }
  }

  FutureOr<void> _onResetGame(ResetGame event, Emitter<AnagramState> emit) {
    _onGameFinished();
    _onRestartGame(emit);
  }

  void _onRestartGame(Emitter<AnagramState> emit) {
    final starterWord = _pickGoodStarterWord(emit);
    emit(
      state.copyWith(
        status: AnagramGameStatus.loaded,
        currentWord: starterWord,
        anagrams: _getAnagrams(starterWord),
        guesses: [],
      ),
    );
  }

  void _onGameFinished() {
    emitPresentation(FinishGuess(_result, state.currentWord));
  }

  List<Word> get _result {
    // All the anagrams that were not guessed
    final notGuessedAnagrams = state.anagrams.map(Word.new).toList();
    // All the guesses that were made
    final guesses = state.guesses.where((word) => word.isAnagram).toList();
    // return the list of anagrams that were not guessed
    return [...guesses, ...notGuessedAnagrams];
  }

  /// create a function to find all the anagrams of the target word
  List<String> _getAnagrams(String targetWord) {
    // find all the anagrams of the target word
    final anagrams = <String>[];
    // return the list of anagrams
    return anagrams;
  }

  // ignore: unused_element
  List<String> _getAnagramsWithOneMoreLetter(String targetWord) {
    final anagrams = HashSet<String>();
    // return the list of anagrams
    return anagrams.toList();
  }

  /// Picks a good starter word for the game.
  String _pickGoodStarterWord(Emitter<AnagramState> emit) {
    const word = 'skate';

    return word;
  }

  /// Checks if the word is a good word.
  bool _isGoodWord(String word) {
    return true;
  }
}


  • onSetupAnagrams: Reads the file, split the words, and add them to the list. It also finds the current word, finds anagrams for that chosen word, and updates the state.


  • onProcessWord : This is the handler called when the user enters a guess and updates the state.
  • onReset : Called on clicking the next word button and resets the game.
  • isGoodWord: Asserts that the given word is in the dictionary and isn't formed by adding a letter to the start or end of the base word.
  • getAnagrams: Creates a list of all possible anagrams of a given word.
  • getAnagramsWithOneMoreLetter: Creates a list of all possible words that can be formed by adding one letter to the given word.
  • pickGoodStarterWord: Randomly select a word with at least the desired number of anagrams.

Milestone 1: Essentials

The first milestone focuses on creating a very simple working program. You’ll be implementing the foundations which will in turn be built on in Milestones 2 and 3.


We will be working on the anagram_bloc.dart .

getAnagrams

Implement getAnagrams which takes a string and finds all the anagrams of that string in our input. Our strategy for now will be straightforward: just compare each string in words List to the input word to determine if they are anagrams. But how shall we do that?


There are different strategies that you could employ to determine whether two strings are anagrams of each other (like counting the number of occurrences of each letter) but for our purpose you will create a helper function (call it sortLetters) that takes a String and returns another String with the same letters in alphabetical order (e.g. "post" -> "post").


Determining whether two strings are anagrams is then a simple matter of checking that they are the same length (for the sake of speed) and checking that the sorted versions of their letters are equal.

wordSet and anagramMap

Unfortunately, the straightforward strategy will be too slow for us to implement the rest of this game. So, we will need to revisit our onSetupAnagrams and find some data structures that store the words in ways that are convenient for our purposes. We will create two new data structures (in addition to words):


  • A HashSet (called wordSet) that will allow us to rapidly (in O(1)) verify whether a word is valid.


  • A HashMap (called anagramMap) that will allow us to group anagrams. We will do this by using the sortLetters version of a string as the key and storing a List of the words that correspond to that key as our value. For example, we may have an entry of the form: key: "opst" value: ["post", "spot", "pots", "tops", ...].


As you process the input words, call sortLetters on each of them then check whether anagramMap already contains an entry for that key. If it does, add the current word to List at that key. Otherwise, create a new one, add the word to it, and store it in the HashMap with the corresponding key.


Once you have completed this, you have reached the end of Milestone 1! You are now ready to move on to the second milestone, where you will be adding more complexity to your program.


Solution to milestone 1

Milestone 2: Adding Quality

Milestone 2 is all about ensuring that the words picked are suitable for the anagram game. Unlike. The previous milestone, this one is split up into three sections.

isGoodWord

Your next task is to implement isGoodWord which checks:

  • the provided word is a valid dictionary word (i.e., in wordSet), and
  • the word does not contain the base word as a substring.




Checking whether a word is a valid dictionary word can be accomplished by looking at wordSet to see if it contains the word. Checking that the word does not contain the base word as a substring is left as a challenge!

getAnagramsWithOneMoreLetter

Finally, implement getAnagramsWithOneMoreLetter which takes a string and finds all anagrams that can be formed by adding one letter to that word.


Be sure to instantiate a new List as your return value then check the given word + each letter of the alphabet one by one against the entries in anagramMap.


Also, update the onRestartGame to invoke getAnagramsWithOneMoreLetter instead of getAnagrams.


pickGoodStarterWord

If your game is working, proceed to implement pickGoodStarterWord to make the game more interesting. Pick a random starting point in the word List and check each word in the array until you find one that has at least minNumAnagrams. Be sure to handle wrapping around to the start of the array if needed.



Two-thirds of the way through! Just one milestone and the extension before you are done.


Solution to milestone 2


Milestone 3: Refactoring

At this point, the game is functional but can be quite hard to play if you start with a long base word. To avoid this, let’s refactor onSetupGame to give words of increasing length.


This refactor starts in the onSetupGame where in addition to populating word List, you should also store each word in a HashMap (let's call it sizeToWords) that maps word length to an List of all words of that length. This means, for example, you should be able to get all four-letter words in the dictionary by calling sizeToWords.get(4).


In pickGoodStarterWord, restrict your search to the words of length wordLength, and once you're done, increment wordLength (unless it's already at axWordLength) so that the next invocation will return a larger word.


Solution to milestone 3


Extensions

This activity (like all future activities) contains some optional extensions. If time permits, attempt at least one extension from the list below or one you have invented yourself.


  • Two-letter mode: switch to allowing the user to add two letters to form anagrams.


  • Optimize word selection by removing words that do not have enough anagrams from the pool of possible starter words. Note that those words should remain in wordSet since they can still be used as anagrams in other words.


  • Two-word mode: Allow the user to add one letter to a pair of words to form two new valid words.



Congratulations on making it to the end! You’ve explored how Lists, HashSets, and HashMaps power efficient data handling in Flutter, just like they do in any well-optimized application. Understanding these structures gives you an edge in writing scalable and performant code. So go ahead, and give yourself a well-deserved pat on the back! Now, put this knowledge into action and keep building amazing things!