1. Before you begin
Imagine being asked whether it's possible to create the world's largest crossword puzzle. You recall some AI techniques that you studied at school and wonder whether you can use Flutter to explore the algorithmic options to create solutions to computationally intensive problems.
In this codelab, you do exactly that. By the end, you build a tool to play in the space of algorithms for constructing word grid puzzles. There are many different definitions of what is a valid crossword puzzle and these techniques help you build puzzles that fit your definition.
With this tool as a base, you then craft a crossword puzzle that uses the crossword generator to build the puzzle for a user to solve. This puzzle is usable on Android, iOS, Windows, macOS, and Linux. Here it is on Android:
Prerequisites
- Completion of the Your first Flutter app codelab
What you learn
- How to use isolates to do computationally expensive work without impeding Flutter's render loop with a combination of Flutter's
compute
function and Riverpod'sselect
rebuild filter's value-caching capabilities. - How to take advantage of immutable data structures with
built_value
andbuilt_collection
to make the search-based Good Old Fashioned AI (GOFAI) techniques like depth-first search and backtracking easy to implement. - How to use the capabilities of the
two_dimensional_scrollables
package to display grid data in a fast and intuitive way.
What you need
- The Flutter SDK.
- Visual Studio Code (VS Code) with the Flutter and Dart plugins.
- Compiler software for your chosen development target. This codelab works for all desktop platforms, Android, and iOS. You need VS Code to target Windows, Xcode to target macOS or iOS, and Android Studio to target Android.
2. Create a project
Create your first Flutter project
- Launch VS Code.
- In the command line, enter flutter new and then select Flutter: New Project in the menu.
- Select Empty application and then choose a directory in which to create your project. This should be any directory that doesn't require elevated privileges or have a space in its path. Examples include your home directory or
C:\src\
.
- Name your project
generate_crossword
. The remainder of this codelab presumes you named your appgenerate_crossword
.
Flutter now creates your project folder and VS Code opens it. You'll now overwrite the contents of two files with a basic scaffold of the app.
Copy and paste the initial app
- In the left pane of VS Code, click Explorer and open the
pubspec.yaml
file.
- Replace the contents of this file with the following:
pubspec.yaml
name: generate_crossword
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.3 <4.0.0'
dependencies:
built_collection: ^5.1.1
built_value: ^8.9.2
characters: ^1.3.0
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
intl: ^0.19.0
riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
two_dimensional_scrollables: ^0.2.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
build_runner: ^2.4.9
built_value_generator: ^8.9.2
custom_lint: ^0.6.4
riverpod_generator: ^2.4.0
riverpod_lint: ^2.3.10
flutter:
uses-material-design: true
The pubspec.yaml
file specifies basic information about your app, such as its current version, and its dependencies. You see a collection of dependencies that aren't part of a normal empty Flutter app. You benefit from all these packages in the coming steps.
- Open the
main.dart
file inlib/
directory.
- Replace the contents of this file with the following:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: Scaffold(
body: Center(
child: Text(
'Hello, World!',
style: TextStyle(fontSize: 24),
),
),
),
),
),
);
}
- Run this code to check everything works. It should display a new window with the obligatory starting phrase of every new project everywhere. There's a
ProviderScope
, indicating that this app will useriverpod
for state management.
3. Add words
Building blocks for a crossword puzzle
A crossword is, at its heart, a list of words. The words are arranged in a grid, some across, some down, such that the words interlock. Solving one word gives clues as to the words that cross that first word. Thus, the first building block has to be a list of words.
A good source for these words is Peter Norvig's Natural Language Corpus Data page. The SOWPODS list as a useful starting point, with its 267,750 words.
In this step, you download a list of words, add it as an asset to your Flutter app, and arrange a Riverpod provider to load the list into the app at startup.
To begin, follow these steps:
- Modify your project's
pubspec.yaml
file to add the following asset declaration for your selected word list. This listing shows only the flutter stanza of your app's configuration, as the rest has stayed the same.
pubspec.yaml
flutter:
uses-material-design: true
assets: // Add this line
- assets/words.txt // And this one.
Your editor will probably highlight this last line with a warning because you have yet to create this file.
- Using your browser and your editor, create an
assets
directory at the top level of your project and create awords.txt
file in it with one of the word lists linked above.
This code was designed with the SOWPODS list mentioned above, but should work with any word list that consists of only A-Z characters. Extending this codebase to work with different character sets is left as an exercise to the reader.
Load the words
To write the code responsible for loading the word list at app startup, follow these steps:
- Create a
providers.dart
file in thelib
directory. - Add the following to the file:
lib/providers.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
This is your first Riverpod provider for this codebase. You will notice that there are several areas that your editor will be complaining about as either an undefined class or an ungenerated target. This project uses code generation for multiple dependencies, including Riverpod, so the undefined class errors are expected.
- To start generating code, run the following command:
$ dart run build_runner watch -d [INFO] Generating build script completed, took 174ms [INFO] Setting up file watchers completed, took 5ms [INFO] Waiting for all file watchers to be ready completed, took 202ms [INFO] Reading cached asset graph completed, took 65ms [INFO] Checking for updates since last build completed, took 680ms [INFO] Running build completed, took 2.3s [INFO] Caching finalized dependency graph completed, took 42ms [INFO] Succeeded after 2.3s with 122 outputs (243 actions)
It will continue to run in the background, updating generated files as you make changes to the project. Once this command has generated the code in providers.g.dart
, your editor should be happy with the code you added to providers.dart
above.
In Riverpod, providers like the wordList
function you defined above are generally lazily instantiated. However, for the purposes of this app, you need the word list loaded eagerly. The Riverpod documentation suggests the following approach for dealing with providers that you need loaded eagerly. You will implement that now.
- Create a
crossword_generator_app.dart
file in alib/widgets
directory. - Add the following to the file:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer(
builder: (context, ref, _) {
final wordListAsync = ref.watch(wordListProvider);
return wordListAsync.when(
data: (wordList) => ListView.builder(
itemCount: wordList.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(wordList.elementAt(index)),
);
},
),
error: (error, stackTrace) => Center(
child: Text('$error'),
),
loading: () => Center(
child: CircularProgressIndicator(),
),
);
},
),
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
This file is interesting from two separate directions. The first is the _EagerInitialization
widget, which has the sole mission of requiring the wordList
provider you created above to load the word list. This widget achieves this objective by listening to the provider using the ref.watch()
call. You can read more about this technique in the Riverpod documentation on Eager initialization of providers.
The second interesting point to note in this file is how Riverpod handles asynchronous content. As you may recall, the wordList
provider is defined as an asynchronous function, as loading content from disk is slow. In watching the word list provider in this code, you receive a AsyncValue<BuiltSet<String>>
. The AsyncValue
part of that type is an adaptor between the asynchronous world of providers and the synchronous world of the Widget's build
method.
The AsyncValue
's when
method handles the three potential states that the future value may be in. The future may have been resolved successfully, in which case the data
callback is invoked, it may be in an error state, in which case the error
callback is invoked, or finally it may still be loading. The return types of the three callbacks must have compatible return types, as the return of the called callback is returned by the when
method. In this instance, the result of the when method is displayed as the body
of the Scaffold
widget.
Create a nearly infinite list app
To integrate the CrosswordGeneratorApp
widget into your app, follow these steps:
- Update the
lib/main.dart
file by adding the following code:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_generator_app.dart'; // Add this import
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Builder',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordGeneratorApp(), // Remove what was here and replace
),
),
);
}
- Restart the app. You should see a scrolling list that will go on nearly forever.
4. Display the words in a grid
In this step, you will create a data structure for creating a crossword puzzle using the built_value
and built_collection
packages. These two packages enable constructing data structures as immutable values, which will be useful for both passing data easily between Isolates, and making implementing depth first search and backtracking a lot easier.
To begin, follow these steps:
- Create a
model.dart
file in thelib
directory and then add the following content to the file:
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
static Serializer<Location> get serializer => _$locationSerializer;
/// The horizontal part of the location. The location is 0 based.
int get x;
/// The vertical part of the location. The location is 0 based.
int get y;
/// Returns a new location that is one step to the left of this location.
Location get left => rebuild((b) => b.x = x - 1);
/// Returns a new location that is one step to the right of this location.
Location get right => rebuild((b) => b.x = x + 1);
/// Returns a new location that is one step up from this location.
Location get up => rebuild((b) => b.y = y - 1);
/// Returns a new location that is one step down from this location.
Location get down => rebuild((b) => b.y = y + 1);
/// Returns a new location that is [offset] steps to the left of this location.
Location leftOffset(int offset) => rebuild((b) => b.x = x - offset);
/// Returns a new location that is [offset] steps to the right of this location.
Location rightOffset(int offset) => rebuild((b) => b.x = x + offset);
/// Returns a new location that is [offset] steps up from this location.
Location upOffset(int offset) => rebuild((b) => b.y = y - offset);
/// Returns a new location that is [offset] steps down from this location.
Location downOffset(int offset) => rebuild((b) => b.y = y + offset);
/// Pretty print a location as a (x,y) coordinate.
String prettyPrint() => '($x,$y)';
/// Returns a new location built from [updates]. Both [x] and [y] are
/// required to be non-null.
factory Location([void Function(LocationBuilder)? updates]) = _$Location;
Location._();
/// Returns a location at the given coordinates.
factory Location.at(int x, int y) {
return Location((b) {
b
..x = x
..y = y;
});
}
}
/// The direction of a word in a crossword.
enum Direction {
across,
down;
@override
String toString() => name;
}
/// A word in a crossword. This is a word at a location in a crossword, in either
/// the across or down direction.
abstract class CrosswordWord
implements Built<CrosswordWord, CrosswordWordBuilder> {
static Serializer<CrosswordWord> get serializer => _$crosswordWordSerializer;
/// The word itself.
String get word;
/// The location of this word in the crossword.
Location get location;
/// The direction of this word in the crossword.
Direction get direction;
/// Compare two CrosswordWord by coordinates, x then y.
static int locationComparator(CrosswordWord a, CrosswordWord b) {
final compareRows = a.location.y.compareTo(b.location.y);
final compareColumns = a.location.x.compareTo(b.location.x);
return switch (compareColumns) { 0 => compareRows, _ => compareColumns };
}
/// Constructor for [CrosswordWord].
factory CrosswordWord.word({
required String word,
required Location location,
required Direction direction,
}) {
return CrosswordWord((b) => b
..word = word
..direction = direction
..location.replace(location));
}
/// Constructor for [CrosswordWord].
/// Use [CrosswordWord.word] instead.
factory CrosswordWord([void Function(CrosswordWordBuilder)? updates]) =
_$CrosswordWord;
CrosswordWord._();
}
/// A character in a crossword. This is a single character at a location in a
/// crossword. It may be part of an across word, a down word, both, but not
/// neither. The neither constraint is enforced elsewhere.
abstract class CrosswordCharacter
implements Built<CrosswordCharacter, CrosswordCharacterBuilder> {
static Serializer<CrosswordCharacter> get serializer =>
_$crosswordCharacterSerializer;
/// The character at this location.
String get character;
/// The across word that this character is a part of.
CrosswordWord? get acrossWord;
/// The down word that this character is a part of.
CrosswordWord? get downWord;
/// Constructor for [CrosswordCharacter].
/// [acrossWord] and [downWord] are optional.
factory CrosswordCharacter.character({
required String character,
CrosswordWord? acrossWord,
CrosswordWord? downWord,
}) {
return CrosswordCharacter((b) {
b.character = character;
if (acrossWord != null) {
b.acrossWord.replace(acrossWord);
}
if (downWord != null) {
b.downWord.replace(downWord);
}
});
}
/// Constructor for [CrosswordCharacter].
/// Use [CrosswordCharacter.character] instead.
factory CrosswordCharacter(
[void Function(CrosswordCharacterBuilder)? updates]) =
_$CrosswordCharacter;
CrosswordCharacter._();
}
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Add a word to the crossword at the given location and direction.
Crossword addWord({
required Location location,
required String word,
required Direction direction,
}) {
return rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width, (_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
])
final Serializers serializers = _$serializers;
This file describes the start of the data structure that you will use for creating crosswords. At its heart, a crossword puzzle is a list of horizontal and vertical words interlocked in a grid. To use this data structure, you construct a Crossword
of the appropriate size with the Crossword.crossword
named constructor, then you add words using the addWord
method. As part of constructing the finalized value, a grid of CrosswordCharacter
s is created by the _fillCharacters
method.
To use this data structure, follow these steps:
- Create a
utils
file in thelib
directory and then add the following content to the file:
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
This is an extension on BuiltSet
that makes it effortless to retrieve a random element of the set. Extension methods make it easy to extend classes with additional functionality. Naming the extension is required to make the extension available outside of the utils.dart
file.
- In your
lib/providers.dart
file, add the following imports:
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart'; // Add this import
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model; // And this import
import 'utils.dart'; // And this one
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
These imports expose the model defined above to the providers you are about to create. The dart:math
import is included for Random
, the flutter/foundation.dart
import is included for debugPrint
, model.dart
for the model, and utils.dart
for the BuiltSet
extension.
- At the end of the same file, add the following providers:
lib/providers.dart
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
crossword = crossword.addWord(
word: word, direction: direction, location: location);
yield crossword;
await Future.delayed(Duration(milliseconds: 100));
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
These changes add two providers to your app. The first is Size
, which is effectively a global variable that contains the currently selected value of the CrosswordSize
enumeration. This will allow the UI to both display and set the size of the crossword under construction. The second provider, crossword
, is a more interesting creation. It is a function that returns a series of Crossword
s. It is built using Dart's support for generators, as marked by the async*
on the function. This means that instead of ending at a return, it yields a series of Crossword
s, a much easier way to write a computation that returns intermediate results.
Due to the presence of a pair of ref.watch
calls at the start of the crossword
provider function, the stream of Crossword
s will be restarted by the Riverpod system each time the selected size of the crossword changes and when the word list finishes loading.
Now that you have code to generate Crosswords, albeit full of random words, it would be nice to show them to the user of the tool.
- Create a
crossword_widget.dart
file in thelib/widgets
directory with the following content:
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
crosswordProvider.select(
(crosswordAsync) => crosswordAsync.when(
data: (crossword) => crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
if (character != null) {
return Container(
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: Text(
character.character,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
This widget, being a ConsumerWidget
, can rely directly on the Size
provider to determine the size of the grid to display the characters of the Crossword
on. The display of this grid is accomplished with the TableView
widget from the two_dimensional_scrollables
package.
It is worth noting that the individual cells rendered by the _buildCell
helper functions each contain in their returned Widget
tree a Consumer
widget. This acts as a refresh boundary. Everything inside the Consumer
widget is recreated when the ref.watch
's returned value changes. It is tempting to recreate the whole tree every time the Crossword
changes, however this causes a lot of computation that can be skipped by using this setup.
If you look at the ref.watch
's parameter, you will see there is another layer of avoidance of recomputing layouts, by using crosswordProvider.select
. This means that the ref.watch
will only trigger a rebuild of the TableViewCell
's contents when the character that the cell is responsible for rendering changes. This reduction in re-rendering is an essential part of keeping the UI responsive.
To expose the CrosswordWidget
and Size
provider to the user, change the crossword_generator_app.dart
file as follows:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_widget.dart'; // Add this import
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()], // Add this line
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: CrosswordWidget(), // Replaces everything that was here before
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget { // Add from here
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
); // To here.
}
A few things have changed here. First, the code responsible for rendering the wordList
as a ListView
has been replaced with a call to the CrosswordWidget
that was defined in the previous file. The other major change is the start of a menu for changing the behavior of the app, starting with changing the size of the crossword. There will be more MenuItemButton
s added in future steps. Run your app, you will see something like this:
There are characters displayed in a grid and a menu enabling the user to change the size of the grid. But the words are not laid out like a crossword puzzle. This is a result of not enforcing any constraints on how words are added to the crossword. In short, it's a mess. Something you will start bringing under control in the next step!
5. Enforce constraints
Adding code to the model to enforce crossword constraints is the goal of this step. There are many different types of crossword puzzles, and the style this codelab will enforce follows in the traditions of the English crossword puzzles. Modifying this code to generate other styles of crossword puzzles is, as ever, left as an exercise to the reader.
To begin, follow these steps:
- Open the
model.dart
file and replace theCrossword
model with the following:
lib/model.dart
/// A crossword puzzle. This is a grid of characters with words placed in it.
/// The puzzle constraint is in the English crossword puzzle tradition.
abstract class Crossword implements Built<Crossword, CrosswordBuilder> {
/// Serializes and deserializes the [Crossword] class.
static Serializer<Crossword> get serializer => _$crosswordSerializer;
/// Width across the [Crossword] puzzle.
int get width;
/// Height down the [Crossword] puzzle.
int get height;
/// The words in the crossword.
BuiltList<CrosswordWord> get words;
/// The characters by location. Useful for displaying the crossword,
/// or checking the proposed solution.
BuiltMap<Location, CrosswordCharacter> get characters;
/// Checks if this crossword is valid.
bool get valid {
// Check that there are no duplicate words.
final wordSet = words.map((word) => word.word).toBuiltSet();
if (wordSet.length != words.length) {
return false;
}
for (final MapEntry(key: location, value: character)
in characters.entries) {
// All characters must be a part of an across or down word.
if (character.acrossWord == null && character.downWord == null) {
return false;
}
// All characters must be within the crossword puzzle.
// No drawing outside the lines.
if (location.x < 0 ||
location.y < 0 ||
location.x >= width ||
location.y >= height) {
return false;
}
// Characters above and below this character must be related
// by a vertical word
if (characters[location.up] case final up?) {
if (character.downWord == null) {
return false;
}
if (up.downWord != character.downWord) {
return false;
}
}
if (characters[location.down] case final down?) {
if (character.downWord == null) {
return false;
}
if (down.downWord != character.downWord) {
return false;
}
}
// Characters to the left and right of this character must be
// related by a horizontal word
final left = characters[location.left];
if (left != null) {
if (character.acrossWord == null) {
return false;
}
if (left.acrossWord != character.acrossWord) {
return false;
}
}
final right = characters[location.right];
if (right != null) {
if (character.acrossWord == null) {
return false;
}
if (right.acrossWord != character.acrossWord) {
return false;
}
}
}
return true;
}
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
if (words.isNotEmpty && !overlap) {
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
/// As a finalize step, fill in the characters map.
@BuiltValueHook(finalizeBuilder: true)
static void _fillCharacters(CrosswordBuilder b) {
b.characters.clear();
for (final word in b.words.build()) {
for (final (idx, character) in word.word.characters.indexed) {
switch (word.direction) {
case Direction.across:
b.characters.updateValue(
word.location.rightOffset(idx),
(b) => b.rebuild((bInner) => bInner.acrossWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
acrossWord: word,
character: character,
),
);
case Direction.down:
b.characters.updateValue(
word.location.downOffset(idx),
(b) => b.rebuild((bInner) => bInner.downWord.replace(word)),
ifAbsent: () => CrosswordCharacter.character(
downWord: word,
character: character,
),
);
}
}
}
}
/// Pretty print a crossword. Generates the character grid, and lists
/// the down words and across words sorted by location.
String prettyPrintCrossword() {
final buffer = StringBuffer();
final grid = List.generate(
height,
(_) => List.generate(
width, (_) => '░', // https://www.compart.com/en/unicode/U+2591
),
);
for (final MapEntry(key: Location(:x, :y), value: character)
in characters.entries) {
grid[y][x] = character.character;
}
for (final row in grid) {
buffer.writeln(row.join());
}
buffer.writeln();
buffer.writeln('Across:');
for (final word
in words.where((word) => word.direction == Direction.across).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
buffer.writeln();
buffer.writeln('Down:');
for (final word
in words.where((word) => word.direction == Direction.down).toList()
..sort(CrosswordWord.locationComparator)) {
buffer.writeln('${word.location.prettyPrint()}: ${word.word}');
}
return buffer.toString();
}
/// Constructor for [Crossword].
factory Crossword.crossword({
required int width,
required int height,
Iterable<CrosswordWord>? words,
}) {
return Crossword((b) {
b
..width = width
..height = height;
if (words != null) {
b.words.addAll(words);
}
});
}
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
As a quick reminder, the changes you are making to the model.dart
and providers.dart
files require build_runner
to be running to update the respective model.g.dart
and providers.g.dart
files. If these files haven't automagically updated themselves, now is a good time to start build_runner
again with dart run build_runner watch -d
.
To take advantage of this new capability in the model layer, you need to update the provider layer to match.
- Edit your
providers.dart
file as follows:
lib/providers.dart
import 'dart:convert';
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'model.dart' as model;
import 'utils.dart';
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
final _random = Random();
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
var candidate = crossword.addWord( // Edit from here
word: word, direction: direction, location: location);
await Future.delayed(Duration(milliseconds: 10));
if (candidate != null) {
debugPrint('Added word: $word');
crossword = candidate;
yield crossword;
} else {
debugPrint('Failed to add word: $word');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
- Run your app. Not much is happening in the UI, but there is a lot happening if you look at the logs.
If you think about what is happening here, we are seeing a crossword appear by random chance. The addWord
method in the Crossword
model is rejecting any proposed word that doesn't fit in the current crossword, so it is amazing that we are seeing anything at all appear.
In preparation for being more methodical about choosing what words to try where, it would be very helpful to move this computation off the UI thread and into a background isolate. Flutter has a very useful wrapper for taking a chunk of work and running it in a background isolate - the compute
function.
- In the
providers.dart
file, modify the crossword provider as follows:
lib/providers.dart
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
var crossword =
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) async* {
while (crossword.characters.length < size.width * size.height * 0.8) {
final word = wordList.randomElement();
final direction =
_random.nextBool() ? model.Direction.across : model.Direction.down;
final location = model.Location.at(
_random.nextInt(size.width), _random.nextInt(size.height));
try {
var candidate = await compute( // Edit from here.
((String, model.Direction, model.Location) wordToAdd) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word, direction: direction, location: location);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
} // To here.
}
yield crossword;
},
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield crossword;
},
loading: () async* {
yield crossword;
},
);
}
This code works. However, it contains a trap. If you keep on this path you will eventually wind up with a logged error like the following:
flutter: Error running isolate: Invalid argument(s): Illegal argument in isolate message: object is unsendable - Library:'dart:async' Class: _Future@4048458 (see restrictions listed at `SendPort.send()` documentation for more information) flutter: <- Instance of 'AutoDisposeStreamProviderElement<Crossword>' (from package:riverpod/src/stream_provider.dart) flutter: <- Context num_variables: 2 <- Context num_variables: 1 parent:{ Context num_variables: 2 } flutter: <- Context num_variables: 1 parent:{ Context num_variables: 1 parent:{ Context num_variables: 2 } } flutter: <- Closure: () => Crossword? (from package:generate_crossword/providers.dart)
This is the result of the closure that compute
is handing off to the background isolate closing over a provider, which can't be sent over SendPort.send()
. One fix for this is to make sure there is nothing for the closure to close over that isn't sendable.
A first step is to separate the providers from the Isolate code.
- Create an
isolates.dart
file in yourlib
directory and then add the following content to it:
lib/isolates.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
final _random = Random();
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
while (
crossword.characters.length < crossword.width * crossword.height * 0.8) {
final word = wordList.randomElement();
final direction = _random.nextBool() ? Direction.across : Direction.down;
final location = Location.at(
_random.nextInt(crossword.width), _random.nextInt(crossword.height));
try {
var candidate = await compute(((String, Direction, Location) wordToAdd) {
final (word, direction, location) = wordToAdd;
return crossword.addWord(
word: word, direction: direction, location: location);
}, (word, direction, location));
if (candidate != null) {
crossword = candidate;
yield crossword;
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
}
This code should look reasonably familiar. It is the core of what was in the crossword
provider, but now as a standalone generator function. Now you can update your providers.dart
file to use this new function to instantiate the background isolate.
lib/providers.dart
// Drop the dart:math import, the _random instance moved to isolates.dart
import 'dart:convert';
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart'; // Add this import
import 'model.dart' as model;
// Drop the utils.dart import
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
// Drop the _random instance
@riverpod
Stream<model.Crossword> crossword(CrosswordRef ref) async* {
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword = // Edit from here
model.Crossword.crossword(width: size.width, height: size.height);
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyCrossword;
},
loading: () async* {
yield emptyCrossword; // To here.
},
);
}
With this you now have a tool that is creating crossword puzzles of different sizes, with the compute
of figuring out the puzzle happening in a background isolate. Now, if only the code could be more efficient when deciding what words to try to add to the crossword puzzle.
6. Manage the work queue
Part of the issue with the code as it stands is that the problem being solved is effectively a search problem, and the current solution is searching blind. If the code concentrates on finding words that will attach to the current words, instead of randomly trying to place words anywhere on the grid, then the system would find solutions faster. A way of approaching this is to introduce a work queue of locations to attempt to find words for.
The code currently builds candidate solutions, checks if the candidate solution is valid, and depending on validity either incorporates the candidate or throws it away. This is an example implementation from the backtracking family of algorithms. This implementation is eased a lot by built_value
and built_collection
, which enable creating new immutable values that derive, and thus share, common state with the immutable value they were derived from. This enables cheap exploitation of potential candidates without the memory costs required for deep copying.
To begin, follow these steps:
- Open the
model.dart
file and add the followingWorkQueue
definition to it:
lib/model.dart
/// Constructor for [Crossword].
/// Use [Crossword.crossword] instead.
factory Crossword([void Function(CrosswordBuilder)? updates]) = _$Crossword;
Crossword._();
}
// Add from here
/// A work queue for a worker to process. The work queue contains a crossword
/// and a list of locations to try, along with candidate words to add to the
/// crossword.
abstract class WorkQueue implements Built<WorkQueue, WorkQueueBuilder> {
static Serializer<WorkQueue> get serializer => _$workQueueSerializer;
/// The crossword the worker is working on.
Crossword get crossword;
/// The outstanding queue of locations to try.
BuiltMap<Location, Direction> get locationsToTry;
/// Known bad locations.
BuiltSet<Location> get badLocations;
/// The list of unused candidate words that can be added to this crossword.
BuiltSet<String> get candidateWords;
/// Returns true if the work queue is complete.
bool get isCompleted => locationsToTry.isEmpty || candidateWords.isEmpty;
/// Create a work queue from a crossword.
static WorkQueue from({
required Crossword crossword,
required Iterable<String> candidateWords,
required Location startLocation,
}) =>
WorkQueue((b) {
if (crossword.words.isEmpty) {
// Strip candidate words too long to fit in the crossword
b.candidateWords.addAll(candidateWords
.where((word) => word.characters.length <= crossword.width));
b.crossword.replace(crossword);
b.locationsToTry.addAll({startLocation: Direction.across});
} else {
// Assuming words have already been stripped to length
b.candidateWords.addAll(
candidateWords.toBuiltSet().rebuild(
(b) => b.removeAll(crossword.words.map((word) => word.word))),
);
b.crossword.replace(crossword);
crossword.characters
.rebuild((b) => b.removeWhere((location, character) {
if (character.acrossWord != null &&
character.downWord != null) {
return true;
}
final left = crossword.characters[location.left];
if (left != null && left.downWord != null) return true;
final right = crossword.characters[location.right];
if (right != null && right.downWord != null) return true;
final up = crossword.characters[location.up];
if (up != null && up.acrossWord != null) return true;
final down = crossword.characters[location.down];
if (down != null && down.acrossWord != null) return true;
return false;
}))
.forEach((location, character) {
b.locationsToTry.addAll({
location: switch ((character.acrossWord, character.downWord)) {
(null, null) =>
throw StateError('Character is not part of a word'),
(null, _) => Direction.across,
(_, null) => Direction.down,
(_, _) => throw StateError('Character is part of two words'),
}
});
});
}
});
WorkQueue remove(Location location) => rebuild((b) => b
..locationsToTry.remove(location)
..badLocations.add(location));
/// Update the work queue from a crossword derived from the current crossword
/// that this work queue is built from.
WorkQueue updateFrom(final Crossword crossword) => WorkQueue.from(
crossword: crossword,
candidateWords: candidateWords,
startLocation: locationsToTry.isNotEmpty
? locationsToTry.keys.first
: Location.at(0, 0),
).rebuild((b) => b
..badLocations.addAll(badLocations)
..locationsToTry
.removeWhere((location, _) => badLocations.contains(location)));
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue, // Add this line
])
final Serializers serializers = _$serializers;
- If you have red squiggles remaining in this file after adding this new content for more than a few seconds, confirm that your
build_runner
is still running. If not, run thedart run build_runner watch -d
command.
In the code you are about to introduce logging to show how long it takes to create crosswords at various sizes. It would be neat if Durations had some form of nicely formatted display. Thankfully, with extension methods we can add the exact method we need.
- Edit the
utils.dart
file as follows:
lib/utils.dart
import 'dart:math';
import 'package:built_collection/built_collection.dart';
/// A [Random] instance for generating random numbers.
final _random = Random();
/// An extension on [BuiltSet] that adds a method to get a random element.
extension RandomElements<E> on BuiltSet<E> {
E randomElement() {
return elementAt(_random.nextInt(length));
}
}
// Add from here
/// An extension on [Duration] that adds a method to format the duration.
extension DurationFormat on Duration {
/// A human-readable string representation of the duration.
/// This format is tuned for durations in the seconds to days range.
String get formatted {
final hours = inHours.remainder(24).toString().padLeft(2, '0');
final minutes = inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = inSeconds.remainder(60).toString().padLeft(2, '0');
return switch ((inDays, inHours, inMinutes, inSeconds)) {
(0, 0, 0, _) => '${inSeconds}s',
(0, 0, _, _) => '$inMinutes:$seconds',
(0, _, _, _) => '$inHours:$minutes:$seconds',
_ => '$inDays days, $hours:$minutes:$seconds',
};
}
} // To here.
This extension method takes advantage of switch expressions and pattern matching over records to select the appropriate way to display different durations ranging from seconds to days. For more information about this style of code, see the Dive into Dart's patterns and records codelab.
- To integrate this new functionality, replace the
isolates.dart
file to redefine how theexploreCrosswordSolutions
function is defined as follows:
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<Crossword> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword);
yield crossword;
} else {
workQueue = workQueue.remove(location);
}
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}');
}
Running this code will result in an app that looks identical on the surface, but the difference is in how long it takes to find a finished crossword puzzle. Here is a 80 x 44 crossword puzzle generated in 1 minute and 29 seconds.
The obvious question is of course, can we go faster? Oh yes, yes we can.
7. Surface statistics
In making something fast, it helps to see what is going on. One thing that helps in this is to surface information about the process as it is under way. So, it is now time to add instrumentation and display that information as a hovering information panel.
The information you will display needs to be extracted from the WorkQueue and displayed in the UI.
A useful first step is to define a new model class that contains the information you want to display.
To begin, follow these steps:
- Edit the
model.dart
file as follows to add theDisplayInfo
class:
lib/model.dart
import 'package:built_collection/built_collection.dart';
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
import 'package:characters/characters.dart';
import 'package:intl/intl.dart'; // Add this import
part 'model.g.dart';
/// A location in a crossword.
abstract class Location implements Built<Location, LocationBuilder> {
- At the end of the file, make the following changes to add the
DisplayInfo
class:
lib/model.dart
/// Factory constructor for [WorkQueue]
factory WorkQueue([void Function(WorkQueueBuilder)? updates]) = _$WorkQueue;
WorkQueue._();
}
// Add from here.
/// Display information for the current state of the crossword solve.
abstract class DisplayInfo implements Built<DisplayInfo, DisplayInfoBuilder> {
static Serializer<DisplayInfo> get serializer => _$displayInfoSerializer;
/// The number of words in the grid.
String get wordsInGridCount;
/// The number of candidate words.
String get candidateWordsCount;
/// The number of locations to explore.
String get locationsToExploreCount;
/// The number of known bad locations.
String get knownBadLocationsCount;
/// The percentage of the grid filled.
String get gridFilledPercentage;
/// Construct a [DisplayInfo] instance from a [WorkQueue].
factory DisplayInfo.from({required WorkQueue workQueue}) {
final gridFilled = (workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height));
final fmt = NumberFormat.decimalPattern();
return DisplayInfo((b) => b
..wordsInGridCount = fmt.format(workQueue.crossword.words.length)
..candidateWordsCount = fmt.format(workQueue.candidateWords.length)
..locationsToExploreCount = fmt.format(workQueue.locationsToTry.length)
..knownBadLocationsCount = fmt.format(workQueue.badLocations.length)
..gridFilledPercentage = '${(gridFilled * 100).toStringAsFixed(2)}%');
}
/// An empty [DisplayInfo] instance.
static DisplayInfo get empty => DisplayInfo((b) => b
..wordsInGridCount = '0'
..candidateWordsCount = '0'
..locationsToExploreCount = '0'
..knownBadLocationsCount = '0'
..gridFilledPercentage = '0%');
factory DisplayInfo([void Function(DisplayInfoBuilder)? updates]) =
_$DisplayInfo;
DisplayInfo._();
} // To here.
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo, // Add this line.
])
final Serializers serializers = _$serializers;
- Modify the
isolates.dart
file to expose theWorkQueue
model as follows:
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({ // Modify this line
required Crossword crossword,
required BuiltSet<String> wordList,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
final location = workQueue.locationsToTry.keys.toBuiltSet().randomElement();
try {
final crossword = await compute(((WorkQueue, Location) workMessage) {
final (workQueue, location) = workMessage;
final direction = workQueue.locationsToTry[location]!;
final target = workQueue.crossword.characters[location];
if (target == null) {
return workQueue.crossword.addWord(
direction: direction,
location: location,
word: workQueue.candidateWords.randomElement(),
);
}
var words = workQueue.candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = workQueue.crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return candidate;
}
}
if (tryCount > 1000) {
break;
}
}
}, (workQueue, location));
if (crossword != null) {
workQueue = workQueue.updateFrom(crossword); // Drop the yield crossword;
} else {
workQueue = workQueue.remove(location);
}
yield workQueue; // Add this line.
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('${crossword.width} x ${crossword.height} Crossword generated in '
'${DateTime.now().difference(start).formatted}');
}
Now that the background isolate is exposing the work queue, it is now a question of how and where to derive statistics from this data source.
- Replace the old crossword provider with a work queue provider and then add more providers that derive information from the work queue provider's stream:
lib/providers.dart
import 'dart:convert';
import 'dart:math'; // Add this import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* { // Modify this provider
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword =
model.Crossword.crossword(width: size.width, height: size.height);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
} // To here.
@Riverpod(keepAlive: true) // Add from here to end of file
class StartTime extends _$StartTime {
@override
DateTime? build() => _start;
DateTime? _start;
void start() {
_start = DateTime.now();
ref.invalidateSelf();
}
}
@Riverpod(keepAlive: true)
class EndTime extends _$EndTime {
@override
DateTime? build() => _end;
DateTime? _end;
void clear() {
_end = null;
ref.invalidateSelf();
}
void end() {
_end = DateTime.now();
ref.invalidateSelf();
}
}
const _estimatedTotalCoverage = 0.54;
@riverpod
Duration expectedRemainingTime(ExpectedRemainingTimeRef ref) {
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final workQueueAsync = ref.watch(workQueueProvider);
return workQueueAsync.when(
data: (workQueue) {
if (startTime == null || endTime != null || workQueue.isCompleted) {
return Duration.zero;
}
try {
final soFar = DateTime.now().difference(startTime);
final completedPercentage = min(
0.99,
(workQueue.crossword.characters.length /
(workQueue.crossword.width * workQueue.crossword.height) /
_estimatedTotalCoverage));
final expectedTotal = soFar.inSeconds / completedPercentage;
final expectedRemaining = expectedTotal - soFar.inSeconds;
return Duration(seconds: expectedRemaining.toInt());
} catch (e) {
return Duration.zero;
}
},
error: (error, stackTrace) => Duration.zero,
loading: () => Duration.zero,
);
}
/// A provider that holds whether to display info.
@Riverpod(keepAlive: true)
class ShowDisplayInfo extends _$ShowDisplayInfo {
var _display = true;
@override
bool build() => _display;
void toggle() {
_display = !_display;
ref.invalidateSelf();
}
}
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref.watch(workQueueProvider).when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
The new providers are a mixture of global state, in the form of whether the information display should be overlaid on top of the crossword grid, and derived data like the running time of the crossword generation. All this is complicated by the fact that listeners to some of this state are transitory. Nothing is listening to the start and end times of the crossword computation if the info display is hidden, but they need to stay in memory if the calculation is to be accurate when the info display is shown. The Riverpod
attribute's keepAlive
parameter is very useful in this case.
In showing the info display, there is a slight wrinkle. We want the ability to show the currently elapsed run time, but there is nothing here to easily force the constant update of the currently elapsed time. Reaching back into the Building next generation UIs in Flutter codelab, here is a useful widget for just this requirement.
- Create a
ticker_builder.dart
file in thelib/widgets
directory and then add the following content to it:
lib/widgets/ticker_builder.dart
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// A Builder widget that invokes its [builder] function on every animation frame.
class TickerBuilder extends StatefulWidget {
const TickerBuilder({super.key, required this.builder});
final Widget Function(BuildContext context) builder;
@override
State<TickerBuilder> createState() => _TickerBuilderState();
}
class _TickerBuilderState extends State<TickerBuilder>
with SingleTickerProviderStateMixin {
late final Ticker _ticker;
@override
void initState() {
super.initState();
_ticker = createTicker(_handleTick)..start();
}
@override
void dispose() {
_ticker.dispose();
super.dispose();
}
void _handleTick(Duration elapsed) {
setState(() {
// Force a rebuild without changing the widget tree.
});
}
@override
Widget build(BuildContext context) => widget.builder.call(context);
}
This widget is a sledgehammer. It rebuilds its content on every frame. This is generally frowned upon, but compared to the computational load of searching for crossword puzzles, the computational load of repainting the elapsed time every frame will probably disappear into the noise. To take advantage of this newly derived information, it is time to create a new widget.
- Create a
crossword_info_widget.dart
file in yourlib/widgets
directory and then add the following content to it:
lib/widgets/crossword_info_widget.dart
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(
right: 32.0,
bottom: 32.0,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16, color: Theme.of(context).colorScheme.primary),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}'),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage),
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining', value: remaining.formatted),
],
),
),
),
),
),
),
);
}
}
class _CrosswordInfoRichText extends StatelessWidget {
final String label;
final String value;
const _CrosswordInfoRichText({required this.label, required this.value});
@override
Widget build(BuildContext context) => RichText(
text: TextSpan(
children: [
TextSpan(
text: '$label ',
style: DefaultTextStyle.of(context).style,
),
TextSpan(
text: value,
style: DefaultTextStyle.of(context)
.style
.copyWith(fontWeight: FontWeight.bold),
),
],
),
);
}
This widget is a prime example of the power of Riverpod's providers. This widget will be marked for rebuilding when any of the five providers updates. The last required change in this step is integrating this new widget into the UI.
- Edit your
crossword_generator_app.dart
file as follows:
lib/widgets/crossword_generator_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_info_widget.dart'; // Add this import
import 'crossword_widget.dart';
class CrosswordGeneratorApp extends StatelessWidget {
const CrosswordGeneratorApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordGeneratorMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Generator'),
),
body: SafeArea(
child: Consumer( // Modify from here
builder: (context, ref, child) {
return Stack(
children: [
Positioned.fill(
child: CrosswordWidget(),
),
if (ref.watch(showDisplayInfoProvider)) CrosswordInfoWidget(),
],
);
},
), // To here.
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menu Children: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton( // Add from here
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () =>
ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
), // To here.
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
The two changes here demonstrate different approaches to integrating providers. In the CrosswordGeneratorApp
's build
method, you introduced a new Consumer
builder to contain the area forced to rebuild when the info display is shown or hidden. On the other hand, the whole drop-down menu is one ConsumerWidget
, which will get rebuilt whether it is the resizing of the crossword or the showing or hiding of the info display. Which approach to take is always an engineering tradeoff of simplicity versus the cost of recalculating layouts of rebuilt widget trees.
Running the app now gives the user more insight into how the crossword generation is progressing. However, near the end of the crossword generation we see there is a period where the numbers are changing, but there is very little change in the grid of characters.
It would be useful to get additional insight as to what is happening and why.
8. Parallelize with threads
To understand why things get slow at the end, it is useful to be able to visualize what the algorithm is doing. A key part is the outstanding locationsToTry
in the WorkQueue
. The TableView gives us a useful way to investigate this. We can change the cell color based on whether it is in locationsToTry
.
To begin, follow these steps:
- Modify the
crossword_widget.dart
file as follows:
lib/widgets/crossword_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordWidget extends ConsumerWidget {
const CrosswordWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch( // Add from here
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
); // To here.
if (character != null) { // Modify from here
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text(character.character),
), // To here.
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
When you run this code, you will see a visualization of the outstanding locations that the algorithm is yet to investigate.
The Interesting thing in watching this as the crossword progresses toward completion is that there is an array of points left to be investigated that won't result in anything useful. There are a couple of options here; one is to cap the investigation once a certain percentage of the crossword cells are filled in and the second is to investigate multiple points of interest at a time. The second path sounds more fun, so let's do that.
- Edit the
isolates.dart
file. This is almost a complete rewrite of the code to split up what was being computed in one background isolate into a pool of N background isolates.
lib/isolates.dart
import 'package:built_collection/built_collection.dart';
import 'package:characters/characters.dart';
import 'package:flutter/foundation.dart';
import 'model.dart';
import 'utils.dart';
Stream<WorkQueue> exploreCrosswordSolutions({
required Crossword crossword,
required BuiltSet<String> wordList,
required int maxWorkerCount,
}) async* {
final start = DateTime.now();
var workQueue = WorkQueue.from(
crossword: crossword,
candidateWords: wordList,
startLocation: Location.at(0, 0),
);
while (!workQueue.isCompleted) {
try {
workQueue = await compute(_generate, (workQueue, maxWorkerCount));
yield workQueue;
} catch (e) {
debugPrint('Error running isolate: $e');
}
}
debugPrint('Generated ${workQueue.crossword.width} x '
'${workQueue.crossword.height} crossword in '
'${DateTime.now().difference(start).formatted} '
'with $maxWorkerCount workers.');
}
Future<WorkQueue> _generate((WorkQueue, int) workMessage) async {
var (workQueue, maxWorkerCount) = workMessage;
final candidateGeneratorFutures = <Future<(Location, Direction, String?)>>[];
final locations = workQueue.locationsToTry.keys.toBuiltList().rebuild((b) => b
..shuffle()
..take(maxWorkerCount));
for (final location in locations) {
final direction = workQueue.locationsToTry[location]!;
candidateGeneratorFutures.add(compute(_generateCandidate,
(workQueue.crossword, workQueue.candidateWords, location, direction)));
}
try {
final results = await candidateGeneratorFutures.wait;
var crossword = workQueue.crossword;
for (final (location, direction, word) in results) {
if (word != null) {
final candidate = crossword.addWord(
location: location, word: word, direction: direction);
if (candidate != null) {
crossword = candidate;
}
} else {
workQueue = workQueue.remove(location);
}
}
workQueue = workQueue.updateFrom(crossword);
} catch (e) {
debugPrint('$e');
}
return workQueue;
}
(Location, Direction, String?) _generateCandidate(
(Crossword, BuiltSet<String>, Location, Direction) searchDetailMessage) {
final (crossword, candidateWords, location, direction) = searchDetailMessage;
final target = crossword.characters[location];
if (target == null) {
return (location, direction, candidateWords.randomElement());
}
// Filter down the candidate word list to those that contain the letter
// at the current location
final words = candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.characters.contains(target.character))
..shuffle());
int tryCount = 0;
final start = DateTime.now();
for (final word in words) {
tryCount++;
for (final (index, character) in word.characters.indexed) {
if (character != target.character) continue;
final candidate = crossword.addWord(
location: switch (direction) {
Direction.across => location.leftOffset(index),
Direction.down => location.upOffset(index),
},
word: word,
direction: direction,
);
if (candidate != null) {
return switch (direction) {
Direction.across => (location.leftOffset(index), direction, word),
Direction.down => (location.upOffset(index), direction, word),
};
}
final deltaTime = DateTime.now().difference(start);
if (tryCount >= 1000 || deltaTime > Duration(seconds: 10)) {
return (location, direction, null);
}
}
}
return (location, direction, null);
}
Most of this code should be familiar as the core business logic has not changed. What has changed is that there are now two layers of compute
calls. The first layer is responsible for farming out individual positions to search to N worker isolates, and then re-combining the results when all N worker isolates are finished. The second layer consists of the N worker isolates. Tuning N to get the best performance is dependent on both your computer and the data in question. The larger the grid, the more workers can work together without getting in each other's way.
The one interesting wrinkle is to note how this code now handles the issue of closures capturing things they shouldn't capture. There are now no closures. The _generate
and _generateWorker
functions are defined as top-level functions, which have no surrounding environment to capture from. The arguments into, and the results out of both of these functions are in the form of Dart records. This is an easy way of working around the one value in, one value out semantics of the compute
call.
Now that you have the ability to create a pool of background workers to search for words that interlock in a grid to form a crossword puzzle, it is time to expose that capability to the rest of the crossword generator tool.
- Edit the
providers.dart
file by editing the workQueue provider as follows:
lib/providers.dart
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* {
final workers = ref.watch(workerCountProvider); // Add this line
final size = ref.watch(sizeProvider);
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword =
model.Crossword.crossword(width: size.width, height: size.height);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
ref.read(startTimeProvider.notifier).start();
ref.read(endTimeProvider.notifier).clear();
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: workers.count, // Add this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
ref.read(endTimeProvider.notifier).end();
}
- Add the
WorkerCount
provider to the end of the file as follows:
lib/providers.dart
/// A provider that summarise the DisplayInfo from a [model.WorkQueue].
@riverpod
class DisplayInfo extends _$DisplayInfo {
@override
model.DisplayInfo build() => ref.watch(workQueueProvider).when(
data: (workQueue) => model.DisplayInfo.from(workQueue: workQueue),
error: (error, stackTrace) => model.DisplayInfo.empty,
loading: () => model.DisplayInfo.empty,
);
}
enum BackgroundWorkers { // Add from here
one(1),
two(2),
four(4),
eight(8),
sixteen(16),
thirtyTwo(32),
sixtyFour(64),
oneTwentyEight(128);
const BackgroundWorkers(this.count);
final int count;
String get label => count.toString();
}
/// A provider that holds the current number of background workers to use.
@Riverpod(keepAlive: true)
class WorkerCount extends _$WorkerCount {
var _count = BackgroundWorkers.four;
@override
BackgroundWorkers build() => _count;
void setCount(BackgroundWorkers count) {
_count = count;
ref.invalidateSelf();
}
} // To here.
With these two changes, the provider layer now exposes a way to set the maximum worker count for the background isolate pool in a way that the isolate functions are correctly configured.
- Update the
crossword_info_widget.dart
file by modifying theCrosswordInfoWidget
as follows:
lib/widgets/crossword_info_widget.dart
class CrosswordInfoWidget extends ConsumerWidget {
const CrosswordInfoWidget({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
final displayInfo = ref.watch(displayInfoProvider);
final workerCount = ref.watch(workerCountProvider).label; // Add this line
final startTime = ref.watch(startTimeProvider);
final endTime = ref.watch(endTimeProvider);
final remaining = ref.watch(expectedRemainingTimeProvider);
return Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: const EdgeInsets.only(
right: 32.0,
bottom: 32.0,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ColoredBox(
color: Theme.of(context).colorScheme.onPrimary.withAlpha(230),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: DefaultTextStyle(
style: TextStyle(
fontSize: 16, color: Theme.of(context).colorScheme.primary),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CrosswordInfoRichText(
label: 'Grid Size',
value: '${size.width} x ${size.height}'),
_CrosswordInfoRichText(
label: 'Words in grid',
value: displayInfo.wordsInGridCount),
_CrosswordInfoRichText(
label: 'Candidate words',
value: displayInfo.candidateWordsCount),
_CrosswordInfoRichText(
label: 'Locations to explore',
value: displayInfo.locationsToExploreCount),
_CrosswordInfoRichText(
label: 'Known bad locations',
value: displayInfo.knownBadLocationsCount),
_CrosswordInfoRichText(
label: 'Grid filled',
value: displayInfo.gridFilledPercentage),
_CrosswordInfoRichText( // Add these two lines
label: 'Max worker count', value: workerCount),
switch ((startTime, endTime)) {
(null, _) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: 'Not started yet',
),
(DateTime start, null) => TickerBuilder(
builder: (context) => _CrosswordInfoRichText(
label: 'Time elapsed',
value: DateTime.now().difference(start).formatted,
),
),
(DateTime start, DateTime end) => _CrosswordInfoRichText(
label: 'Completed in',
value: end.difference(start).formatted),
},
if (startTime != null && endTime == null)
_CrosswordInfoRichText(
label: 'Est. remaining', value: remaining.formatted),
],
),
),
),
),
),
),
);
}
}
- Modify the
crossword_generator_app.dart
file by adding the following section to the_CrosswordGeneratorMenu
widget:
lib/widgets/crossword_generator_app.dart
class _CrosswordGeneratorMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
MenuItemButton(
leadingIcon: ref.watch(showDisplayInfoProvider)
? Icon(Icons.check_box_outlined)
: Icon(Icons.check_box_outline_blank_outlined),
onPressed: () =>
ref.read(showDisplayInfoProvider.notifier).toggle(),
child: Text('Display Info'),
),
for (final count in BackgroundWorkers.values) // Add from here
MenuItemButton(
leadingIcon: count == ref.watch(workerCountProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
onPressed: () =>
ref.read(workerCountProvider.notifier).setCount(count),
child: Text(count.label), // To here.
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
If you run the app now, you will be able to modify the number of background isolates being instantiated to search for words to slot into the crossword.
- Click on the gear icon in the to open the contextual menu containing sizing for the crossword, whether to display the statistics on the currently generated crossword, and now, the number of isolates to use.
Running the crossword generator has significantly reduced the compute time for an 80x44 crossword by using multiple cores concurrently.
9. Turn it into a game
This last section is really a bonus round. You will take all the techniques you have learned while constructing the crossword generator and use these techniques to build a game. You will utilize the crossword generator to create a crossword puzzle. You will reuse the contextual menu idioms to enable the user to select and deselect words to put in the various word-shaped holes in the grid. All with the aim of completing the crossword.
I'm not going to say this game is polished or finished, it's far from in fact. There are balance and difficulty issues which can be solved with improving the choice of alternate words. There is no tutorial to lead users in and the thinking animation leaves a lot to be desired. I'm not even going to mention the bare bones "You've won!" screen.
The tradeoff here is that to properly polish this proto-game into a full game will take significantly more code. More code than should be in a single codelab. So, instead, this is a speed run step designed to reinforce the techniques learned so far in this codelab by changing where and how they are used. Hopefully this reinforces the lessons learned earlier in this codelab. Alternatively, you can go ahead and build your own experiences based on this code. We'd love to see what you build!
To begin, follow these steps:
- Delete everything in the
lib/widgets
directory. You will be creating shiny new widgets for your game. That just happens to borrow a lot from the old widgets. - Edit your
model.dart
file to updateCrossword
'saddWord
method as follows:
lib/model.dart
/// Add a word to the crossword at the given location and direction.
Crossword? addWord({
required Location location,
required String word,
required Direction direction,
bool requireOverlap = true, // Add this parameter
}) {
// Require that the word is not already in the crossword.
if (words.map((crosswordWord) => crosswordWord.word).contains(word)) {
return null;
}
final wordCharacters = word.characters;
bool overlap = false;
// Check that the word fits in the crossword.
for (final (index, character) in wordCharacters.indexed) {
final characterLocation = switch (direction) {
Direction.across => location.rightOffset(index),
Direction.down => location.downOffset(index),
};
final target = characters[characterLocation];
if (target != null) {
overlap = true;
if (target.character != character) {
return null;
}
if (direction == Direction.across && target.acrossWord != null ||
direction == Direction.down && target.downWord != null) {
return null;
}
}
}
// Edit from here
// If overlap is required, make sure that the word overlaps with an existing
// word. Skip this test if the crossword is empty.
if (words.isNotEmpty && !overlap && requireOverlap) { // To here.
return null;
}
final candidate = rebuild(
(b) => b
..words.add(
CrosswordWord.word(
word: word,
direction: direction,
location: location,
),
),
);
if (candidate.valid) {
return candidate;
} else {
return null;
}
}
This minor modification of your Crossword model enables words to be added that don't overlap. It's useful to allow players to play anywhere on a board and still be able to use Crossword
as a base model for storing the player's moves. It is just a list of words at specific locations placed in a specific direction.
- Add the
CrosswordPuzzleGame
model class to the end of yourmodel.dart
file.
lib/model.dart
/// Creates a puzzle from a crossword and a set of candidate words.
abstract class CrosswordPuzzleGame
implements Built<CrosswordPuzzleGame, CrosswordPuzzleGameBuilder> {
static Serializer<CrosswordPuzzleGame> get serializer =>
_$crosswordPuzzleGameSerializer;
/// The [Crossword] that this puzzle is based on.
Crossword get crossword;
/// The alternate words for each [CrosswordWord] in the crossword.
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>> get alternateWords;
/// The player's selected words.
BuiltList<CrosswordWord> get selectedWords;
bool canSelectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return true;
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild((b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
));
}
return null !=
puzzle.crosswordFromSelectedWords.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false);
}
CrosswordPuzzleGame? selectWord({
required Location location,
required String word,
required Direction direction,
}) {
final crosswordWord = CrosswordWord.word(
word: word,
location: location,
direction: direction,
);
if (selectedWords.contains(crosswordWord)) {
return rebuild((b) => b.selectedWords.remove(crosswordWord));
}
var puzzle = this;
if (puzzle.selectedWords
.where((b) => b.direction == direction && b.location == location)
.isNotEmpty) {
puzzle = puzzle.rebuild((b) => b
..selectedWords.removeWhere(
(selectedWord) =>
selectedWord.location == location &&
selectedWord.direction == direction,
));
}
// Check if the selected word meshes with the already selected words.
// Note this version of the crossword does not enforce overlap to
// allow the player to select words anywhere on the grid. Enforcing words
// to be solved in order is a possible alternative.
final updatedSelectedWordsCrossword =
puzzle.crosswordFromSelectedWords.addWord(
location: location,
word: word,
direction: direction,
requireOverlap: false,
);
// Make sure the selected word is in the crossword or is an alternate word.
if (updatedSelectedWordsCrossword != null) {
if (puzzle.crossword.words.contains(crosswordWord) ||
puzzle.alternateWords[location]?[direction]?.contains(word) == true) {
return puzzle.rebuild((b) => b
..selectedWords.add(CrosswordWord.word(
word: word, location: location, direction: direction)));
}
}
return null;
}
/// The crossword from the selected words.
Crossword get crosswordFromSelectedWords => Crossword.crossword(
width: crossword.width, height: crossword.height, words: selectedWords);
/// Test if the puzzle is solved. Note, this allows for the possibility of
/// multiple solutions.
bool get solved =>
crosswordFromSelectedWords.valid &&
crosswordFromSelectedWords.words.length == crossword.words.length &&
crossword.words.isNotEmpty;
/// Create a crossword puzzle game from a crossword and a set of candidate
/// words.
factory CrosswordPuzzleGame.from({
required Crossword crossword,
required BuiltSet<String> candidateWords,
}) {
// Remove all of the currently used words from the list of candidates
candidateWords = candidateWords
.rebuild((p0) => p0.removeAll(crossword.words.map((p1) => p1.word)));
// This is the list of alternate words for each word in the crossword
var alternates =
BuiltMap<Location, BuiltMap<Direction, BuiltList<String>>>();
// Build the alternate words for each word in the crossword
for (final crosswordWord in crossword.words) {
final alternateWords = candidateWords.toBuiltList().rebuild((b) => b
..where((b) => b.length == crosswordWord.word.length)
..shuffle()
..take(4)
..sort());
candidateWords =
candidateWords.rebuild((b) => b.removeAll(alternateWords));
alternates = alternates.rebuild(
(b) => b.updateValue(
crosswordWord.location,
(b) => b.rebuild(
(b) => b.updateValue(
crosswordWord.direction,
(b) => b.rebuild((b) => b.replace(alternateWords)),
ifAbsent: () => alternateWords,
),
),
ifAbsent: () => {crosswordWord.direction: alternateWords}.build(),
),
);
}
return CrosswordPuzzleGame((b) {
b
..crossword.replace(crossword)
..alternateWords.replace(alternates);
});
}
factory CrosswordPuzzleGame(
[void Function(CrosswordPuzzleGameBuilder)? updates]) =
_$CrosswordPuzzleGame;
CrosswordPuzzleGame._();
}
/// Construct the serialization/deserialization code for the data model.
@SerializersFor([
Location,
Crossword,
CrosswordWord,
CrosswordCharacter,
WorkQueue,
DisplayInfo,
CrosswordPuzzleGame, // Add this line
])
final Serializers serializers = _$serializers;
The updates to the providers.dart
file are an interesting grab bag of changes. Most of the providers that were present to support statistics gathering have been removed. The ability to change the number of background isolates has been removed and replaced with a constant. There is also a new provider that gives access to the new CrosswordPuzzleGame
model you just added above.
lib/providers.dart
import 'dart:convert';
// Drop the dart:math import
import 'package:built_collection/built_collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'isolates.dart';
import 'model.dart' as model;
part 'providers.g.dart';
const backgroundWorkerCount = 4; // Add this line
/// A provider for the wordlist to use when generating the crossword.
@riverpod
Future<BuiltSet<String>> wordList(WordListRef ref) async {
// This codebase requires that all words consist of lowercase characters
// in the range 'a'-'z'. Words containing uppercase letters will be
// lowercased, and words containing runes outside this range will
// be removed.
final re = RegExp(r'^[a-z]+$');
final words = await rootBundle.loadString('assets/words.txt');
return const LineSplitter().convert(words).toBuiltSet().rebuild((b) => b
..map((word) => word.toLowerCase().trim())
..where((word) => word.length > 2)
..where((word) => re.hasMatch(word)));
}
/// An enumeration for different sizes of [model.Crossword]s.
enum CrosswordSize {
small(width: 20, height: 11),
medium(width: 40, height: 22),
large(width: 80, height: 44),
xlarge(width: 160, height: 88),
xxlarge(width: 500, height: 500);
const CrosswordSize({
required this.width,
required this.height,
});
final int width;
final int height;
String get label => '$width x $height';
}
/// A provider that holds the current size of the crossword to generate.
@Riverpod(keepAlive: true)
class Size extends _$Size {
var _size = CrosswordSize.medium;
@override
CrosswordSize build() => _size;
void setSize(CrosswordSize size) {
_size = size;
ref.invalidateSelf();
}
}
@riverpod
Stream<model.WorkQueue> workQueue(WorkQueueRef ref) async* {
final size = ref.watch(sizeProvider); // Drop the ref.watch(workerCountProvider)
final wordListAsync = ref.watch(wordListProvider);
final emptyCrossword =
model.Crossword.crossword(width: size.width, height: size.height);
final emptyWorkQueue = model.WorkQueue.from(
crossword: emptyCrossword,
candidateWords: BuiltSet<String>(),
startLocation: model.Location.at(0, 0),
);
// Drop the startTimeProvider and endTimeProvider refs
yield* wordListAsync.when(
data: (wordList) => exploreCrosswordSolutions(
crossword: emptyCrossword,
wordList: wordList,
maxWorkerCount: backgroundWorkerCount, // Edit this line
),
error: (error, stackTrace) async* {
debugPrint('Error loading word list: $error');
yield emptyWorkQueue;
},
loading: () async* {
yield emptyWorkQueue;
},
);
} // Drop the endTimeProvider ref
@riverpod // Add from here to end of file
class Puzzle extends _$Puzzle {
model.CrosswordPuzzleGame _puzzle = model.CrosswordPuzzleGame.from(
crossword: model.Crossword.crossword(width: 0, height: 0),
candidateWords: BuiltSet<String>(),
);
@override
model.CrosswordPuzzleGame build() {
final size = ref.watch(sizeProvider);
final wordList = ref.watch(wordListProvider).value;
final workQueue = ref.watch(workQueueProvider).value;
if (wordList != null &&
workQueue != null &&
workQueue.isCompleted &&
(_puzzle.crossword.height != size.height ||
_puzzle.crossword.width != size.width ||
_puzzle.crossword != workQueue.crossword)) {
compute(_puzzleFromCrosswordTrampoline, (workQueue.crossword, wordList))
.then((puzzle) {
_puzzle = puzzle;
ref.invalidateSelf();
});
}
return _puzzle;
}
Future<void> selectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) async {
final candidate = await compute(
_puzzleSelectWordTrampoline, (_puzzle, location, word, direction));
if (candidate != null) {
_puzzle = candidate;
ref.invalidateSelf();
} else {
debugPrint('Invalid word selection: $word');
}
}
bool canSelectWord({
required model.Location location,
required String word,
required model.Direction direction,
}) {
return _puzzle.canSelectWord(
location: location,
word: word,
direction: direction,
);
}
}
// Trampoline functions to disentangle these Isolate target calls from the
// unsendable reference to the [Puzzle] provider.
Future<model.CrosswordPuzzleGame> _puzzleFromCrosswordTrampoline(
(model.Crossword, BuiltSet<String>) args) async =>
model.CrosswordPuzzleGame.from(crossword: args.$1, candidateWords: args.$2);
model.CrosswordPuzzleGame? _puzzleSelectWordTrampoline(
(
model.CrosswordPuzzleGame,
model.Location,
String,
model.Direction
) args) =>
args.$1.selectWord(location: args.$2, word: args.$3, direction: args.$4);
The most interesting parts of the Puzzle
provider are the stratagems undertaken to gloss over the expense of creating the CrosswordPuzzleGame
from a Crossword
and a wordList
, and the expense of selecting a word. Both of these actions when undertaken without the aid of a background Isolate cause sluggish UI interaction. By using some sleight of hand to push out an intermediate result while computing the final result in the background, you wind up with a responsive UI while the required computations are taking place in the background.
- In the now-empty
lib/widgets
directory, create acrossword_puzzle_app.dart
file with the following content:
lib/widgets/crossword_puzzle_app.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers.dart';
import 'crossword_generator_widget.dart';
import 'crossword_puzzle_widget.dart';
import 'puzzle_completed_widget.dart';
class CrosswordPuzzleApp extends StatelessWidget {
const CrosswordPuzzleApp({super.key});
@override
Widget build(BuildContext context) {
return _EagerInitialization(
child: Scaffold(
appBar: AppBar(
actions: [_CrosswordPuzzleAppMenu()],
titleTextStyle: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold,
),
title: Text('Crossword Puzzle'),
),
body: SafeArea(
child: Consumer(builder: (context, ref, _) {
final workQueueAsync = ref.watch(workQueueProvider);
final puzzleSolved =
ref.watch(puzzleProvider.select((puzzle) => puzzle.solved));
return workQueueAsync.when(
data: (workQueue) {
if (puzzleSolved) {
return PuzzleCompletedWidget();
}
if (workQueue.isCompleted &&
workQueue.crossword.characters.isNotEmpty) {
return CrosswordPuzzleWidget();
}
return CrosswordGeneratorWidget();
},
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(child: Text('$error')),
);
}),
),
),
);
}
}
class _EagerInitialization extends ConsumerWidget {
const _EagerInitialization({required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(wordListProvider);
return child;
}
}
class _CrosswordPuzzleAppMenu extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) => MenuAnchor(
menuChildren: [
for (final entry in CrosswordSize.values)
MenuItemButton(
onPressed: () => ref.read(sizeProvider.notifier).setSize(entry),
leadingIcon: entry == ref.watch(sizeProvider)
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(entry.label),
),
],
builder: (context, controller, child) => IconButton(
onPressed: () => controller.open(),
icon: Icon(Icons.settings),
),
);
}
Most of this file should be fairly familiar by now. Yes, there will be undefined widgets, which you will now start fixing.
- Create a
crossword_generator_widget.dart
file and add the following content to it:
lib/widgets/crossword_generator_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordGeneratorWidget extends ConsumerWidget {
const CrosswordGeneratorWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) => workQueue.crossword.characters[location],
error: (error, stackTrace) => null,
loading: () => null,
),
),
);
final explorationCell = ref.watch(
workQueueProvider.select(
(workQueueAsync) => workQueueAsync.when(
data: (workQueue) =>
workQueue.locationsToTry.keys.contains(location),
error: (error, stackTrace) => false,
loading: () => false,
),
),
);
if (character != null) {
return AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: explorationCell
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: explorationCell
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
),
child: Text('•'), // https://www.compart.com/en/unicode/U+2022
),
),
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
This should also be reasonably familiar. The primary difference is that instead of displaying the characters of the words being generated, you are now displaying a unicode character to denote the presence of an unknown character. This really could use some work to improve the aesthetics.
- Create
crossword_puzzle_widget.dart
file and add the following content to it:
lib/widgets/crossword_puzzle_widget.dart
import 'package:built_collection/built_collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:two_dimensional_scrollables/two_dimensional_scrollables.dart';
import '../model.dart';
import '../providers.dart';
class CrosswordPuzzleWidget extends ConsumerWidget {
const CrosswordPuzzleWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(sizeProvider);
return TableView.builder(
diagonalDragBehavior: DiagonalDragBehavior.free,
cellBuilder: _buildCell,
columnCount: size.width,
columnBuilder: (index) => _buildSpan(context, index),
rowCount: size.height,
rowBuilder: (index) => _buildSpan(context, index),
);
}
TableViewCell _buildCell(BuildContext context, TableVicinity vicinity) {
final location = Location.at(vicinity.column, vicinity.row);
return TableViewCell(
child: Consumer(
builder: (context, ref, _) {
final character = ref.watch(puzzleProvider
.select((puzzle) => puzzle.crossword.characters[location]));
final selectedCharacter = ref.watch(puzzleProvider.select((puzzle) =>
puzzle.crosswordFromSelectedWords.characters[location]));
final alternateWords = ref
.watch(puzzleProvider.select((puzzle) => puzzle.alternateWords));
if (character != null) {
final acrossWord = character.acrossWord;
var acrossWords = BuiltList<String>();
if (acrossWord != null) {
acrossWords = acrossWords.rebuild((b) => b
..add(acrossWord.word)
..addAll(alternateWords[acrossWord.location]
?[acrossWord.direction] ??
[])
..sort());
}
final downWord = character.downWord;
var downWords = BuiltList<String>();
if (downWord != null) {
downWords = downWords.rebuild((b) => b
..add(downWord.word)
..addAll(alternateWords[downWord.location]
?[downWord.direction] ??
[])
..sort());
}
return MenuAnchor(
builder: (context, controller, _) {
return GestureDetector(
onTapDown: (details) =>
controller.open(position: details.localPosition),
child: AnimatedContainer(
duration: Durations.extralong1,
curve: Curves.easeInOut,
color: Theme.of(context).colorScheme.onPrimary,
child: Center(
child: AnimatedDefaultTextStyle(
duration: Durations.extralong1,
curve: Curves.easeInOut,
style: TextStyle(
fontSize: 24,
color: Theme.of(context).colorScheme.primary,
),
child: Text(selectedCharacter?.character ?? ''),
),
),
),
);
},
menuChildren: [
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Across'),
),
for (final word in acrossWords)
_WordSelectMenuItem(
location: acrossWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.across,
),
if (acrossWords.isNotEmpty && downWords.isNotEmpty)
Padding(
padding: const EdgeInsets.all(4),
child: Text('Down'),
),
for (final word in downWords)
_WordSelectMenuItem(
location: downWord!.location,
word: word,
selectedCharacter: selectedCharacter,
direction: Direction.down,
),
],
);
}
return ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
);
},
),
);
}
TableSpan _buildSpan(BuildContext context, int index) {
return TableSpan(
extent: FixedTableSpanExtent(32),
foregroundDecoration: TableSpanDecoration(
border: TableSpanBorder(
leading: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
trailing: BorderSide(
color: Theme.of(context).colorScheme.onPrimaryContainer),
),
),
);
}
}
class _WordSelectMenuItem extends ConsumerWidget {
const _WordSelectMenuItem({
required this.location,
required this.word,
required this.selectedCharacter,
required this.direction,
});
final Location location;
final String word;
final CrosswordCharacter? selectedCharacter;
final Direction direction;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(puzzleProvider.notifier);
return MenuItemButton(
onPressed: ref.watch(puzzleProvider.select((puzzle) =>
puzzle.canSelectWord(
location: location, word: word, direction: direction)))
? () => notifier.selectWord(
location: location, word: word, direction: direction)
: null,
leadingIcon: switch (direction) {
Direction.across => selectedCharacter?.acrossWord?.word == word,
Direction.down => selectedCharacter?.downWord?.word == word,
}
? Icon(Icons.radio_button_checked_outlined)
: Icon(Icons.radio_button_unchecked_outlined),
child: Text(word),
);
}
}
This widget is a bit more intense than the last one, even though it has been constructed from pieces you have seen used in other places in the past. Now, each populated cell produces a context menu when clicked, which lists the words a user can select. If words have been selected, then words that conflict aren't selectable. To deselect a word, the user taps on the menu item for that word.
Assuming the player can select words to fill the entire crossword, you need a "You've won!" screen.
- Create a
puzzle_completed_widget.dart
file and then add the following content to it:
lib/widgets/puzzle_completed_widget.dart
import 'package:flutter/material.dart';
class PuzzleCompletedWidget extends StatelessWidget {
const PuzzleCompletedWidget({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'Puzzle Completed!',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
),
),
);
}
}
I'm sure you can take this and make it more interesting. To learn more about animation tools, see the Building next generation UIs in Flutter codelab.
- Edit your
lib/main.dart
file as follows:
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'widgets/crossword_puzzle_app.dart'; // Update this line
void main() {
runApp(
ProviderScope(
child: MaterialApp(
title: 'Crossword Puzzle', // Update this line
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blueGrey,
brightness: Brightness.light,
),
home: CrosswordPuzzleApp(), // Update this line
),
),
);
}
When you run this app, you will see the animation as the crossword generator generates your puzzle. Then you will be presented with a blank puzzle to solve. Assuming you solve it, you should be presented with a screen that looks like this:
10. Congratulations
Congratulations! You succeeded in building a puzzle game with Flutter!
You built a crossword generator that became a puzzle game. You mastered running background computations in a pool of isolates. You used immutable data structures to ease the implementation of a backtracking algorithm. And you spent quality time with TableView
, which will come in handy the next time you need to display tabular data.