أنشِئ ألغاز الكلمات باستخدام Flutter

1. قبل البدء

تخيل أن يتم سؤالك عمّا إذا كان من الممكن إنشاء أكبر لغز كلمات متقاطعة في العالم. تتذكّر بعض أساليب الذكاء الاصطناعي التي درستها في المدرسة وتتساءل عمّا إذا كان بإمكانك استخدام Flutter لاستكشاف الخيارات الخوارزمية لإيجاد حلول للمشاكل الحاسوبية الحاسوبية.

تفعل ذلك بالضبط في هذا الدرس التطبيقي حول الترميز. وفي النهاية، يمكنك إنشاء أداة لتلعبها في مجال الخوارزميات لإنشاء ألغاز شبكة الكلمات. تتوفّر تعريفات مختلفة لألغاز الكلمات المتقاطعة الصالحة، وتساعدك هذه الأساليب في إنشاء ألغاز تناسب تعريفك الخاص.

صورة متحركة لغز كلمات متقاطعة يتم إنشاؤها.

يمكنك صياغة ألغاز كلمات متقاطعة كقاعدة لها، وذلك باستخدام أداة إنشاء الكلمات المتقاطعة لإنشاء اللغز ليحلّه المستخدم. يمكن استخدام هذا اللغز على Android وiOS وWindows وmacOS وLinux. إليك هذه الميزة على Android:

لقطة شاشة لغز كلمات متقاطعة يجري حلّها في محاكي Pixel Fold

المتطلبات الأساسية

المعلومات التي تطّلع عليها

  • كيفية استخدام عناصر العزل لتنفيذ إجراءات باهظة التكلفة من الناحية الحسابية بدون التأثير في حلقة عرض Flutter، وذلك من خلال الجمع بين وظيفة compute في Flutter وselect لإعادة إنشاء إمكانات التخزين المؤقت للقيمة في Flutter
  • كيفية الاستفادة من هياكل البيانات غير القابلة للتغيير باستخدام built_value وbuilt_collection لتسهيل تنفيذ تقنيات الذكاء الاصطناعي (GOFAI) المستندة إلى عمليات البحث، مثل البحث المعمّق أولاً والتتبّع العكسي
  • كيفية استخدام إمكانات حزمة two_dimensional_scrollables لعرض بيانات الشبكة بطريقة سريعة وسهلة

ما تحتاج إليه

  • Flutter SDK
  • Visual Studio Code (رمز VS) مع المكوّنَين الإضافيَين Flutter وDart
  • برنامج التحويل البرمجي لهدف التطوير الذي اخترته يمكن استخدام هذا الدرس التطبيقي على جميع الأنظمة الأساسية لأجهزة كمبيوتر سطح المكتب وأجهزة Android وiOS. ويجب أن يكون لديك رمز VS Code لاستهداف أنظمة التشغيل Windows وXcode لاستهداف أنظمة التشغيل macOS أو iOS، كما يجب استخدام "استوديو Android" لاستهداف Android.

2. إنشاء مشروع

إنشاء مشروعك الأول على Flutter

  1. قم بتشغيل VS Code.
  2. في سطر الأوامر، أدخِل Flutter new ثم اختَر Flutter: مشروع جديد في القائمة.

لقطة شاشة لـ VS Code مع

  1. اختَر إفراغ التطبيق، ثم اختَر الدليل الذي تريد إنشاء مشروعك فيه. ويجب أن يكون هذا الدليل أي دليل لا يتطلب أذونات مميزة وعالية المستوى أو يحتوي على مسافة في مساره. وتشمل الأمثلة الدليل الرئيسي أو C:\src\.

لقطة شاشة لـ VS Code يظهر فيها Blank Application (تطبيق فارغ) على أنّه محدّد كجزء من مسار الطلب الجديد

  1. أدخِل اسمًا لمشروعك "generate_crossword". تفترض بقية هذه الدروس التطبيقية حول الترميز أنّك أطلقت اسم التطبيق "generate_crossword".

لقطة شاشة لـ VS Code مع

ينشئ Flutter الآن مجلد المشاريع ويفتحه رمز VS. ستقوم الآن بالكتابة فوق محتويات ملفين باستخدام مخزن أساسي للتطبيق.

نسخ التطبيق الأولي ولصقه

  1. في الجزء الأيمن من رمز VS، انقر على Explorer (المستكشف) وافتح ملف pubspec.yaml.

لقطة شاشة جزئية لـ VS Code مع أسهم لتمييز موقع ملف pubspec.yaml

  1. استبدِل محتوى هذا الملف بما يلي:

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

يحدِّد ملف pubspec.yaml المعلومات الأساسية عن تطبيقك، مثل إصداره الحالي وتبعياته. تظهر لك مجموعة من الموارد التابعة التي ليست جزءًا من تطبيق Flutter الفارغ العادي. يمكنك الاستفادة من كل هذه الحزم في الخطوات القادمة.

  1. افتح ملف main.dart في الدليل lib/.

لقطة شاشة جزئية لرمز VS مع سهم يوضّح موقع ملف main.dart

  1. استبدِل محتوى هذا الملف بما يلي:

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. شغِّل هذا الرمز للتأكّد من أنّ كل شيء يعمل على ما يرام. يجب أن يعرض نافذة جديدة بعبارة البدء الإلزامية لكل مشروع جديد في كل مكان. هناك ProviderScope يشير إلى أنّ هذا التطبيق سيستخدم riverpod لإدارة الولايات.

نافذة تطبيق تحتوي على الكلمات &quot;Hello, World!&quot; في الوسط

3- إضافة كلمات

الوحدات الأساسية لألغاز الكلمات المتقاطعة

الكلمات المتقاطعة هي، في صميمها، قائمة من الكلمات. يتم ترتيب الكلمات في شبكة، بعضها متقاطع، والبعض الآخر لأسفل، بحيث تتشابك الكلمات. إن حل كلمة واحدة يعطي أدلة على الكلمات التي تتقاطع مع تلك الكلمة الأولى. وبالتالي، يجب أن تكون الوحدة الأساسية الأولى قائمة من الكلمات.

تُعدّ صفحة بيانات مجموعة اللغة الطبيعية مصدرًا جيدًا لهذه الكلمات. تشكّل قائمة SOWPODS نقطة بداية مفيدة تضم 267,750 كلمة.

في هذه الخطوة، عليك تنزيل قائمة الكلمات وإضافتها كمادة عرض إلى تطبيق Flutter، ثم ترتيب مزوِّد خدمة Riverpod لتحميل القائمة في التطبيق عند بدء تشغيله.

للبدء في ذلك، اتبع الخطوات التالية:

  1. عليك تعديل ملف pubspec.yaml الخاص بمشروعك لإضافة بيان مواد العرض التالي لقائمة الكلمات التي اختَرتها. تعرض بطاقة بيانات المتجر هذه فقط مجموعة الصور المميّزة لإعدادات تطبيقك، لأنّ بقية العناصر بقيت على حالها.

pubspec.yaml

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

من المحتمل أن يقوم المحرر لديك بتمييز هذا السطر الأخير مع تحذير لأنك لم تقم بعد بإنشاء هذا الملف.

  1. باستخدام المتصفّح والمحرّر، أنشِئ دليل assets في المستوى الأعلى من مشروعك وأنشِئ ملف words.txt فيه باستخدام إحدى قوائم الكلمات التي تمت إضافة رابط لها أعلاه.

تم تصميم هذا الرمز باستخدام قائمة SOWPODS المذكورة أعلاه، ولكن يجب أن يعمل مع أي قائمة كلمات تتكون من أحرف A-Z فقط. يُترك تمديد قاعدة الرموز هذه للعمل مع مجموعات أحرف مختلفة كتمرين للقارئ.

تحميل الكلمات

لكتابة الرمز البرمجي المسؤول عن تحميل قائمة الكلمات عند بدء تشغيل التطبيق، اتّبِع الخطوات التالية:

  1. أنشِئ ملف providers.dart في الدليل lib.
  2. أضِف ما يلي إلى الملف:

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

هذا هو أول مزود Riverpod لقاعدة الرموز هذه. ستلاحظ أن هناك العديد من الجوانب التي سيشكلها المحرر الخاص بك كفئة غير محددة أو هدف لم يتم إنشاؤه. يستخدم هذا المشروع عملية إنشاء التعليمات البرمجية لتبعيات متعددة، بما في ذلك Riverpod، لذا من المتوقع حدوث أخطاء فئة غير محددة.

  1. لبدء إنشاء الرمز، شغِّل الأمر التالي:
$ 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)

وسيستمر تشغيله في الخلفية، مع تحديث الملفات التي تم إنشاؤها أثناء إجراء تغييرات على المشروع. بعد إنشاء هذا الأمر للرمز في providers.g.dart، من المفترض أن يكون المحرر الذي تتعامل معه موافقًا على الرمز الذي أضفته إلى providers.dart أعلاه.

في Riverpod، يتم عادةً إنشاء مثيل كسول لمقدّمي الخدمات، مثل الدالة wordList التي حدّدتها أعلاه. ومع ذلك، لأغراض هذا التطبيق، سيلزم تحميل قائمة الكلمات بحرص. تقترح مستندات Riverpod الأسلوب التالي للتعامل مع مقدّمي الخدمات الذين تحتاج إلى تحميلهم بحزم. ستنفذ ذلك الآن.

  1. أنشِئ ملف crossword_generator_app.dart في دليل lib/widgets.
  2. أضِف ما يلي إلى الملف:

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

هذا الملف مثير للاهتمام من اتجاهين منفصلين. الأولى هي التطبيق المصغّر _EagerInitialization، ومهمته الوحيدة هي أن تطلب من موفِّر خدمة wordList الذي أنشأته أعلاه تحميل قائمة الكلمات. تحقّق هذه الأداة هذا الهدف من خلال الاستماع إلى مقدّم الخدمة باستخدام طلب ref.watch(). يمكنك قراءة المزيد من المعلومات عن هذا الأسلوب في مستندات Riverpod حول الإعداد السريع لمقدّمي الخدمات.

تجدر الإشارة إلى أن النقطة الثانية المثيرة للاهتمام في هذا الملف هي كيفية تعامل Riverpod مع المحتوى غير المتزامن. كما هو موضح، يتم تعريف موفِّر wordList على أنّه دالة غير متزامنة، لأنّ تحميل المحتوى من القرص بطيء. عند مشاهدة موفِّر قائمة الكلمات في هذا الرمز، ستتلقّى AsyncValue<BuiltSet<String>>. يُعد الجزء AsyncValue من هذا النوع محوّلاً بين العالم غير المتزامن لموفري الخدمات والعالم المتزامن لطريقة build في الأداة.

تعالج طريقة when في AsyncValue الحالات الثلاث المحتمَلة التي قد تكون فيها القيمة المستقبلية. ربما تم حلّ المسألة المستقبلية بنجاح، وفي هذه الحالة تم استدعاء استدعاء data، قد تكون هناك حالة خطأ، وفي هذه الحالة تم استدعاء معاودة الاتصال error، أو قد يكون التحميل جاريًا في النهاية. يجب أن تحتوي أنواع الإرجاع للاستدعاءات الثلاثة على أنواع إرجاع متوافقة، حيث يتم إرجاع ما يسمى معاودة الاتصال باستخدام الطريقة when. في هذه الحالة، يتم عرض نتيجة الطريقة when على أنّها body للأداة Scaffold.

إنشاء تطبيق قائمة شبه لانهائية

لدمج تطبيق "CrosswordGeneratorApp" المصغّر في تطبيقك، يُرجى اتّباع الخطوات التالية:

  1. عدِّل ملف lib/main.dart من خلال إضافة الرمز التالي:

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. أعِد تشغيل التطبيق. من المفترض أن تظهر لك قائمة تمرير ستظل متاحة إلى الأبد تقريبًا.

نافذة تطبيق بعنوان &quot;منشئ الكلمات المتقاطعة&quot; وقائمة من الكلمات

4. عرض الكلمات في شبكة

في هذه الخطوة، ستُنشئ بنية بيانات لإنشاء ألغاز كلمات متقاطعة باستخدام الحِزمتَين built_value وbuilt_collection. تمكن هاتان الباقتان إنشاء هياكل البيانات كقيم غير قابلة للتغيير، والتي ستكون مفيدة لكل من تمرير البيانات بسهولة بين المعزولات، وتسهيل تنفيذ البحث المتعمق أولاً والتتبع العكسي كثيرًا.

للبدء في ذلك، اتبع الخطوات التالية:

  1. أنشِئ ملف model.dart في دليل lib، ثم أضِف المحتوى التالي إلى الملف:

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;

يصف هذا الملف بداية هيكل البيانات التي ستستخدمها لإنشاء الكلمات المتقاطعة. في جوهره، أحجية الكلمات المتقاطعة هي قائمة من الكلمات الأفقية والرأسية المتشابكة في شبكة. لاستخدام بنية البيانات هذه، عليك إنشاء Crossword بالحجم المناسب باستخدام الدالة الإنشائية Crossword.crossword المُسماة، ثم إضافة كلمات باستخدام الطريقة addWord. كجزء من إنشاء القيمة النهائية، يتم إنشاء شبكة مكوّنة من CrosswordCharacter باستخدام الطريقة _fillCharacters.

لاستخدام هيكل البيانات هذا، اتبع الخطوات التالية:

  1. أنشِئ ملف utils في دليل lib، ثم أضِف المحتوى التالي إلى الملف:

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

هذه إضافة على BuiltSet تتيح إمكانية استرداد عنصر عشوائي من المجموعة بسهولة. تسهِّل طرق الإضافات توسيع الصفوف باستخدام وظائف إضافية. يجب تسمية الامتداد لإتاحة الإضافة خارج ملف utils.dart.

  1. في ملف lib/providers.dart، أضِف عمليات الاستيراد التالية:

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 {

تعرض عمليات الاستيراد هذه النموذج المحدّد أعلاه لموفّري الخدمات الذين أنت على وشك إنشاءهم. يتم تضمين استيراد dart:math لـ Random، بينما يتم تضمين flutter/foundation.dart للاستيراد لـ debugPrint، وmodel.dart للنموذج، وutils.dart للإضافة BuiltSet.

  1. في نهاية الملف نفسه، أضِف الموفّرين التاليين:

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

تؤدي هذه التغييرات إلى إضافة اثنين من مقدّمي الخدمة إلى تطبيقك. الأول هو Size، وهو متغير عمومي يحتوي على القيمة المحددة حاليًا للتعداد CrosswordSize. سيسمح ذلك لواجهة المستخدم بعرض وتعيين حجم الكلمات المتقاطعة قيد الإنشاء. مقدم الخدمة الثاني، crossword، هو إنشاء أكثر تشويقًا. وهي دالة تعرض سلسلة من Crossword. وقد بنيت باستخدام دعم Dart للمولدات، كما هو محدد في async* في الدالة. وهذا يعني أنّه بدلاً من الإنهاء بعائد، ينتج عن سلسلة من Crossword، وهي طريقة أسهل بكثير لكتابة عملية حسابية تعرض نتائج متوسطة.

بسبب توفُّر زوج من مكالمتَي ref.watch في بداية وظيفة مزوِّد crossword، سيعيد نظام Riverpod بث Crossword في كل مرة يتغيّر فيها الحجم المحدَّد من الكلمات المتقاطعة وعند انتهاء تحميل قائمة الكلمات.

الآن بعد أن أصبح لديك تعليمة برمجية لإنشاء كلمات متقاطعة، وإن كانت مليئة بالكلمات العشوائية، فسيكون من الجيد عرضها لمستخدم الأداة.

  1. أنشئ ملف crossword_widget.dart في الدليل lib/widgets بالمحتوى التالي:

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

بما أنّ هذه الأداة ConsumerWidget، يمكنها الاعتماد مباشرةً على موفِّر خدمة Size لتحديد حجم الشبكة التي سيتم عرض أحرف Crossword عليها. يتم عرض هذه الشبكة باستخدام التطبيق المصغّر TableView من حزمة two_dimensional_scrollables.

يُرجى العِلم أنّ الخلايا الفردية التي تعرضها الدوال المساعدة _buildCell تحتوي على كل تطبيق مصغّر Consumer في شجرة Widget المعروضة. ويكون هذا بمثابة حدود تحديث. تتم إعادة إنشاء كل المحتوى داخل التطبيق المصغّر Consumer عند تغيُّر قيمة ref.watch المعروضة. من المُغري إعادة إنشاء الشجرة بأكملها في كل مرة تتغيّر فيها Crossword، إلا أنّها تتسبب في حدوث الكثير من العمليات الحسابية التي يمكن تخطّيها باستخدام هذا الإعداد.

إذا نظرت إلى معلَمة ref.watch، ستلاحظ أنّ هناك طبقة أخرى يمكنك تجنّب إعادة حساب التنسيقات، وذلك باستخدام crosswordProvider.select. يعني هذا أنّ ref.watch لن يؤدي إلى إعادة إنشاء محتوى TableViewCell إلا عندما تكون الخلية مسؤولة عن عرض التغييرات. ويشكّل هذا التقليل في إعادة العرض جزءًا أساسيًا من سرعة استجابة واجهة المستخدم.

لعرض موفِّري CrosswordWidget وSize للمستخدم، يمكنك تغيير ملف crossword_generator_app.dart على النحو التالي:

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

تغيّرت بعض الأمور هنا. أولاً، تم استبدال الرمز المسؤول عن عرض wordList على أنّه ListView بطلب CrosswordWidget المحدَّد في الملف السابق. التغيير الرئيسي الآخر هو بدء قائمة لتغيير سلوك التطبيق، بدءًا من تغيير حجم الكلمات المتقاطعة. ستتم إضافة المزيد من MenuItemButton في الخطوات المستقبلية. شغِّل تطبيقك، وسترى شيئًا مثل هذا:

نافذة تطبيق تحمل العنوان &quot;منشئ الكلمات المتقاطعة&quot; وشبكة من الأحرف مكتوب عليها كلمات متداخلة بدون قافية أو سبب

هناك أحرف معروضة في شبكة وقائمة تمكّن المستخدم من تغيير حجم الشبكة. ولكن لا ترسم الكلمات على شكل أحجية كلمات متقاطعة. ويرجع ذلك إلى عدم فرض أي قيود على كيفية إضافة الكلمات إلى الكلمات المتقاطعة. باختصار، هناك فوضى. شيء ستبدأ في السيطرة عليه في الخطوة التالية!

5- فرض قيود

إن إضافة التعليمات البرمجية إلى النموذج لفرض قيود الكلمات المتقاطعة هي الهدف من هذه الخطوة. هناك أنواع مختلفة من ألغاز الكلمات المتقاطعة، ويعتمد هذا الدرس التطبيقي على تقاليد ألغاز الكلمات المتقاطعة الإنجليزية. واعلم أنّ تعديل هذا الرمز البرمجي لإنشاء أنماط أخرى من ألغاز الكلمات المتقاطعة هو تمرين للقارئ.

للبدء في ذلك، اتبع الخطوات التالية:

  1. افتح ملف model.dart واستبدِل نموذج Crossword بما يلي:

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

كتذكير سريع، تتطلب التغييرات التي تجريها على ملفَي model.dart وproviders.dart تفعيل build_runner لتعديل ملف model.g.dart وproviders.g.dart المعنيَّين. إذا لم يتم تعديل هذه الملفات تلقائيًا، حان الوقت لبدء "build_runner" مجددًا باستخدام "dart run build_runner watch -d".

للاستفادة من هذه الإمكانية الجديدة في طبقة النموذج، ستحتاج إلى تحديث طبقة الموفِّر للمطابقة.

  1. عدِّل ملف providers.dart على النحو التالي:

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. شغِّل تطبيقك. لا يحدث الكثير في واجهة المستخدم، ولكن سيحدث الكثير عند النظر إلى السجلات.

نافذة تطبيق &quot;منشئ الكلمات المتقاطعة&quot; التي تحتوي على كلمات مكتوب عليها كلمات بالإنجليزية متقاطعة مع نقاط عشوائية

إذا فكرت فيما يحدث هنا، فإننا نرى كلمة متقاطعة تظهر عن طريق الصدفة العشوائية. ترفض طريقة addWord في النموذج Crossword أي كلمة مقترحة لا تتلاءم مع الكلمات المتقاطعة الحالية، لذا من المدهش أن نرى أي شيء يظهر على الإطلاق.

استعدادًا لمراعاة اختيار الكلمات التي يجب تجربتها، سيكون من المفيد جدًا نقل هذه العملية الحسابية خارج سلسلة واجهة المستخدم إلى وحدة عزل في الخلفية. يتضمّن Flutter برنامج تضمين مفيدًا جدًا لتنفيذ قدر من العمل وتنفيذه في عزل الخلفية، وهو عبارة عن الوظيفة compute.

  1. في ملف providers.dart، عدِّل موفِّر الكلمات المتقاطعة على النحو التالي:

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

هذا الرمز يعمل. لكنه يحتوي على فخ. إذا بقيت على هذا المسار، فسينتهي بك الأمر إلى ظهور خطأ مسجَّل مثل ما يلي:

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)

يحدث هذا نتيجة إغلاق التطبيق compute الذي يتم تسليمه إلى ميزة إغلاق عزل الخلفية لدى موفِّر، ولا يمكن إرساله عبر SendPort.send(). لحلّ هذه المشكلة، عليك التأكّد من أنّه لا يمكن إرسال إغلاق المرآب.

الخطوة الأولى هي فصل المزودين عن رمز العزل.

  1. أنشئ ملف isolates.dart في دليل lib، ثم أضِف المحتوى التالي إليه:

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

يجب أن يبدو هذا الرمز مألوفًا بشكل معقول. وهو جوهر ما كان في مزوِّد crossword، ولكنّه أصبح الآن دالة إنشاء مستقلّة. يمكنك الآن تعديل ملف providers.dart لاستخدام هذه الوظيفة الجديدة لإنشاء مثيل عزل الخلفية.

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

تتيح لك هذه الأداة إنشاء ألغاز الكلمات المتقاطعة بأحجام مختلفة، بالإضافة إلى حلّ الألغاز في الخلفية من خلال ميزة "compute". والآن، أتمنى لو كان الرمز أكثر كفاءة عند تحديد الكلمات التي تريد إضافتها إلى أحجية الكلمات المتقاطعة.

6- إدارة قائمة انتظار العمل

يتمثل جزء من المشكلة في التعليمة البرمجية كما هي في أن المشكلة التي يتم حلها هي مشكلة البحث بشكل فعال، والحل الحالي هو البحث المكفوفين. إذا كان الرمز يركز على العثور على الكلمات التي سوف تُلحق بالكلمات الحالية، بدلاً من محاولة وضع الكلمات عشوائيًا في أي مكان على الشبكة، فسيجد النظام الحلول بشكل أسرع. هناك طريقة للتعامل مع هذا الأمر تتمثل في تقديم قائمة انتظار للعمل تضمّ المواقع الجغرافية لمحاولة العثور على كلمات لها.

ينشئ الكود حاليًا حلولاً مرشحة، ويتحقق مما إذا كان الحل المرشح صالحًا، ويعتمد الاعتماد على مدى الصلاحية على دمج المرشح أو التخلص منه. هذا مثال على التنفيذ من مجموعة الخوارزميات العكسية. سهّلت built_value وbuilt_collection عملية التنفيذ هذه كثيرًا، ما يتيح إنشاء قيم جديدة غير قابلة للتغيير تستنتج وبالتالي تشترك في الحالة المشتركة مع القيمة غير القابلة للتغيير التي تم اشتقاقها منها. ويتيح ذلك الاستفادة بتكلفة منخفضة من المرشحين المحتملين بدون تكاليف الذاكرة المطلوبة للنسخ العميق.

للبدء في ذلك، اتبع الخطوات التالية:

  1. افتح ملف model.dart وأضِف إليه تعريف 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. إذا كانت هناك خطوط مموجة حمراء متبقية في هذا الملف بعد إضافة هذا المحتوى الجديد لأكثر من بضع ثوانٍ، يُرجى التأكد من أنّ "build_runner" لا يزال قيد التشغيل. وإذا لم يكن الأمر كذلك، شغِّل الأمر dart run build_runner watch -d.

أنت على وشك إدخال التسجيل في التعليمة البرمجية لإظهار المدة التي يستغرقها إنشاء الكلمات المتقاطعة بأحجام مختلفة. سيكون من الرائع أن يكون للمدد شكل ما من أشكال العرض المنسقة بشكل جيد. لحسن الحظ، باستخدام طرق التمديد يمكننا إضافة الطريقة الدقيقة التي نحتاجها.

  1. عدِّل ملف utils.dart على النحو التالي:

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.

تستفيد طريقة الإضافة هذه من تعبيرات التبديل ومطابقة الأنماط عبر السجلات لتحديد الطريقة المناسبة لعرض فترات مختلفة تتراوح من ثوانٍ إلى أيام. لمزيد من المعلومات حول هذا النمط من الرموز، يمكنك الاطّلاع على الدرس التطبيقي حول الترميز الاطّلاع على أنماط وسجلات Dart.

  1. لدمج هذه الوظيفة الجديدة، استبدِل الملف isolates.dart لإعادة تحديد طريقة تعريف الدالة exploreCrosswordSolutions على النحو التالي:

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

سيؤدي تشغيل هذا الرمز إلى إنشاء تطبيق يبدو متطابقًا في السطح، لكن الاختلاف هو المدة التي يستغرقها العثور على لغز كلمات متقاطعة منتهية. أنشئ في دقيقة واحدة و29 ثانية لعبة ألغاز كلمات متقاطعة بحجم 80 × 44.

منشئ الكلمات المتقاطعة، مع العديد من الكلمات المتقاطعة. تم تصغير حجم الكلمات، بحيث لا يمكن قراءتها.

والسؤال الواضح هو بالطبع، هل يمكننا التحرك بشكل أسرع؟ أوه نعم، نعم يمكننا ذلك.

7. عرض الإحصاءات

عند إضفاء السرعة على شيء ما، من المفيد معرفة ما يحدث. الشيء الوحيد الذي يساعد في ذلك هو عرض معلومات حول العملية أثناء تنفيذها. لذا، حان الوقت الآن لإضافة الأدوات وعرض تلك المعلومات على شكل لوحة معلومات قابلة للتحريك.

يجب استخراج المعلومات التي ستعرضها من Workقائمة الانتظار وعرضها في واجهة المستخدم.

تتمثل الخطوة الأولى المفيدة في تحديد فئة نموذج جديدة تحتوي على المعلومات التي تريد عرضها.

للبدء في ذلك، اتبع الخطوات التالية:

  1. عدِّل ملف model.dart على النحو التالي لإضافة الفئة 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. في نهاية الملف، أدخِل التغييرات التالية لإضافة الفئة 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. عدِّل الملف isolates.dart لعرض نموذج WorkQueue على النحو التالي:

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

والآن بعد أن كشفت أنظمة عزل الخلفية عن قائمة انتظار العمل، أصبح الأمر الآن يتساءل عن كيفية استخلاص الإحصاءات من مصدر البيانات هذا ومكان استخلاصها.

  1. استبدِل موفِّر الكلمات المتقاطعة القديم بموفِّر قائمة انتظار العمل، ثم أضِف المزيد من الموفِّرين الذين يستمدون المعلومات من ساحة المشاركات لموفِّر قائمة انتظار العمل:

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

المزودون الجدد عبارة عن مزيج من الحالة العامة، في شكل ما إذا كان يجب وضع عرض المعلومات فوق شبكة الكلمات المتقاطعة، والبيانات المستمدة مثل وقت تشغيل إنشاء الكلمات المتقاطعة. وكل هذا معقّد لأنّ مستمعي بعض هذه الحالة عابرون. لا شيء يستمع إلى وقت بدء وانتهاء حساب الكلمات المتقاطعة إذا كانت المعلومات المعروضة مخفيّة، ولكن يجب أن تبقى هذه المعلومات في الذاكرة إذا كانت العمليات الحسابية دقيقة عند عرض المعلومات. تعتبر المعلمة keepAlive للسمة Riverpod مفيدة جدًا في هذه الحالة.

هناك تجعّد طفيف في الشاشة أثناء عرض المعلومات. نريد القدرة على إظهار وقت التشغيل المنقضي حاليًا، ولكن لا يوجد شيء هنا لفرض التحديث المستمر للوقت المنقضي حاليًا بسهولة. بالعودة إلى الدرس التطبيقي حول إنشاء واجهات المستخدم من الجيل التالي في Flutter، إليك أداة مفيدة لتحقيق هذا الشرط فقط.

  1. أنشِئ ملف ticker_builder.dart في دليل lib/widgets، ثم أضِف المحتوى التالي إليه:

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

هذه الأداة عبارة عن مطرقة ثقيلة. يعيد إنشاء المحتوى في كل إطار. وهذا أمر استياء عمومًا، ولكن مقارنةً بالحمل الحسابية للبحث عن ألغاز الكلمات المتقاطعة، من المحتمل أن يختفي الحِمل الحاسوبي لإعادة طلاء الوقت المنقضي في كل إطار بدون أي تشويش. للاستفادة من هذه المعلومات الناتجة حديثًا، حان الوقت لإنشاء أداة جديدة.

  1. أنشئ ملف crossword_info_widget.dart في دليل lib/widgets، ثم أضِف المحتوى التالي إليه:

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

هذا التطبيق المصغّر هو مثال رئيسي على مزايا موفّري Riverpod. سيتم وضع علامة على هذه الأداة لإعادة إنشائها عند تحديث أي من مزودي الخدمة الخمسة. التغيير الأخير المطلوب في هذه الخطوة هو دمج هذه الأداة الجديدة في واجهة المستخدم.

  1. عدِّل ملف crossword_generator_app.dart على النحو التالي:

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

يوضح التغييران المذكوران هنا طرقًا مختلفة لدمج مقدّمي الخدمة. في طريقة build في CrosswordGeneratorApp، قدّمت أداة إنشاء Consumer جديدة لاحتواء المنطقة المفروضة على إعادة البناء عند عرض المعلومات أو إخفائها. من ناحية أخرى، القائمة المنسدلة بأكملها هي ConsumerWidget واحدة، وستتم إعادة تصميمها سواء تمّ تغيير حجم الكلمات المتقاطعة أو إظهار المعلومات أو إخفاؤها. والأسلوب الذي يجب اتخاذه دائمًا هو المقايضة الهندسية بين البساطة وتكلفة إعادة حساب التنسيقات لأشجار الأدوات التي أُعيد تصميمها.

يمنح تشغيل التطبيق الآن المستخدم المزيد من الرؤى حول كيفية تقدم إنشاء الكلمات المتقاطعة. ومع ذلك، اقتربنا من نهاية جيل الكلمات المتقاطعة نرى أن هناك فترة تتغير فيها الأرقام، لكن هناك تغيُّر بسيط جدًا في شبكة الأحرف.

نافذة تطبيق &quot;منشئ الكلمات المتقاطعة&quot;، مع كلمات أصغر حجمًا يسهل التعرّف عليها، وتراكب عائم في أسفل يسار الشاشة مع إحصاءات حول إطلاق الإصدار الحالي

سيكون من المفيد الحصول على إحصاءات إضافية حول ما يحدث وسبب ذلك.

8. التوازي مع سلاسل المحادثات

لفهم سبب بطء الأمور في النهاية، من المفيد أن تكون قادرًا على تصور ما تفعله الخوارزمية. وتشكّل locationsToTry المعلّقة في WorkQueue بشكل أساسي. يمنحنا TableView طريقة مفيدة للتحقيق في هذا الأمر. يمكننا تغيير لون الخلية بناءً على ما إذا كانت في locationsToTry.

للبدء في ذلك، اتبع الخطوات التالية:

  1. عدِّل ملف crossword_widget.dart على النحو التالي:

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

عند تشغيل هذه التعليمة البرمجية، سترى تصورًا للمواقع المعلقة التي لم تتحقق الخوارزمية بعد.

&quot;منشئ كلمات متقاطعة&quot; يُظهر مرحلة الجيل. تحتوي بعض الأحرف على نص أبيض على خلفية زرقاء داكنة، بينما يحتوي البعض الآخر على نص أزرق على خلفية بيضاء.

الشيء المثير للاهتمام في ملاحظة هذا أثناء تقدم الكلمات المتقاطعة نحو الانتهاء هو أن هناك مجموعة من النقاط المتبقية للتحقيق فيها ولن تؤدي إلى أي شيء مفيد. هناك خياران هنا؛ أحدهما هو تقييد التحقيق بمجرد ملء نسبة معينة من خلايا الكلمات المتقاطعة والثاني هو التحقيق في نقاط اهتمام متعددة في وقت واحد. يبدو المسار الثاني أكثر متعة، لذلك دعونا نفعل ذلك.

  1. عدِّل ملف isolates.dart. وهذا إجراء إعادة كتابة كاملة للرمز البرمجي لتقسيم ما كان يتم حسابه في خلفية واحدة منفصلة إلى مجموعة من عناصر عزل الخلفية.

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

ومن المفترض أن يكون معظم هذا الرمز مألوفًا لأن منطق العمل الأساسي لم يتغير. ما تم تغييره هو توفُّر طبقتين من طلبات "compute" الآن. تكون الطبقة الأولى مسؤولة عن توزيع المواضع الفردية للبحث عن عزل العمال N، ثم إعادة دمج النتائج عند إنهاء جميع عمليات عزل العمال N. تتكون الطبقة الثانية من عزل العامل N. يعتمد ضبط N للحصول على أفضل أداء على كل من جهاز الكمبيوتر والبيانات المعنية. كلما زادت الشبكة، زاد عدد العمال الذين يمكنهم العمل معًا دون اعتراض بعضهم البعض.

يتمثل أحد التجاعيد المثيرة للاهتمام في ملاحظة كيفية تعامل هذه التعليمات البرمجية الآن مع مشكلة عمليات الإغلاق التي تسجل الأشياء التي لا ينبغي التقاطها. ما من حالات إغلاق في الوقت الحالي. يتم تعريف الدالتَين _generate و_generateWorker كدالتَين من المستوى الأعلى لا تتوفّر فيهما بيئة محيطة يمكن التعرّف عليها. تكون الوسيطات في ونتائج كلتا الدالتين في شكل سجلات Dart. هذه طريقة سهلة لإيجاد قيمة واحدة في دلالات قيمة واحدة من استدعاء الدالة compute.

والآن بعد أن أصبح لديك القدرة على إنشاء مجموعة من العاملين في الخلفية للبحث عن الكلمات التي تتشابك في شبكة لتكوين أحجية كلمات متقاطعة، حان الوقت لتعرض هذه القدرة لبقية أداة إنشاء الكلمات المتقاطعة.

  1. يمكنك تعديل ملف providers.dart من خلال تعديل موفِّر Workplaylist على النحو التالي:

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. أضِف موفِّر "WorkerCount" إلى نهاية الملف على النحو التالي:

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.

من خلال هذين التغييرين، تكشف طبقة الموفّر الآن عن طريقة لضبط الحد الأقصى لعدد العاملين في مجموعة عزل الخلفية بطريقة يتم بها ضبط وظائف العزل بشكل صحيح.

  1. عدِّل ملف crossword_info_widget.dart من خلال تعديل CrosswordInfoWidget على النحو التالي:

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. عدِّل الملف crossword_generator_app.dart من خلال إضافة القسم التالي إلى تطبيق _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),
        ),
      );
}

في حال تشغيل التطبيق الآن، ستتمكّن من تعديل عدد عمليات عزل الخلفية التي يتم إنشاء مثيل لها للبحث عن كلمات لإضافتها إلى الكلمات المتقاطعة.

  1. انقر على رمز الترس في القائمة السياقية التي تحتوي على مقاسات الكلمات المتقاطعة، وما إذا كان سيتم عرض إحصاءات حول الكلمات المتقاطعة التي تم إنشاؤها حاليًا، والآن، عدد العناصر المعزولة المطلوب استخدامها.

نافذة &quot;منشئ الكلمات المتقاطعة&quot; التي تتضمّن كلمات وإحصاءات

أدى تشغيل أداة إنشاء الكلمات المتقاطعة إلى انخفاض كبير في وقت الحوسبة لكلمة متقاطعة بحجم 80x44 بفضل استخدام نوى متعددة في الوقت نفسه.

9. تحويل اللعبة إلى لعبة

هذا القسم الأخير هو جولة إضافية حقًا. وستأخذ كل الأساليب التي تعلمتها أثناء إنشاء منشئ الكلمات المتقاطعة وتستخدم هذه التقنيات لبناء لعبة. ستستخدم أداة إنشاء الكلمات المتقاطعة لإنشاء لغز كلمات متقاطعة. ستقوم بإعادة استخدام عبارات القائمة السياقية لتمكين المستخدم من تحديد الكلمات وإلغاء تحديدها لوضعها في الثقوب المختلفة على شكل كلمات في الشبكة. كل ذلك بهدف إكمال الكلمات المتقاطعة.

لن أقول إن هذه اللعبة مصقولة أو منتهية، بل شيء أبعد ما يكون في الواقع. هناك مشكلات في التوازن والصعوبة يمكن حلها من خلال تحسين اختيار الكلمات البديلة. لا يوجد برنامج تعليمي لتوجيه المستخدمين إليها، كما أن الرسوم المتحركة المفكرة تترك الكثير من الأمور غير المرغوب فيها. لن أذكر عبارة "لقد فزت" الشاشة.

والمفاضلة هنا هي أن صقل هذه اللعبة الأولية بشكل صحيح إلى لعبة كاملة يتطلب المزيد من الرموز. يجب توفير رموز برمجية أكثر من تلك المطلوبة في درس تطبيقي واحد حول الترميز. وبدلاً من ذلك، تهدف هذه الخطوة إلى تعزيز الأساليب التي تم تعلّمها حتى الآن في هذا الدرس التطبيقي حول الترميز من خلال تغيير مكان وكيفية استخدامها. نأمل أن يعزز ذلك الدروس المستفادة سابقًا في هذا الدرس التطبيقي حول الترميز. بدلاً من ذلك، يمكنك المتابعة وإنشاء تجاربك الخاصة بناءً على هذا الرمز. نود أن نرى ما تقوم بإنشائه!

للبدء في ذلك، اتبع الخطوات التالية:

  1. احذف جميع البيانات من الدليل lib/widgets. ستتمكّن من إنشاء تطبيقات مصغّرة جديدة ورائعة للعبتك. يحدث ذلك لاستعارة الكثير من التطبيقات المصغّرة القديمة.
  2. عدِّل ملف model.dart لتعديل طريقة addWord في Crossword على النحو التالي:

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

يتيح هذا التعديل البسيط في نموذج الكلمات المتقاطعة إضافة كلمات لا تتداخل. من المفيد السماح للّاعبين باللعب في أي مكان على اللوح وسيظلّ بإمكانهم استخدام Crossword كنموذج أساسي لتخزين حركات اللاعب. هي مجرد قائمة كلمات من مواقع محددة يتم وضعها في اتجاه محدد.

  1. أضِف فئة النموذج CrosswordPuzzleGame إلى نهاية ملف 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;

التغييرات التي تم إجراؤها على ملف providers.dart هي حزمة مفيدة من التغييرات. تمّت إزالة معظم مقدّمي الخدمة الذين كانوا حاضرين لدعم جمع الإحصاءات. تمت إزالة إمكانية تغيير عدد عمليات عزل الخلفية واستبدالها بعدد ثابت. يتوفّر أيضًا مقدّم خدمة جديد يتيح الوصول إلى نموذج "CrosswordPuzzleGame" الجديد الذي أضفته للتوّ.

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

إنّ أهم التفاصيل التي يستند إليها مقدّم خدمة Puzzle هي الاستراتيجيات التي يتم اتّباعها لتحسين تكلفة إنشاء CrosswordPuzzleGame من Crossword وwordList، بالإضافة إلى تكاليف اختيار الكلمات. يتسبب كلا الإجراءين عند تنفيذهما بدون مساعدة في عزل الخلفية في حدوث تفاعل بطيئًا مع واجهة المستخدم. باستخدام بعض خفة اليد لدفع نتيجة متوسطة أثناء حساب النتيجة النهائية في الخلفية، ينتهي بك الأمر مع واجهة مستخدم سريعة الاستجابة بينما تحدث العمليات الحسابية المطلوبة في الخلفية.

  1. في دليل lib/widgets الفارغ الآن، يمكنك إنشاء ملف crossword_puzzle_app.dart يتضمّن المحتوى التالي:

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

من المفترض أن يكون معظم هذا الملف مألوفًا إلى حد ما الآن. نعم، ستظهر تطبيقات مصغّرة غير محدّدة، وستبدأ الآن في إصلاحها.

  1. أنشئ ملف crossword_generator_widget.dart وأضِف المحتوى التالي إليه:

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

يجب أن يكون هذا أيضًا مألوفًا بشكل معقول. الاختلاف الأساسي هو أنه بدلاً من عرض حروف الكلمات التي يتم إنشاؤها، يتم الآن عرض حرف يونيكود للإشارة إلى وجود حرف غير معروف. هذا يمكن حقًا استخدام بعض العمل لتحسين الجماليات.

  1. أنشئ ملف crossword_puzzle_widget.dart وأضِف المحتوى التالي إليه:

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

وتتميز هذه الأداة بكثافة أكبر من الأداة السابقة، على الرغم من أنها قد تم بناؤها من أجزاء سبق لك استخدامها في أماكن أخرى في الماضي. الآن، تنتج كل خلية معبأة قائمة سياق عند النقر عليها، والتي تسرد الكلمات التي يمكن للمستخدم تحديدها. إذا تم تحديد الكلمات، فلن يكون من الممكن اختيار الكلمات المتعارضة. لإلغاء اختيار كلمة، ينقر المستخدم على عنصر القائمة لتلك الكلمة.

على افتراض أن اللاعب يمكنه اختيار الكلمات لملء الكلمات المتقاطعة بالكامل، ستحتاج إلى عبارة "لقد فزت!" الشاشة.

  1. أنشئ ملف puzzle_completed_widget.dart ثم أضِف المحتوى التالي إليه:

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

أنا متأكد من أنه يمكنك الاستعانة به وجعله أكثر تشويقًا. لمزيد من المعلومات حول أدوات الصور المتحركة، يُرجى الاطّلاع على الدرس التطبيقي حول الترميز إنشاء واجهات مستخدم من الجيل التالي في Flutter.

  1. عدِّل ملف lib/main.dart على النحو التالي:

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

عند تشغيل هذا التطبيق، ستظهر لك الحركة أثناء إنشاء لغزك من خلال أداة إنشاء الكلمات المتقاطعة. بعد ذلك، سيظهر لك لغز فارغ لحله. بافتراض أنك تحلها، يجب أن تظهر لك شاشة تبدو على النحو التالي:

نافذة تطبيق ألغاز الكلمات المتقاطعة تعرض النص &quot;اكتمل اللغز&quot;.

10. تهانينا

تهانينا! لقد نجحت في إنشاء لعبة ألغاز باستخدام Flutter

لقد أنشأت أداة إنشاء كلمات متقاطعة وتحوّلت إلى لعبة ألغاز. لقد أتقنت إجراء العمليات الحسابية في الخلفية ضمن مجموعة من وحدات العزل. لقد استخدمت هياكل بيانات غير قابلة للتغيير لتيسير تنفيذ خوارزمية التتبع العكسي. وقضيت وقتًا ممتعًا مع TableView، وسيكون مفيدًا في المرة القادمة التي تحتاج فيها إلى عرض بيانات جدولية.

مزيد من المعلومات