Crea un rompicapo con Flutter

1. Prima di iniziare

Immagina che ti venga chiesto se è possibile creare il più grande cruciverba del mondo. Ricordi alcune tecniche di IA che hai studiato a scuola e ti chiedi se sia possibile usare Flutter per esplorare le opzioni algoritmiche per creare soluzioni a problemi ad alta intensità di calcolo.

In questo codelab, farai esattamente questo. Alla fine, creerai uno strumento che ti permetterà di giocare nello spazio degli algoritmi per costruire rompicapi a griglia di parole. Esistono molte definizioni diverse di cosa è un cruciverba valido e queste tecniche ti aiutano a creare rompicapi che si adattano alla tua definizione.

Animazione della generazione di un cruciverba.

Con questo strumento come base, puoi creare un cruciverba che utilizza il generatore di cruciverba per costruire il rompicapo che l'utente può risolvere. Questo rompicapo è utilizzabile su Android, iOS, Windows, macOS e Linux. Eccolo su Android:

Screenshot di un cruciverba in fase di risoluzione su un emulatore di Pixel Fold.

Prerequisiti

Cosa imparerai

  • Scopri come utilizzare gli isolati per svolgere un lavoro che richiede molte risorse di calcolo senza impedire il loop di rendering di Flutter con una combinazione della funzione compute di Flutter e delle funzionalità di ricostruzione del filtro nella cache del valore di Riverpod in select.
  • Come sfruttare strutture di dati immutabili con built_value e built_collection per semplificare l'implementazione delle tecniche di IA Buona Old Fashioned (GOFAI) basate sulla ricerca, come la ricerca depth-first e il backtracking.
  • Come utilizzare le funzionalità del pacchetto two_dimensional_scrollables per visualizzare i dati della griglia in modo rapido e intuitivo.

Cosa serve

  • L'SDK Flutter.
  • Visual Studio Code (VS Code) con i plug-in Flutter e Dart.
  • Compilatore del software per il target di sviluppo scelto. Questo codelab funziona per tutte le piattaforme desktop, Android e iOS. È necessario VS Code per il targeting di Windows, Xcode per macOS o iOS e Android Studio per il targeting di Android.

2. Creare un progetto

Crea il tuo primo progetto Flutter

  1. Avvia VS Code.
  2. Nella riga di comando, inserisci flutter new e seleziona Flutter: New Project nel menu.

Uno screenshot di VS Code con

  1. Seleziona Vuota l'applicazione, quindi scegli una directory in cui creare il progetto. Deve essere qualsiasi directory che non richiede privilegi elevati o non abbia uno spazio nel percorso. Alcuni esempi sono la tua home directory o C:\src\.

Uno screenshot di VS Code con applicazione vuota mostrata come selezionato nell'ambito del flusso della nuova applicazione

  1. Assegna al progetto il nome generate_crossword. Nella parte restante di questo codelab si presuppone che tu abbia assegnato alla tua app il nome generate_crossword.

Uno screenshot di VS Code con

Flutter ora crea la cartella del progetto e VS Code la apre. Ora sovrascriverai i contenuti di due file con uno scaffold di base dell'app.

Copia e incolla l'app iniziale

  1. Nel riquadro sinistro di VS Code, fai clic su Explorer e apri il file pubspec.yaml.

Uno screenshot parziale di VS Code con frecce che evidenziano la posizione del file pubspec.yaml

  1. Sostituisci il contenuto di questo file con il seguente:

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

Il file pubspec.yaml specifica le informazioni di base della tua app, come la versione attuale e le sue dipendenze. Vedi una raccolta di dipendenze che non fanno parte di una normale app Flutter vuota. Questi pacchetti saranno disponibili nei prossimi passaggi.

  1. Apri il file main.dart nella directory lib/.

Uno screenshot parziale di VS Code con una freccia che mostra la posizione del file main.ARROW

  1. Sostituisci il contenuto di questo file con il seguente:

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),
            ),
          ),
        ),
      ),
    ),
  );
}
  1. Esegui questo codice per verificare che tutto funzioni. Dovrebbe visualizzare una nuova finestra con la frase iniziale obbligatoria di ogni nuovo progetto ovunque. È presente un'icona ProviderScope che indica che questa app utilizzerà riverpod per la gestione dello stato.

Una finestra dell&#39;app con la parola &quot;Hello, World!&quot; al centro

3. Aggiungi parole

Componenti di base per un cruciverba

Un cruciverba è, in fondo, un elenco di parole. Le parole sono disposte in una griglia, alcune incrociate, altre verso il basso, in modo che si intrecciano. Risolvere una parola dà indizi sulle parole che la incrociano. Pertanto, il primo componente di base deve essere un elenco di parole.

Una buona fonte per queste parole è la pagina Natural Language Corpus Data di Peter Norvig. L'elenco SOWPODS come utile punto di partenza, con le sue 267.750 parole.

In questo passaggio, scaricherai un elenco di parole, lo aggiungerai come asset all'app Flutter e organizzerai un provider di Riverpod a caricare l'elenco nell'app all'avvio.

Per iniziare, procedi nel seguente modo:

  1. Modifica il file pubspec.yaml del progetto per aggiungere la seguente dichiarazione degli asset per l'elenco di parole selezionato. Questo elenco mostra solo la stanza flutter della configurazione dell'app, perché il resto è rimasto invariato.

pubspec.yaml

flutter:
  uses-material-design: true
  assets:                                       // Add this line
    - assets/words.txt                          // And this one.

È probabile che l'editor evidenzi quest'ultima riga con un avviso perché devi ancora creare questo file.

  1. Utilizzando il browser e l'editor, crea una directory assets al livello superiore del tuo progetto e crea un file words.txt al suo interno con uno degli elenchi di parole riportati sopra.

Questo codice è stato progettato in base all'elenco SOWPODS sopra menzionato, ma dovrebbe funzionare con qualsiasi elenco di parole composto solo da caratteri A-Z. L'estensione di questo codebase al lavoro con set di caratteri diversi è un esercizio che il lettore deve svolgere.

Carica le parole

Per scrivere il codice responsabile del caricamento dell'elenco di parole all'avvio dell'app, procedi nel seguente modo:

  1. Crea un file providers.dart nella directory lib.
  2. Aggiungi quanto segue al 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)));
}

Questo è il tuo primo fornitore di Riverpod per questo codebase. Noterai che il tuo editor segnala varie aree come classe non definita o target non generata. Questo progetto utilizza la generazione di codice per più dipendenze, tra cui Riverpod, quindi sono previsti errori di classe non definiti.

  1. Per iniziare a generare il codice, esegui questo comando:
$ 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)

Continuerà a essere eseguito in background, aggiornando i file generati man mano che apporti modifiche al progetto. Dopo che questo comando ha generato il codice in providers.g.dart, l'editor dovrebbe essere soddisfatto del codice che hai aggiunto a providers.dart sopra.

In Riverpod, i provider come la funzione wordList definita sopra vengono generalmente creati tramite un'istanza mediante un'istanza lenta. Tuttavia, ai fini di questa app, è necessario che l'elenco di parole venga caricato con entusiasmo. La documentazione di Riverpod suggerisce il seguente approccio per la gestione dei provider che devi caricare con entusiasmo. Lo implementerai ora.

  1. Crea un file crossword_generator_app.dart in una directory lib/widgets.
  2. Aggiungi quanto segue al 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;
  }
}

Questo file è interessante in due direzioni distinte. Il primo è il widget _EagerInitialization, che ha l'unica missione di richiedere al provider wordList che hai creato sopra di caricare l'elenco di parole. Questo widget raggiunge questo scopo ascoltando il provider tramite la chiamata ref.watch(). Per ulteriori informazioni su questa tecnica, consulta la documentazione di Riverpod sull'inizializzazione eager dei provider.

Il secondo punto interessante da notare in questo file è il modo in cui Riverpod gestisce i contenuti asincroni. Come forse ricorderai, il provider wordList è definito come una funzione asincrona, in quanto il caricamento dei contenuti dal disco è lento. Mentre controlli il fornitore dell'elenco di parole in questo codice, ricevi un AsyncValue<BuiltSet<String>>. La parte AsyncValue di questo tipo è un adattatore tra il mondo asincrono dei provider e il mondo sincrono del metodo build del widget.

Il metodo when di AsyncValue gestisce i tre stati potenziali in cui potrebbe trovarsi il valore futuro. Il futuro potrebbe essere stato risolto correttamente, nel qual caso viene richiamato il callback data, potrebbe essere in uno stato di errore, in questo caso viene richiamato il callback error o infine potrebbe essere ancora in fase di caricamento. I tipi restituiti dei tre callback devono avere tipi restituiti compatibili, poiché il ritorno del callback chiamato viene restituito dal metodo when. In questo caso, il risultato del metodo quando viene visualizzato come body del widget Scaffold.

Crea un'app elenco quasi infinita

Per integrare il widget CrosswordGeneratorApp nella tua app, segui questi passaggi:

  1. Aggiorna il file lib/main.dart aggiungendo il seguente codice:

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
      ),
    ),
  );
}
  1. Riavvia l'app. Dovresti vedere un elenco scorrevole che continuerà quasi all'infinito.

Una finestra dell&#39;app dal titolo &quot;Crossword Builder&quot; e un elenco di parole

4. Visualizza le parole in una griglia

In questo passaggio creerai una struttura di dati per creare un cruciverba utilizzando i pacchetti built_value e built_collection. Questi due pacchetti consentono di costruire strutture di dati come valori immutabili, il che sarà utile sia per passare facilmente dati tra isolati e per semplificare l'implementazione della prima ricerca approfondita e del backtracking.

Per iniziare, procedi nel seguente modo:

  1. Crea un file model.dart nella directory lib, quindi aggiungi i seguenti contenuti al 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;

Questo file descrive l'inizio della struttura dei dati che utilizzerai per creare le parole crociate. Essenzialmente, un cruciverba è un elenco di parole, orizzontali e verticali, raggruppate in una griglia. Per utilizzare questa struttura di dati, devi creare un Crossword delle dimensioni appropriate con il costruttore denominato Crossword.crossword, quindi aggiungere parole utilizzando il metodo addWord. Come parte della costruzione del valore finalizzato, il metodo _fillCharacters crea una griglia di CrosswordCharacter.

Per utilizzare questa struttura di dati:

  1. Crea un file utils nella directory lib, quindi aggiungi i seguenti contenuti al 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));
  }
}

Si tratta di un'estensione di BuiltSet che semplifica il recupero di un elemento casuale del set. I metodi di estensione rendono più semplice estendere le classi con funzionalità aggiuntive. È necessario assegnare un nome all'estensione per renderla disponibile all'esterno del file utils.dart.

  1. Nel file lib/providers.dart, aggiungi le seguenti importazioni:

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 {

Queste importazioni espongono il modello definito sopra ai provider che stai per creare. L'importazione dart:math è inclusa per Random, l'importazione flutter/foundation.dart è inclusa per debugPrint, model.dart per il modello e utils.dart per l'estensione BuiltSet.

  1. Alla fine dello stesso file, aggiungi i seguenti provider:

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;
    },
  );
}

Queste modifiche aggiungono due fornitori alla tua app. La prima è Size, che è effettivamente una variabile globale contenente il valore attualmente selezionato dell'enumerazione CrosswordSize. In questo modo l'UI potrà visualizzare e impostare le dimensioni del cruciverba in fase di allestimento. Il secondo provider, crossword, è una creazione più interessante. È una funzione che restituisce una serie di Crossword. Viene creato utilizzando il supporto di Dart per i generatori, come contrassegnato dal async* nella funzione. Ciò significa che, invece di terminare con un ritorno, produce una serie di Crossword, un modo molto più semplice per scrivere un calcolo che restituisce risultati intermedi.

A causa della presenza di una coppia di chiamate ref.watch all'inizio della funzione provider crossword, il flusso di Crossword s verrà riavviato dal sistema Riverpod ogni volta che la dimensione selezionata del cruciverba cambia e al termine del caricamento dell'elenco di parole.

Ora che hai il codice per generare cruciverba, anche se pieni di parole casuali, sarebbe utile mostrarle all'utente dello strumento.

  1. Crea un file crossword_widget.dart nella directory lib/widgets con il seguente contenuto:

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),
        ),
      ),
    );
  }
}

Questo widget, essendo un ConsumerWidget, può affidarsi direttamente al provider Size per determinare la dimensione della griglia su cui visualizzare i caratteri del Crossword. La visualizzazione di questa griglia viene eseguita con il widget TableView del pacchetto two_dimensional_scrollables.

Vale la pena notare che le singole celle visualizzate dalle funzioni helper _buildCell contengono ciascuna nella struttura Widget restituita un widget Consumer. Funziona come un limite di aggiornamento. Tutto ciò che si trova all'interno del widget Consumer viene ricreato quando il valore restituito di ref.watch cambia. Si può avere la tentazione di ricreare l'intero albero ogni volta che cambia Crossword, tuttavia, molti calcoli possono essere ignorati utilizzando questa configurazione.

Se osservi il parametro di ref.watch, puoi notare che c'è un ulteriore livello di prevenzione dal ricalcolo dei layout, utilizzando crosswordProvider.select. Ciò significa che ref.watch attiverà una nuova compilazione dei contenuti di TableViewCell solo quando il carattere che la cella è responsabile del rendering delle modifiche. Questa riduzione del rendering è una parte essenziale del mantenimento della UI reattiva.

Per esporre il provider CrosswordWidget e Size all'utente, modifica il file crossword_generator_app.dart come segue:

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.
}

Alcune cose sono cambiate. Innanzitutto, il codice responsabile del rendering di wordList come ListView è stato sostituito con una chiamata al metodo CrosswordWidget definito nel file precedente. L'altra modifica importante è l'inizio di un menu per modificare il comportamento dell'app, a partire dalla modifica delle dimensioni del cruciverba. Nei prossimi passaggi verranno aggiunti altri MenuItemButton. Esegui l'app. Visualizzerai qualcosa di simile a questo:

Una finestra dell&#39;app con il titolo Generatore di parole crociate e una griglia di caratteri disposti come parole che si sovrappongono, senza rima o motivo

Sono visualizzati dei caratteri in una griglia e un menu che consente all'utente di modificare le dimensioni della griglia. Ma le parole non sono disposte come un cruciverba. Questo è il risultato della mancata applicazione di vincoli su come vengono aggiunte le parole al cruciverba. In breve, è un disastro. Qualcosa che inizierai a tenere sotto controllo nel passaggio successivo.

5. Forza l'applicazione dei vincoli

L'obiettivo di questo passaggio è l'aggiunta di codice al modello per applicare i vincoli delle parole crociate. Esistono diversi tipi di cruciverba e lo stile che questo codelab segue nelle tradizioni dei cruciverba inglesi. Modificare questo codice per generare altri stili di cruciverba è come sempre un esercizio disponibile per il lettore.

Per iniziare, procedi nel seguente modo:

  1. Apri il file model.dart e sostituisci il modello Crossword con quanto segue:

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._();
}

Ti ricordiamo che le modifiche che stai apportando ai file model.dart e providers.dart richiedono che build_runner sia in esecuzione per aggiornare i rispettivi file model.g.dart e providers.g.dart. Se questi file non si sono automagicamente aggiornati, ora è un buon momento per ricominciare a build_runner con dart run build_runner watch -d.

Per sfruttare questa nuova funzionalità nel livello del modello, devi aggiornare il livello del provider in modo che corrisponda.

  1. Modifica il file providers.dart come segue:

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;
    },
  );
}
  1. Esegui l'app. Non succede molto nella UI, ma molto accade se esamini i log.

Finestra dell&#39;app Generatore di cruciverba con parole disposte verso l&#39;alto e verso il basso che si intersecano in punti casuali

Se pensi a ciò che sta accadendo qui, vediamo apparire un cruciverba per caso. Il metodo addWord nel modello Crossword rifiuta qualsiasi parola proposta che non rientri nel cruciverba corrente, quindi è incredibile vedere qualsiasi cosa appaia.

Per prepararti a una maggiore metodica nella scelta delle parole da provare e dove, sarebbe molto utile spostare questo calcolo fuori dal thread dell'interfaccia utente e isolarlo in background. Flutter ha un wrapper molto utile per prendere una parte del lavoro e per eseguirlo in un isolato in background: la funzione compute.

  1. Nel file providers.dart, modifica il fornitore di cruciverba come segue:

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;
    },
  );
}

Questo codice funziona. Tuttavia, contiene una trappola. Se continui su questo percorso, alla fine verrà visualizzato un errore registrato come il seguente:

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)

Questo è il risultato della chiusura che compute sta trasferendo all'isolato in background che si chiude su un provider, che non può essere inviato tramite SendPort.send(). Una soluzione a questo problema è assicurarsi che non ci sia nulla che impedisca l'invio della chiusura.

Un primo passaggio consiste nel separare i provider dal codice Isolate.

  1. Crea un file isolates.dart nella directory lib, quindi aggiungi i seguenti contenuti:

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');
    }
  }
}

Il codice deve avere un aspetto ragionevolmente familiare. È l'elemento fondamentale di ciò che era nel provider crossword, ma ora come funzione di generatore autonomo. Ora puoi aggiornare il file providers.dart per utilizzare questa nuova funzione per creare un'istanza per l'isolamento in background.

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.
    },
  );
}

Grazie a questo strumento ora hai a disposizione uno strumento per creare cruciverba di diverse dimensioni, con l'compute di capire il puzzle che si svolge su uno sfondo isolato. Se solo il codice fosse più efficiente nel decidere quali parole provare ad aggiungere al cruciverba,

6. Gestire la coda di lavoro

Parte del problema con il codice così com'è è che il problema da risolvere è di fatto un problema di ricerca e la soluzione attuale è una ricerca cieca. Se il codice si concentra sulla ricerca di parole che si associano a quelle correnti, invece di cercare di posizionare casualmente le parole in qualsiasi punto della griglia, il sistema troverà soluzioni più velocemente. Un modo per affrontare questo problema è introdurre una coda di lavoro di posizioni per cercare di trovare le parole.

Il codice attualmente crea soluzioni candidate, controlla se la soluzione candidata è valida e, a seconda della validità, incorpora il candidato o la elimina. Questo è un esempio di implementazione dalla famiglia di algoritmi di backtracking. Questa implementazione è molto semplificata da built_value e built_collection, che consentono di creare nuovi valori immutabili che derivano, e quindi condividono, stato comune con il valore immutabile da cui sono stati derivati. Ciò consente uno sfruttamento economico di potenziali candidati senza i costi di memoria necessari per la copia approfondita.

Per iniziare, procedi nel seguente modo:

  1. Apri il file model.dart e aggiungi la seguente definizione di WorkQueue:

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;
  1. Se nel file sono presenti dei scarabocchi rossi dopo aver aggiunto questi nuovi contenuti per più di qualche secondo, verifica che il tuo build_runner sia ancora in esecuzione. In caso contrario, esegui il comando dart run build_runner watch -d.

Nel codice stai per introdurre il logging per mostrare quanto tempo occorre per creare cruciverba di varie dimensioni. Sarebbe bello se Durations presentasse una qualche forma di display ben formattato. Fortunatamente, con i metodi di estensione possiamo aggiungere il metodo esatto di cui abbiamo bisogno.

  1. Modifica il file utils.dart come segue:

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.

Questo metodo di estensione sfrutta le espressioni e la corrispondenza dei pattern di cambio dei record per selezionare il modo appropriato per visualizzare durate diverse che vanno da secondi a giorni. Per ulteriori informazioni su questo stile di codice, consulta il codelab Introduzione ai pattern e ai record di Dart.

  1. Per integrare questa nuova funzionalità, sostituisci il file isolates.dart in modo da ridefinire la definizione della funzione exploreCrosswordSolutions come segue:

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}');
}

L'esecuzione di questo codice darà origine a un'app che appare identica in apparenza, ma la differenza sta nel tempo necessario per trovare un cruciverba finito. Ecco un cruciverba 80 x 44 generato in 1 minuto e 29 secondi.

Generatore di cruciverba, con tante parole che si intersecano. Diminuito lo zoom, le parole sono troppo piccole per essere lette.

La domanda ovvia è: possiamo andare più velocemente? Sì, sì, possiamo.

7. Statistiche di superficie

Quando si prepara qualcosa velocemente, è utile capire cosa sta succedendo. A questo scopo, è utile mettere in evidenza le informazioni sulla procedura in corso. A questo punto puoi aggiungere gli strumenti e visualizzare le informazioni sotto forma di riquadro informativo che passa con il mouse.

Le informazioni visualizzate devono essere estratte dalla WorkQueue e visualizzate nell'interfaccia utente.

Un primo passaggio utile consiste nel definire una nuova classe del modello contenente le informazioni da visualizzare.

Per iniziare, procedi nel seguente modo:

  1. Modifica il file model.dart come segue per aggiungere la classe DisplayInfo:

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> {
  1. Al termine del file, apporta le seguenti modifiche per aggiungere la classe DisplayInfo:

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;
  1. Modifica il file isolates.dart per esporre il modello WorkQueue come segue:

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}');
}

Ora che l'isolato in background sta esponendo la coda di lavoro, ora è una questione di come e dove ricavare le statistiche da questa origine dati.

  1. Sostituisci il vecchio provider di cruciverba con un provider di code di lavoro e quindi aggiungi altri provider che ricavano informazioni dal flusso del provider di code di lavoro:

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,
      );
}

I nuovi fornitori rappresentano una combinazione di stato globale, in quanto le informazioni visualizzate devono essere sovrapposte alla griglia delle cruciverba e dati derivati come il tempo di esecuzione della generazione dei cruciverba. Tutto questo è complicato dal fatto che gli ascoltatori di questo stato sono transitori. Non è possibile ascoltare i momenti di inizio e di fine del calcolo del cruciverba se la visualizzazione delle informazioni è nascosta, ma devono rimanere in memoria se il calcolo deve essere preciso quando vengono mostrate le informazioni. In questo caso, il parametro keepAlive dell'attributo Riverpod è molto utile.

C'è una leggera pieghe nel display informativo. Vogliamo la possibilità di mostrare il tempo di esecuzione attualmente trascorso, ma non c'è nulla qui per forzare facilmente l'aggiornamento costante del tempo attualmente trascorso. Tornando al codelab Creazione di UI di nuova generazione in Flutter, ecco un widget utile proprio per questo requisito.

  1. Crea un file ticker_builder.dart nella directory lib/widgets, quindi aggiungi il seguente contenuto:

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

Questo widget è una mazza. Ricostruisce i contenuti su ogni frame. Questo è generalmente disapprovato, ma rispetto al carico computazionale della ricerca di cruciverba, il carico computazionale della ricolorazione del tempo trascorso ogni frame probabilmente scomparirà nel rumore. Per sfruttare queste informazioni appena derivate, è il momento di creare un nuovo widget.

  1. Crea un file crossword_info_widget.dart nella directory lib/widgets a cui aggiungi i seguenti contenuti:

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),
            ),
          ],
        ),
      );
}

Questo widget è un ottimo esempio della potenza dei provider di Riverpod. Questo widget verrà contrassegnato per essere ricreato quando uno dei cinque fornitori verrà aggiornato. L'ultima modifica necessaria in questo passaggio è l'integrazione di questo nuovo widget nella UI.

  1. Modifica il file crossword_generator_app.dart come segue:

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),
        ),
      );
}

Le due modifiche in questo caso dimostrano approcci diversi all'integrazione dei provider. Nel metodo build di CrosswordGeneratorApp, hai introdotto un nuovo builder di Consumer per contenere l'area di cui viene forzata la ricreazione quando la visualizzazione delle informazioni viene mostrata o nascosta. D'altra parte, l'intero menu a discesa è un solo ConsumerWidget, che viene ricostruito sia che si tratti del ridimensionamento del cruciverba, sia che si tratti di mostrare o nascondere la visualizzazione delle informazioni. Quale approccio da adottare è sempre un compromesso ingegneristico tra semplicità e costo del ricalcolo dei layout di strutture di widget ricostruite.

L'esecuzione dell'app ora offre all'utente maggiori informazioni sull'avanzamento della generazione delle parole crociate. Tuttavia, verso la fine della generazione del cruciverba, vediamo che c'è un periodo in cui i numeri cambiano, ma la griglia di caratteri cambia molto poco.

Finestra dell&#39;app Generatore di cruciverba, questa volta parole più piccole e riconoscibili, e un overlay mobile nell&#39;angolo in basso a destra con statistiche sull&#39;esecuzione della generazione corrente

Sarebbe utile avere qualche informazione in più su cosa sta succedendo e perché.

8. Esegui la parallelizzazione con i thread

Per capire perché le cose diventano lente alla fine, è utile essere in grado di visualizzare cosa sta facendo l'algoritmo. Un elemento fondamentale è l'eccezionale locationsToTry di WorkQueue. TableView è uno strumento utile per analizzare il problema. Possiamo modificare il colore della cella a seconda che sia in locationsToTry.

Per iniziare, procedi nel seguente modo:

  1. Modifica il file crossword_widget.dart come segue:

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),
        ),
      ),
    );
  }
}

Quando esegui questo codice, vedrai una visualizzazione delle località in sospeso che l'algoritmo deve ancora analizzare.

Generatore di cruciverba che mostra la generazione a metà. Alcune lettere hanno testo bianco su sfondo blu scuro, mentre altre sono blu su sfondo bianco.

La cosa interessante di osservare questo grafico mentre il cruciverba avanza verso il completamento è che rimane una serie di punti da indagare che non restituirà nulla di utile. Ci sono un paio di opzioni qui: uno è limitare l'indagine una volta compilata una determinata percentuale di celle del cruciverba e la seconda è analizzare più punti d'interesse alla volta. Il secondo percorso sembra più divertente, procediamo così.

  1. Modifica il file isolates.dart. Si tratta di una riscrittura quasi completa del codice per suddividere ciò che veniva calcolato da un isolato di background in un pool di N isolati di sfondo.

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

La maggior parte di questo codice dovrebbe essere familiare perché la logica di business di base non è cambiata. Ciò che è cambiato è che ora ci sono due livelli di chiamate compute. Il primo livello è responsabile della creazione di singole posizioni in cui eseguire la ricerca negli isolati di worker N, quindi ricombina i risultati quando tutti gli isolati di worker N sono completati. Il secondo livello è costituito dagli isolati di worker N. L'ottimizzazione della funzionalità N per ottenere le prestazioni migliori dipende dal computer e dai dati in questione. Più grande è la griglia, maggiore è il numero di lavoratori che possono lavorare insieme senza intralciarsi a vicenda.

L'unica cosa interessante è notare come questo codice ora gestisce il problema delle chiusure che acquisiscono elementi che non dovrebbero acquisire. Al momento non ci sono chiusure. Le funzioni _generate e _generateWorker sono definite come funzioni di primo livello, che non hanno un ambiente circostante da cui eseguire l'acquisizione. Gli argomenti in e i risultati da entrambe le funzioni sono sotto forma di record Dart. Questo è un modo semplice per aggirare la semantica di un singolo valore nella chiamata compute.

Ora che hai la possibilità di creare un pool di worker in background per cercare le parole che si intrecciano in una griglia per formare un cruciverba, è il momento di esporre questa funzionalità al resto dello strumento per la generazione di cruciverba.

  1. Modifica il file providers.dart modificando il provider workQueue come segue:

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();
}
  1. Aggiungi il provider WorkerCount alla fine del file come segue:

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.

Con queste due modifiche, il livello provider espone ora un modo per impostare il numero massimo di worker per il pool di isolati in background, in modo che le funzioni di isolamento siano configurate correttamente.

  1. Aggiorna il file crossword_info_widget.dart modificando CrosswordInfoWidget come segue:

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),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}
  1. Modifica il file crossword_generator_app.dart aggiungendo la seguente sezione al widget _CrosswordGeneratorMenu:

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),
        ),
      );
}

Se esegui l'app ora, potrai modificare il numero di isolati di sfondo da creare per cercare le parole da inserire nel cruciverba.

  1. Fai clic sull'icona a forma di ingranaggio per aprire il menu contestuale contenente le dimensioni del cruciverba, se visualizzare le statistiche sul cruciverba attualmente generato e ora il numero di isolati da utilizzare.

Finestra del Generatore di cruciverba con parole e statistiche

L'esecuzione del generatore di cruciverba ha ridotto in modo significativo i tempi di calcolo di un cruciverba 80x44 utilizzando più core contemporaneamente.

9. Trasforma in un gioco

Quest'ultima sezione è davvero un round bonus. Prenderai tutte le tecniche che hai imparato durante la creazione del generatore di cruciverba e userai queste tecniche per creare un gioco. Utilizzerai il generatore di cruciverba per creare un cruciverba. Potrai riutilizzare le espressioni idiomatiche del menu contestuale per consentire all'utente di selezionare e deselezionare le parole da inserire nei vari spazi a forma di parola della griglia. Il tutto con l'obiettivo di completare il cruciverba.

Non dirò che questo gioco è raffinato o finito, è ben lontano. Migliorando la scelta delle parole alternative, si possono risolvere problemi di equilibrio e difficoltà. Non esiste un tutorial per indirizzare gli utenti e l'animazione pensante lascia molto a desiderare. Non dirò nemmeno delle ossa nude: "Hai vinto!" schermo.

Il compromesso è che per perfezionare correttamente questo proto-game in un gioco completo, sarà necessario un codice notevolmente maggiore. Più codice di quello che dovrebbe essere in un unico codelab. Quindi, questo è un passaggio di esecuzione veloce progettato per rafforzare le tecniche apprese finora in questo codelab cambiando dove e come vengono utilizzate. Speriamo che ciò rafforzi quanto appreso in precedenza in questo codelab. In alternativa, puoi procedere e creare le tue esperienze basate su questo codice. Ci piacerebbe vedere cosa crei.

Per iniziare, procedi nel seguente modo:

  1. Elimina tutti i contenuti della directory lib/widgets. Creerai nuovi widget per il tuo gioco. Questo accade semplicemente prendendo in prestito molto dai vecchi widget.
  2. Modifica il file model.dart per aggiornare il metodo addWord di Crossword come segue:

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;
    }
  }

Questa piccola modifica del modello delle parole crociate consente di aggiungere parole che non si sovrappongono. È utile consentire ai giocatori di giocare ovunque su un tabellone e poter comunque usare Crossword come modello base per memorizzare le mosse del giocatore. È solo un elenco di parole in posizioni specifiche posizionate in una determinata direzione.

  1. Aggiungi la classe del modello CrosswordPuzzleGame alla fine del file model.dart.

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;

Gli aggiornamenti al file providers.dart sono un'interessante serie di modifiche. La maggior parte dei fornitori presenti a supporto della raccolta delle statistiche è stata rimossa. La possibilità di modificare il numero di isolati di sfondo è stata rimossa e sostituita con una costante. È disponibile anche un nuovo provider che dà accesso al nuovo modello CrosswordPuzzleGame che hai appena aggiunto in alto.

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);

Le parti più interessanti del provider Puzzle sono gli stratagemi intrapresi per riflettere sulle spese di creazione di CrosswordPuzzleGame da un Crossword e un wordList e le spese per la selezione di una parola. Entrambe queste azioni, se eseguite senza l'ausilio di un Isolate in background, causano l'interazione lenta dell'interfaccia utente. Usando un po' di mano per spingere un risultato intermedio mentre si calcola il risultato finale in background, si ritroverà con una UI reattiva mentre i calcoli richiesti vengono eseguiti in background.

  1. Nella directory lib/widgets, ormai vuota, crea un file crossword_puzzle_app.dart con i seguenti contenuti:

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),
        ),
      );
}

Ormai la maggior parte di questo file dovrebbe essere abbastanza familiare. Sì, ci saranno widget non definiti, che ora inizierai a correggere.

  1. Crea un file crossword_generator_widget.dart e aggiungi i seguenti contenuti:

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),
        ),
      ),
    );
  }
}

Inoltre, dovrebbe essere ragionevolmente familiare. La differenza principale è che, invece di visualizzare i caratteri delle parole che vengono generate, viene visualizzato un carattere Unicode per indicare la presenza di un carattere sconosciuto. Potrebbe essere necessario un po' di lavoro per migliorare l'estetica.

  1. Crea il file crossword_puzzle_widget.dart e aggiungi i seguenti contenuti:

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),
    );
  }
}

Questo widget è un po' più intenso del precedente, anche se è stato realizzato con pezzi che hai visto usare in altri luoghi in passato. Ora, ogni cella popolata produce un menu contestuale, se selezionato, che elenca le parole che l'utente può selezionare. Se sono state selezionate delle parole, quelle in conflitto non sono selezionabili. Per deselezionare una parola, l'utente tocca la relativa voce di menu.

Supponendo che il giocatore possa selezionare delle parole per riempire l'intero cruciverba, hai bisogno di un "Hai vinto!" schermo.

  1. Crea un file puzzle_completed_widget.dart a cui aggiungi i seguenti contenuti:

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,
        ),
      ),
    );
  }
}

Sicuramente potrai prenderla e renderla più interessante. Per scoprire di più sugli strumenti di animazione, consulta il codelab Creazione di UI di nuova generazione in Flutter.

  1. Modifica il file lib/main.dart come segue:

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
      ),
    ),
  );
}

Quando esegui questa app, vedrai l'animazione mentre il generatore di cruciverba genera il tuo rompicapo. Poi ti verrà presentato un rompicapo vuoto da risolvere. Se lo hai risolto, dovresti visualizzare una schermata simile alla seguente:

Finestra dell&#39;app Cruciverba con il testo &quot;Puzzle completato!&quot;

10. Complimenti

Complimenti! Hai creato un rompicapo con Flutter!

Hai creato un generatore di cruciverba, che è diventato un rompicapo. Hai imparato a eseguire calcoli in background in un pool di isolati. Hai utilizzato strutture di dati immutabili per semplificare l'implementazione di un algoritmo di backtracking. Hai trascorso del tempo di qualità con TableView, cosa che ti sarà utile la prossima volta che dovrai visualizzare i dati tabulari.

Scopri di più