Do Widgets tutorial (#1)

This commit is contained in:
2026-06-04 17:59:48 +02:00
parent 2ba63a4f0e
commit c7e80406b1
8 changed files with 460 additions and 20 deletions

10
AGENTS.md Normal file
View File

@@ -0,0 +1,10 @@
This is a Flutter project.
The structure is as follows:
* `abc/` directory contains usual Flutter application
* `util/` directory contains scripts to run the application
* `src/` directory contains source code files that are symlinked into `abc/lib/` directory so that Flutter builder can find them
All new source code files should go into `src/` directory and be symlinked to `abc/lib/`.

1
abc/lib/game.dart Symbolic link
View File

@@ -0,0 +1 @@
../../src/game.dart

View File

@@ -1,20 +0,0 @@
import 'package:flutter/material.dart';
void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Center(
child: Text('Wow, nice, Hello World!'),
),
),
);
}
}

1
abc/lib/main.dart Symbolic link
View File

@@ -0,0 +1 @@
../../src/main.dart

1
abc/lib/tutFun.dart Symbolic link
View File

@@ -0,0 +1 @@
../../src/tutFun.dart

287
src/game.dart Normal file
View File

@@ -0,0 +1,287 @@
/// Game logic and supporting types for Birdle,
/// a five-letter word-guessing game similar to Wordle.
///
/// Defines the [Game] state machine and the
/// [Word], [Letter], and [HitType] data model used to
/// represent guesses and their evaluation against a hidden word.
library;
import 'dart:collection';
import 'dart:math';
/// The result of evaluating a [Letter] of a guess against the hidden word.
enum HitType {
/// The letter hasn't yet been evaluated.
none,
/// The letter matches the hidden word's letter at the same position.
hit,
/// The letter is in the hidden word, but at a different position.
partial,
/// The letter doesn't appear in the hidden word.
miss,
}
/// A single character paired with its [HitType] against the hidden word.
typedef Letter = ({String char, HitType type});
/// Every word that can be legally entered as a guess.
const List<String> allLegalGuesses = [...legalWords, ...legalGuesses];
/// Words that can be chosen as the hidden word.
const List<String> legalWords = ['aback', 'abase', 'abate', 'abbey', 'abbot'];
/// Additional words accepted as guesses beyond those in [legalWords].
const List<String> legalGuesses = [
'aback',
'abase',
'abate',
'abbey',
'abbot',
'abhor',
'abide',
'abled',
'abode',
'abort',
];
/// Game state of a single round of Birdle,
/// a five-letter word-guessing game similar to Wordle.
///
/// Exposes the state and methods a UI needs to
/// evaluate guesses and track progress,
/// but doesn't advance play on its own.
///
/// Clients drive each round by calling [guess] to submit an attempt and
/// [resetGame] to start over.
class Game {
/// The default maximum number of guesses allowed in a [Game].
static const int defaultMaxGuesses = 5;
/// Creates a new game with [maxGuesses] guesses allowed.
///
/// If [seed] is provided, the hidden word is
/// chosen deterministically from [legalWords],
/// otherwise it is selected at random.
Game({this.maxGuesses = defaultMaxGuesses, this.seed})
: _wordToGuess = _generateInitialWord(seed),
_guesses = List<Word>.filled(maxGuesses, Word.empty());
/// The maximum number of guesses allowed in this game.
final int maxGuesses;
/// The seed used to choose the hidden word,
/// or `null` if it was selected at random.
final int? seed;
/// The current hidden word, exposed publicly through [hiddenWord].
Word _wordToGuess;
/// Backing storage for [guesses].
///
/// Holds every guess slot in order,
/// with unfilled slots represented by empty [Word]s.
List<Word> _guesses;
/// The word the player is trying to guess.
Word get hiddenWord => _wordToGuess;
/// An unmodifiable view of every guess slot, including those still empty.
UnmodifiableListView<Word> get guesses => UnmodifiableListView(_guesses);
/// The most recently submitted guess,
/// or an empty [Word] if no guesses have been made.
Word get previousGuess {
final index = _guesses.lastIndexWhere((word) => word.isNotEmpty);
return index == -1 ? Word.empty() : _guesses[index];
}
/// The index of the next empty guess slot, or `-1` if every slot is full.
int get activeIndex => _guesses.indexWhere((word) => word.isEmpty);
/// The number of guesses still available to the player.
int get guessesRemaining {
if (activeIndex == -1) return 0;
return maxGuesses - activeIndex;
}
/// Whether the most recent guess matches the hidden word.
bool get didWin {
if (_guesses.first.isEmpty) return false;
for (final letter in previousGuess) {
if (letter.type != HitType.hit) return false;
}
return true;
}
/// Whether all allowed guesses have been used without winning.
bool get didLose => guessesRemaining == 0 && !didWin;
/// Picks a new hidden word and clears every submitted guess.
void resetGame() {
_wordToGuess = _generateInitialWord(seed);
_guesses = List<Word>.filled(maxGuesses, Word.empty());
}
/// Evaluates [guess] against the hidden word,
/// records the result in [guesses], and returns it.
///
/// For finer control, use [isLegalGuess] to validate input or
/// [matchGuessOnly] to evaluate without recording the result.
Word guess(String guess) {
final result = matchGuessOnly(guess);
addGuessToList(result);
return result;
}
/// Whether [guess] is a legal word to guess.
///
/// UIs can call this method before [guess] to
/// show players a message when they enter an invalid word.
bool isLegalGuess(String guess) => Word.fromString(guess).isLegalGuess;
/// Evaluates [guess] against the hidden word without advancing the game.
Word matchGuessOnly(String guess) =>
Word.fromString(guess).evaluateGuess(_wordToGuess);
/// Stores [guess] in the next empty slot of [guesses].
void addGuessToList(Word guess) {
final guessIndex = activeIndex;
if (guessIndex == -1) {
throw StateError('No guesses remaining.');
}
_guesses[guessIndex] = guess;
}
/// Returns the starting hidden word for a new round.
///
/// Picks a deterministic word from [legalWords] when [seed] is provided,
/// or one at random otherwise.
static Word _generateInitialWord(int? seed) =>
seed == null ? Word.random() : Word.fromSeed(seed);
}
/// A five-letter word made up of [Letter]s, each tracking its [HitType].
class Word with IterableMixin<Letter> {
/// Creates a word backed by the specified list of [Letter]s.
Word(this._letters);
/// Creates a word with five blank letters of [HitType.none].
factory Word.empty() =>
Word(List<Letter>.filled(5, (char: '', type: HitType.none)));
/// Creates a [Word] from [guess].
///
/// Each character is lowercased,
/// every [Letter] starts as [HitType.none].
factory Word.fromString(String guess) {
if (guess.length != 5) {
throw ArgumentError.value(
guess,
'guess',
'Must be exactly 5 characters long.',
);
}
final letters = guess
.toLowerCase()
.split('')
.map((char) => (char: char, type: HitType.none))
.toList();
return Word(letters);
}
/// Creates a word chosen at random from [legalWords].
factory Word.random() {
final random = Random();
final nextWord = legalWords[random.nextInt(legalWords.length)];
return Word.fromString(nextWord);
}
/// Creates a word chosen from [legalWords] using [seed] as an index.
factory Word.fromSeed(int seed) =>
Word.fromString(legalWords[seed % legalWords.length]);
/// An unmodifiable list of [Letter]s that make up this word.
final List<Letter> _letters;
@override
Iterator<Letter> get iterator => _letters.iterator;
/// Whether every [Letter] in this word has no character.
@override
bool get isEmpty => every((letter) => letter.char.isEmpty);
@override
int get length => _letters.length;
/// The [Letter] at index [i] in word.
Letter operator [](int i) => _letters[i];
@override
String toString() => _letters.map((letter) => letter.char).join().trim();
/// Returns a multi-line string showing each [Letter] alongside its [HitType].
///
/// Used to play the game from the command line.
String toStringVerbose() => _letters
.map((letter) => '${letter.char} - ${letter.type.name}')
.join('\n');
}
/// Validation and guess-evaluation logic on [Word].
extension WordUtils on Word {
/// Whether this word appears in [allLegalGuesses].
bool get isLegalGuess => allLegalGuesses.contains(toString());
/// Compares this [Word] against the specified [hiddenWord]
/// and returns a new [Word] with the same letters,
/// but where each [Letter] has new a [HitType] of
/// [HitType.hit], [HitType.partial], or [HitType.miss].
Word evaluateGuess(Word hiddenWord) {
//assert(isLegalGuess);
final result = List<Letter>.filled(length, (char: '', type: HitType.none));
// Counts hidden-word letters that can still be claimed as partial matches.
final unmatchedHiddenLetterCounts = <String, int>{};
// Reserve exact matches before scoring partial matches.
for (var i = 0; i < length; i++) {
final guessChar = this[i].char;
final hiddenChar = hiddenWord[i].char;
if (guessChar == hiddenChar) {
result[i] = (char: guessChar, type: HitType.hit);
} else {
// Track non-hit hidden letters for the partial-match pass.
final unmatchedCount = unmatchedHiddenLetterCounts[hiddenChar] ?? 0;
unmatchedHiddenLetterCounts[hiddenChar] = unmatchedCount + 1;
}
}
// Spend each remaining hidden letter only once for partial matches.
for (var i = 0; i < length; i++) {
if (result[i].type == HitType.hit) continue;
final guessChar = this[i].char;
final unmatchedCount = unmatchedHiddenLetterCounts[guessChar] ?? 0;
final isPartial = unmatchedCount > 0;
if (isPartial) {
// Use one available hidden letter for this partial match.
unmatchedHiddenLetterCounts[guessChar] = unmatchedCount - 1;
}
result[i] = (
char: guessChar,
type: isPartial ? HitType.partial : HitType.miss,
);
}
return Word(result);
}
}

138
src/main.dart Normal file
View File

@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'game.dart';
import 'tutFun.dart';
void main() {
runApp(const MainApp());
}
class GamePage extends StatefulWidget {
GamePage({super.key});
@override State<GamePage> createState() => GamePageState();
}
class GamePageState extends State<GamePage> {
final Game _game = Game();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
spacing: 5.0,
children: [
for (final guess in _game.guesses)
Row(
spacing: 5.0,
children: [
for (final letter in guess)
Tile(letter.char, letter.type)
],
),
GuessInput(
onSubmitGuess: (guess) {
/**/print(guess);
setState(() {
_game.guess(guess);
});
}
),
],
),
);
}
}
class GuessInput extends StatelessWidget {
GuessInput({super.key, required this.onSubmitGuess});
final FocusNode _focusNode = FocusNode();
final void Function(String) onSubmitGuess;
final TextEditingController _textEditingController = TextEditingController();
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
child: TextField(
autofocus: true,
controller: _textEditingController,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(35)),
),
),
focusNode: _focusNode,
maxLength: 5,
onSubmitted: (_) {
processSubmit();
},
),
padding: const EdgeInsets.all(8.0),
),
),
IconButton(
icon: const Icon(Icons.arrow_circle_up),
onPressed: () {
processSubmit();
},
padding: EdgeInsets.zero,
),
]
);
}
void processSubmit() {
onSubmitGuess(_textEditingController.text.trim());
_textEditingController.clear();
_focusNode.requestFocus();
}
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Align(
alignment: Alignment.centerLeft,
child: Text("Birdle"),
),
),
body: Center(child: GamePage()),
),
);
}
}
class Tile extends StatelessWidget {
const Tile(this.letter, this.hitType, {super.key});
final String letter;
final HitType hitType;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
child: Center(
child: Text(
letter.toUpperCase(),
style: Theme.of(context).textTheme.titleLarge,
),
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
color: tutTileColor(hitType),
),
duration: Duration(milliseconds: 300),
height: 60,
width: 60,
);
}
}

17
src/tutFun.dart Normal file
View File

@@ -0,0 +1,17 @@
library;
import 'package:flutter/material.dart';
import 'game.dart';
Color tutTileColor(HitType t) {
if (t == HitType.hit) {
return Colors.green;
}
if (t == HitType.partial) {
return Colors.yellow;
}
if (t == HitType.miss) {
return Colors.grey;
}
return Colors.white;
}

5
util/run-macos Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
SDIR=$(cd "$(dirname "$0")" ; pwd -P)
cd $SDIR/../abc
flutter run --enable-experiment=dot-shorthands -d macos