👆 Handle User Input — Let Users Type Their Guesses!
🧒 How Do Apps Listen to Users? (The Mailbox Analogy!)
Imagine Your App Has a Mailbox...
Right now, your Birdle app is like a beautiful painting — it looks nice, but it doesn't do anything when you touch it. To make it interactive, your app needs a way to receive messages from the user.
In Flutter, we use three special tools to listen to what the user types:
1. TextField
The mailbox slot — where users type their guesses. It's a box on screen that accepts keyboard input.
2. TextEditingController
The mail clerk — reads what's in the mailbox, clears it out, and delivers the message where it needs to go.
3. FocusNode
The spotlight — makes sure the cursor stays in the text field so the user can keep typing without clicking again.
By the end of this lesson, players will be able to type their 5-letter guesses into your Birdle game and submit them with a button or the Enter key!
Callbacks — "Call Me When Something Happens!"
A callback is just a fancy word for: "Here's my phone number — call me when you're ready."
When you give a widget a callback function, you're saying: "I don't know exactly when the user will type something, but when they do, run this function."
🔍 A Simple Callback Example
// Step 1: Define what should happen when text is submitted
void handleGuess(String userTypedThis) {
print('The user guessed: $userTypedThis');
}
// Step 2: Give this function to the text field
TextField(
onSubmitted: handleGuess, // ← "Call handleGuess when user presses Enter"
)
In plain English: onSubmitted is like saying: "Hey TextField, when the user finishes typing and presses Enter, take whatever they typed and pass it to my handleGuess function. I'll handle it from there!"
The Complete Flow — From Typing to Game Board
Here's the full journey of a user's guess, from keyboard to colored tiles:
- User types "BIRDS" into the TextField
- User presses Enter (or clicks the submit button)
- onSubmitted callback fires — it grabs the text from the controller
- Controller clears the text field (ready for next guess)
- FocusNode keeps the cursor in the text field
- onSubmitGuess callback sends the guess to the game logic
- Game evaluates the guess and updates the tiles
- UI rebuilds — the tiles change colors!
Today you'll build steps 1-6. In the next lesson, you'll learn how the game actually evaluates guesses and updates the UI (steps 7-8).
1 Create the GuessInput Widget Skeleton
First, we'll create a brand new widget called GuessInput that will handle all the typing and submitting logic.
Step 1.1 The Basic Structure with a Callback
Add this code below your Tile class in main.dart:
🔍 The GuessInput Class
class GuessInput extends StatelessWidget {
GuessInput({super.key, required this.onSubmitGuess});
final void Function(String) onSubmitGuess;
@override
Widget build(BuildContext context) {
return Container(); // Placeholder — we'll fill this in!
}
}
What each line means:
class GuessInput extends StatelessWidget— "I'm creating a new widget for typing guesses."required this.onSubmitGuess— "Whoever creates me MUST give me a function to call when the user submits a guess. Therequiredkeyword means it's not optional!"final void Function(String) onSubmitGuess— "I'll store that function. It takes a String (the guess) and returns nothing (void)."- The
buildmethod currently returns an emptyContainer— we're about to replace it with a real UI!
GuessInput widget doesn't know (or care) what happens to the guess after it's submitted. It just says: "Here's what the user typed — you handle it!" This keeps the input widget reusable — you could use the same GuessInput in a different game without changing it.
2 The TextField — Where Users Type
Now let's replace that empty Container with a real text input! We'll use a Row with an Expanded TextField.
Step 2.1 Build the TextField with Rounded Border
Replace the return Container(); in your GuessInput.build method with:
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
maxLength: 5,
decoration: const InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(35)),
),
),
),
),
),
],
);
}
What's new here:
Row— We'll add a button next to the text field later, so we need a horizontal layout.Expanded— Tells the TextField: "Take up all the leftover space in the Row!" Without this, the TextField would be tiny.Padding(EdgeInsets.all(8.0))— Adds 8 pixels of breathing room around the text field.TextField(maxLength: 5)— The star of the show! This creates a text input box. ThemaxLength: 5limits guesses to 5 letters (remember, Birdle uses 5-letter words).decoration: InputDecoration(border: OutlineInputBorder(...))— Gives the text field a nice rounded border (radius 35 makes it pill-shaped).
Step 2.2 Test It! Add GuessInput to Your App
To see the text field, you need to actually use the GuessInput widget in your app. For now, let's add it below your game grid.
Update your MainApp (or wherever your game layout is) to include the GuessInput at the bottom of the Column:
// At the bottom of your Column's children list, add:
GuessInput(
onSubmitGuess: (guess) {
print('User guessed: $guess'); // Temporary — just to test
},
),
Hot reload! You should now see a rounded text input box below your game grid. Try typing in it — you can type up to 5 letters!
3 TextEditingController — Read and Clear Text
A TextEditingController is your remote control for the text field. It lets you read what the user typed, clear the field, and even set text programmatically.
Step 3.1 Add a Controller to Your TextField
Add the controller to your GuessInput class:
class GuessInput extends StatelessWidget {
GuessInput({super.key, required this.onSubmitGuess});
final void Function(String) onSubmitGuess;
final TextEditingController _textEditingController = TextEditingController(); // ← NEW!
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
maxLength: 5,
decoration: const InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(35)),
),
),
controller: _textEditingController, // ← Connect controller to TextField
),
),
),
],
);
}
}
How the controller works:
_textEditingController.text— Reads whatever is currently typed in the field._textEditingController.clear()— Erases everything in the field (like pressing a reset button).- The underscore
_before the name makes it private — only the GuessInput widget can use it.
Step 3.2 Handle the onSubmitted Event
Now add the onSubmitted callback to capture when the user presses Enter:
TextField(
maxLength: 5,
decoration: const InputDecoration(/* ... */),
controller: _textEditingController,
onSubmitted: (_) { // ← Fires when Enter is pressed
print(_textEditingController.text); // Print what user typed
_textEditingController.clear(); // Clear the field for next guess
},
),
What's happening:
onSubmitted: (_) => { ... }— The underscore_means "I know there's a parameter (the submitted text), but I don't need to use it directly because I'll get it from the controller instead."print(_textEditingController.text)— Prints the guess to the console so you can verify it's working._textEditingController.clear()— Empties the text field so the user can type their next guess immediately.
Hot reload, type a 5-letter word in the text field, and press Enter. Check your terminal — you should see the word printed there, and the text field should clear!
4 Focus Control — Keep the Cursor Where It Belongs
Right now, after the user presses Enter, the text field clears but the cursor disappears. The user has to click back into the field to type again. Let's fix that with FocusNode!
Step 4.1 Auto-Focus on Launch
First, make the text field automatically focused when the app starts. Add autofocus: true to your TextField:
TextField(
maxLength: 5,
decoration: const InputDecoration(/* ... */),
controller: _textEditingController,
autofocus: true, // ← Focus this field immediately!
onSubmitted: (_) {
print(_textEditingController.text);
_textEditingController.clear();
},
),
Now when the app launches, the cursor is already in the text field — the user can start typing immediately!
Step 4.2 Keep Focus After Submission
For the second issue (losing focus after Enter), we need a FocusNode:
class GuessInput extends StatelessWidget {
GuessInput({super.key, required this.onSubmitGuess});
final void Function(String) onSubmitGuess;
final TextEditingController _textEditingController = TextEditingController();
final FocusNode _focusNode = FocusNode(); // ← NEW!
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
maxLength: 5,
decoration: const InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(35)),
),
),
controller: _textEditingController,
autofocus: true,
focusNode: _focusNode, // ← Attach the FocusNode
onSubmitted: (_) {
print(_textEditingController.text);
_textEditingController.clear();
_focusNode.requestFocus(); // ← Re-focus after clearing!
},
),
),
),
],
);
}
}
What FocusNode does:
FocusNode()— Creates a "focus manager" that can control where the keyboard cursor is.focusNode: _focusNode— Attaches the focus manager to this specific TextField._focusNode.requestFocus()— Says: "Hey! Put the cursor back in this text field right now!" This runs after the field is cleared, so the user can immediately type their next guess.
Hot reload! Now when you press Enter, the field clears and the cursor stays right where it is — ready for the next guess!
5 Wire It All Together — Use the onSubmitGuess Callback
Now let's replace the temporary print with the actual callback that was passed into the widget.
Step 5.1 Call onSubmitGuess Instead of print
Update the onSubmitted to use the callback:
onSubmitted: (_) {
onSubmitGuess(_textEditingController.text.trim()); // ← Call the callback!
_textEditingController.clear();
_focusNode.requestFocus();
},
What changed:
onSubmitGuess(...)— Instead of just printing, we now call the function that was passed into the widget..trim()— Removes any extra spaces from the beginning or end of the text. So " birds " becomes "birds".
.trim()? If a user accidentally types a space before or after their guess (like " bird " instead of "bird"), the game might reject it. trim() cleans up the input automatically — it's a small detail that makes your app feel polished!
6 Add a Submit Button — IconButton
On mobile phones, users expect a button to submit their input — not everyone knows to press Enter! Let's add an icon button next to the text field.
Step 6.1 Add an IconButton to the Row
Add an IconButton as the second child of the Row (after the Expanded TextField):
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
maxLength: 5,
decoration: const InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(35)),
),
),
controller: _textEditingController,
autofocus: true,
focusNode: _focusNode,
onSubmitted: (_) {
onSubmitGuess(_textEditingController.text.trim());
_textEditingController.clear();
_focusNode.requestFocus();
},
),
),
),
IconButton( // ← NEW: Submit button!
padding: EdgeInsets.zero,
icon: const Icon(Icons.arrow_circle_up),
onPressed: () {
onSubmitGuess(_textEditingController.text.trim());
_textEditingController.clear();
_focusNode.requestFocus();
},
),
],
);
}
What's happening:
IconButton— A button that shows an icon instead of text.padding: EdgeInsets.zero— Removes the default padding so the button is compact.Icon(Icons.arrow_circle_up)— Shows an upward arrow in a circle (⤴️) — a common "submit" symbol.onPressed: () { ... }— Does exactly the same thing as pressing Enter: submits the guess, clears the field, and re-focuses.
Hot reload! You should now see an arrow button next to the text field. Click it — it submits the guess just like pressing Enter!
📝 What You Learned Today
You just made your app interactive! Users can now type and submit guesses.
Built a TextField Input
Created a GuessInput widget with a rounded TextField limited to 5 characters using maxLength.
Used TextEditingController
Read user input with .text, cleared the field with .clear(), and cleaned input with .trim().
Controlled Focus
Used autofocus: true for launch focus and FocusNode.requestFocus() to keep focus after submission.
Added Callbacks & Buttons
Implemented onSubmitted and onPressed callbacks, and added an IconButton for mobile-friendly submission.
🧠 Test Yourself!
Let's check your understanding of user input:
Q1 How do you programmatically read or clear the text in a TextField?
Q2 How do you programmatically move focus to a specific TextField?
📦 Project 1: Create a Styled Login Form
Apply your TextField skills to build a different kind of input — a login form!
Objective: Build a Username & Password Form
📋 Requirements:
- Create a new widget called
LoginForm(StatelessWidget) - It should have a Column containing:
- A TextField for "Username" with an appropriate decoration label
- A SizedBox(height: 16) for spacing
- A TextField for "Password" with
obscureText: true(shows dots instead of letters) - A SizedBox(height: 16)
- An ElevatedButton that says "Login" — when pressed, print both values to the console
- Use two separate TextEditingControllers — one for username, one for password
- Wrap the Column in a Padding of 16 pixels
🎯 Expected Output:
A centered login form with two text fields (username and password) and a Login button. Pressing the button prints both values.
- Use
TextEditingControllerfor each field - Use
obscureText: trueon the password field - Use
InputDecoration(labelText: 'Username')for labels ElevatedButton(onPressed: () {...}, child: Text('Login'))
📦 Project 2: Build a Search Bar with Clear Button
Create a search bar that has a clear (X) button to reset the input — a common UI pattern!
Objective: Search Bar with Prefix Icon and Clear Button
📋 Requirements:
- Create a new widget called
SearchBar - It should have a Row with two children:
- An Expanded TextField with:
- A search icon as a
prefixIconin the decoration - A hintText saying "Search birds..."
- A
TextEditingController
- A search icon as a
- An IconButton with
Icons.clearthat:- Clears the controller text
- Requests focus back to the TextField
- An Expanded TextField with:
- Use a
FocusNodeto manage focus - Add a callback called
onSearch(similar to onSubmitGuess pattern)
🎯 Expected Output:
A search bar with a magnifying glass icon inside the field, hint text, and an X button to clear. Pressing Enter or the X button triggers the onSearch callback.
- Use
prefixIcon: Icon(Icons.search)insideInputDecoration - Use
hintText: 'Search birds...'insideInputDecoration - For the clear button:
onPressed: () { _controller.clear(); _focusNode.requestFocus(); } - Use the same callback pattern as GuessInput:
required this.onSearch
Lesson Complete!
You built a fully interactive text input system! Users can now type guesses, press Enter or click a button to submit, and the field stays focused for the next guess. Your app is becoming a real game!
Click the button above to track your progress!