Build a Virtual Pet with Flutter
Build a Tamagotchi-style virtual pet app with exotic pets using Flutter.
Introduction
30 Second Summary
Remember Tamagotchis? Those tiny digital creatures you had to feed, entertain, and keep alive. The concept is timeless because it turns simple interactions into something you actually care about.
In this project, you will build a virtual pet app using Flutter that runs in your web browser. You'll create a pet with hunger, happiness, and energy stats, and learn how to manage interactive state and handle user input.
What You'll Build
A Tamagotchi-style virtual pet app built with Flutter featuring stat tracking, care actions, and the ability to adopt exotic pets like a Dragon, with Material Design styling.
By the end of this project, you'll have:
- A working Flutter project with proper structure running in Google Chrome.
- A StatefulWidget that manages pet stats (hunger, happiness, energy) with real-time updates when you feed, play, or rest your pet.
- An adopt screen for choosing exotic pets like a Cat, Dog, or Dragon, each with unique starting stats.
- Secret Mission: Add pet evolution stages that unlock as your pet's stats grow.
Are there any prerequisites?
None. This guide includes full setup instructions for every tool you need, including VS Code, Git, and the Flutter SDK. No prior experience with Dart or Flutter is required.
Set Up Your Flutter Environment
Before you can build your Todo app, you need a development environment. Flutter is a toolkit from Google that lets you build apps for web, mobile, and desktop from a single codebase.
In this step, you'll install three things: Git (which Flutter uses behind the scenes), Visual Studio Code (your code editor), and the Flutter SDK itself.
In this step, get ready to:
- Install Git and Visual Studio Code.
- Install the Flutter SDK through VS Code.
- Create and run your first Flutter project in Chrome.
Install Git and Visual Studio Code
Flutter depends on Git to manage its SDK packages internally. You also need a code editor to write and run your Flutter code.
Download Git
The Git install process differs depending on your operating system.
macOS
macOS includes a Git shim that prompts you to install the Xcode Command Line Tools on first use.
- Open Terminal by pressing Cmd+Space, typing Terminal, and pressing Enter.
- Check if Git is already available by running:
git --version
✔️ I see a version number
Git is ready on your Mac. Move on to downloading Visual Studio Code below.
ⓧ I see a prompt to install developer tools
- Click Install in the dialog that appears.
- Wait for the installation to complete (this may take a few minutes).
- Run git --version again to confirm it works.
Windows
- Open your browser and go to git-scm.com.
- Click Download for Windows.
- Run the downloaded installer.
- Accept all the default settings by clicking Next through each screen, then click Install.
- Click Finish when the installation completes.
Why do I need Git?
Flutter uses Git internally to download and update its SDK packages. You won't run Git commands directly in this project, but Flutter needs it behind the scenes.
Download Visual Studio Code
- Open your browser and go to code.visualstudio.com.
- Click the download button for your operating system.
- Run the installer and follow the prompts to complete the installation.
Already have VS Code installed?
If you already have Visual Studio Code on your machine, skip the download and move on to the next substep. Help me check if my VS Code is up to date.
Install the Flutter SDK via VS Code
The Flutter team built a streamlined install experience directly into VS Code. Instead of downloading the SDK manually, you'll use the Flutter extension to handle everything.
- Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Visual Studio Code, and pressing Enter.
- Click the Extensions icon in the left sidebar (it looks like four squares).
- Type Flutter in the search bar.
- Click Install on the Flutter extension by Dart Code.
What does the Flutter extension install?
Installing the Flutter extension also automatically installs the Dart extension. Together, these give you code highlighting, debugging, and project management tools for Flutter development.
Now you'll use the extension to download the Flutter SDK itself.
- Open the Command Palette by pressing Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows).
- Type Flutter: New Project and select it from the dropdown.
- A prompt appears asking you to locate the Flutter SDK. Click Download SDK.
- Choose a folder where you'd like to install the Flutter SDK (your home folder or Documents works well).
- Click Clone Flutter.
- Wait for the download to complete. This may take a few minutes depending on your internet speed.
- When a notification appears saying the SDK is ready, click Add SDK to PATH.
What is the PATH?
The PATH is a list of folders your computer searches when you type a command in the terminal. Adding Flutter to your PATH means you can run Flutter commands from any terminal window.
- Close and reopen any terminal windows you have open.
- Restart VS Code completely (close the window and reopen it).
Don't see the Download SDK prompt?
If the Command Palette skips straight to project type selection, the Flutter SDK may already be installed on your machine. Press Escape to cancel, then open a new terminal in VS Code and run:
flutter --version
If you see a version number, Flutter is ready and you can skip to creating your project. Help me troubleshoot my Flutter SDK installation.
Create and run your Flutter project
With Flutter installed, you can now create your first project. The VS Code Flutter extension provides a guided project creation flow through the Command Palette.
- Open the Command Palette by pressing Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows).
- Type Flutter: New Project and select it.
- Select Application from the list of project templates.
- Choose a folder for your project (your Desktop works well for easy access).
- Type todo_app as the project name and press Enter.
Why the underscore in the name?
Flutter project names follow Dart's naming convention: lowercase letters with underscores separating words. Spaces, hyphens, and capital letters are not allowed in project names.
VS Code opens your new project automatically. You'll see a file tree on the left with folders like lib/, web/, and pubspec.yaml.
Before running the app, update the project description and version to match your Todo app.
- In the VS Code file tree, click on pubspec.yaml to open it.
- Find the line that says:
description: "A new Flutter project."
- Replace it with:
description: "A simple todo app built with Flutter."
- Find the version line (it says version: 1.0.0+1) and replace it with:
version: 0.1.0
- Find the cupertino_icons line under dependencies and delete it (you won't need it for this project).
- Save pubspec.yaml by pressing Cmd+S (macOS) or Ctrl+S (Windows).
What is pubspec.yaml?
This file is your project's configuration file. It defines your app's name, version, and the packages it depends on. Think of it like a recipe card listing all the ingredients your app needs.
✔️ Awesome, I've got everything!
Great. Make sure you've saved pubspec.yaml before moving on.
ⓧ I'd like to double check the full code
name: todo_app
description: "A simple todo app built with Flutter."
publish_to: "none"
version: 0.1.0
environment:
sdk: ^3.9.0
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
Now it's time to run your app in Chrome.
- Look at the bottom-right corner of VS Code's status bar. You should see a device selector (it may say No Device or show a device name).
- Click the device selector and choose Chrome from the list.
- Press F5 to launch your app (or click the play button in the top toolbar).
A Chrome window opens showing the default Flutter counter app with a floating action button. Your VS Code terminal shows that the app is connected and running.
What is hot reload?
Flutter's hot reload updates your running app in under a second whenever you save a file. You don't need to stop and restart the app to see your changes.
If the app ever stops updating on save, press r in the VS Code terminal where the app is running to trigger a manual hot reload.
🙋♀️ Chrome not appearing in the device list?
Make sure Google Chrome is installed on your machine. If Chrome is installed but not showing up, restart VS Code and try again.
Help me get Chrome working as a Flutter target device.
Your development environment is ready and your first Flutter app is running. Next up, you'll replace the default code and build the shell of your Todo app.
Build the App Shell
Build the Pet Home
Your Flutter environment is ready and the default counter app is running in Chrome. Now it's time to replace that boilerplate with the foundation of your virtual pet app.
Every Flutter app starts with a root widget that defines the theme and structure for the entire application. In this step, you'll create a clean app shell with Material 3 styling and a placeholder screen where your pet will live.
In this step, get ready to:
- Replace the default counter app with your custom pet app shell.
- Confirm hot reload is working for rapid development.
Replace the default code with your app shell
Flutter apps are built by nesting widgets together. You'll start with a MaterialApp widget (which provides theming and navigation) and a Scaffold widget (which gives you an app bar and body area).
- In VS Code, open lib/main.dart from the Explorer sidebar.
- Select all the code in the file (Cmd+A on macOS or Ctrl+A on Windows) and delete it.
- Paste in this new code:
import 'package:flutter/material.dart';
// Entry point - launches the app
void main() {
runApp(const MyApp());
}
// Root widget that configures theming and sets the home screen
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Virtual Pet',
debugShowCheckedModeBanner: false,
theme: ThemeData(
// Generate a full color scheme from a single seed color
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true,
),
// Set PetScreen as the home page
home: const PetScreen(),
);
}
}
// Placeholder screen where the pet will live
class PetScreen extends StatelessWidget {
const PetScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Pet'),
),
);
}
}
What does this code do?
This file defines two widgets that form the skeleton of your app:
- MyApp is a StatelessWidget that returns a MaterialApp. It sets up Material Design 3 theming with a green color scheme and points to PetScreen as the home page.
- PetScreen returns a Scaffold, which provides the basic visual structure of a screen. For now it just has an app bar with a title.
- debugShowCheckedModeBanner: false hides the red "DEBUG" ribbon that Flutter shows by default in the top-right corner.
- Save lib/main.dart (Cmd+S on macOS or Ctrl+S on Windows).
- Check your Chrome window. You should see a green-themed app bar with the text "My Pet" and a blank white body below it.
Still seeing the counter app?
If the app didn't update after saving, press r in the VS Code terminal where the app is running to manually trigger a hot reload.
If you see red error text in Chrome, double check that you pasted the entire code block. A missing closing brace or bracket is the most common cause.
Help me fix an error in my Flutter app shell code.
✔️ Awesome, I've got everything!
Great. Make sure you've saved lib/main.dart before moving on.
ⓧ I'd like to double check the full code
Here is the complete lib/main.dart file at this point:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Virtual Pet',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true,
),
home: const PetScreen(),
);
}
}
class PetScreen extends StatelessWidget {
const PetScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Pet'),
),
);
}
}
Experience hot reload
Flutter's hot reload lets you see code changes in your running app within seconds, without restarting it. This makes building UIs feel fast and experimental.
- In lib/main.dart, find the line that says title: const Text('My Pet'), and change 'My Pet' to any other text (for example, 'Hello Flutter').
- Save the file.
- Check Chrome. The app bar text should update to your new title within a couple of seconds.
- Change the title back to 'My Pet' and save again.
Why is hot reload so useful?
Without hot reload, every change requires a full app restart. That means waiting for the app to recompile and losing any in-app state (like typed text or scroll position).
Hot reload injects your updated code into the running app and rebuilds the widget tree. You keep your state and see results instantly.
🙋♀️ App not updating when you save?
Press r in the VS Code terminal where Flutter is running. This manually triggers a hot reload.
If that doesn't work, press R (capital) to perform a full hot restart. This rebuilds the app from scratch.
Help me get hot reload working in my Flutter project.
Your app shell is in place and hot reload is confirmed working. Next up, you'll create your pet's data model and give it stats like hunger, happiness, and energy.
Create the Todo List
Create Your Virtual Pet
Your app shell is up and running with a themed AppBar. Now it needs a pet to live inside it.
In this step, you will define a data model for your pet and build a UI that displays its stats. This introduces two core Flutter patterns: StatefulWidget for managing data that changes over time, and widget composition for laying out stat bars and pet info.
In this step, get ready to:
- Define a Pet data model with name, hunger, happiness, and energy stats.
- Convert PetScreen into a StatefulWidget that holds your pet's state.
- Build a UI with stat bars and a pet display area.
Define the Pet model and state
Before you can display a pet, you need a way to represent it in code. A simple class with a name, hunger, happiness, and energy field gives your pet a clear structure.
- In lib/main.dart, find the closing brace of the MyApp class. Add the following Pet class directly below it:
class Pet {
String name;
int hunger;
int happiness;
int energy;
Pet({
required this.name,
this.hunger = 50,
this.happiness = 50,
this.energy = 50,
});
}
What does this code do?
- name stores the pet's name (like "Sparky").
- hunger, happiness, and energy are integer stats that range from 0 to 100. Each defaults to 50 so every new pet starts at a balanced midpoint.
- The required keyword means you must provide a name when creating a Pet. The stat parameters are optional because they have defaults.
Now you need to convert PetScreen from a StatelessWidget to a StatefulWidget. A StatelessWidget cannot hold data that changes. Since your pet's stats will change as users interact with it, you need a StatefulWidget that can trigger UI rebuilds when the data changes.
- Delete the entire existing PetScreen class (from class PetScreen extends StatelessWidget down to its closing }).
- Replace it with the following StatefulWidget and its companion State class:
class PetScreen extends StatefulWidget {
const PetScreen({super.key});
@override
State<PetScreen> createState() => _PetScreenState();
}
class _PetScreenState extends State<PetScreen> {
// Create a pet with a default name
final Pet _pet = Pet(name: 'Sparky');
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Pet'),
),
);
}
}
What is a StatefulWidget?
A StatefulWidget has two parts: the widget itself (immutable) and a companion State object (mutable). The State object persists across rebuilds and holds your data.
- PetScreen is the widget shell. It creates the State object via createState().
- _PetScreenState holds the mutable _pet object and the build method. When you call setState() later, Flutter reruns build to refresh the UI.
- The underscore prefix (_) makes both the State class and the pet variable private to this file.
- Save lib/main.dart.
- Check Chrome. The app should still show the green-themed AppBar with "My Pet" and an empty body. The conversion preserved existing behaviour.
Seeing a red error screen in Chrome?
- Make sure the Pet class is placed between the closing } of MyApp and the start of PetScreen. Order matters in Dart.
- Check that _PetScreenState has the @override annotation above both createState() and build().
- Confirm you deleted the old StatelessWidget version of PetScreen completely. Having two classes with the same name causes a compile error.
Why is my Flutter app showing a red error screen after converting to StatefulWidget?
Build the pet stats UI
With the state in place, you can build the pet display. The screen needs to show the pet's name, an emoji representing it, and three stat bars for hunger, happiness, and energy.
- In _PetScreenState, replace the entire build method with the following code:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_pet.name),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
// Pet display area
const Text(
'🐱',
style: TextStyle(fontSize: 80),
),
const SizedBox(height: 16),
Text(
_pet.name,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
// Stat bars
_buildStatBar('Hunger', _pet.hunger, Colors.orange),
const SizedBox(height: 12),
_buildStatBar('Happiness', _pet.happiness, Colors.pink),
const SizedBox(height: 12),
_buildStatBar('Energy', _pet.energy, Colors.blue),
],
),
),
);
}
Widget _buildStatBar(String label, int value, Color color) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$label: $value/100'),
const SizedBox(height: 4),
LinearProgressIndicator(
value: value / 100,
backgroundColor: color.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 12,
),
],
);
}
What does this build method do?
- The body uses a Column to stack the pet emoji, name, and stat bars vertically.
- Each stat bar is built by a helper method _buildStatBar that takes a label, value, and color.
- LinearProgressIndicator renders a horizontal bar showing the stat's current value (0–100) as a filled percentage.
- value / 100 converts the integer stat to a 0.0–1.0 range that the progress indicator expects.
- Save lib/main.dart.
- Check Chrome. You should see a cat emoji, the name "Sparky", and three colored stat bars all showing 50/100.
Don't see the stat bars?
- Make sure you replaced the entire build method, including its @override annotation. Do not nest one build method inside another.
- Check that the _buildStatBar helper method is inside _PetScreenState but outside the build method. It should be a sibling method, not nested inside build.
- Press r in the VS Code terminal if the app did not hot reload automatically.
Why aren't my pet stat bars showing in my Flutter app?
Test with different stat values
The default stats all show 50/100. Try changing one to confirm the bars respond to different values.
- In _PetScreenState, find this line:
final Pet _pet = Pet(name: 'Sparky');
- Change it to give Sparky different starting stats:
final Pet _pet = Pet(name: 'Sparky', hunger: 80, happiness: 30, energy: 60);
- Save lib/main.dart.
- Check Chrome. The three stat bars should now show different fill levels: hunger at 80%, happiness at 30%, and energy at 60%.
Why test with different values?
Temporarily hardcoding different stat values lets you visually confirm the bars render at different fill levels. You'll reset them to 50 in the next step when you wire up the action buttons.
✔️ Awesome, I've got everything!
Great. Double check you have saved lib/main.dart and your Chrome window shows Sparky with three stat bars at different fill levels.
ⓧ I'd like to double check the full code
Here is the complete lib/main.dart at this point:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Virtual Pet',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
useMaterial3: true,
),
home: const PetScreen(),
);
}
}
class Pet {
String name;
int hunger;
int happiness;
int energy;
Pet({
required this.name,
this.hunger = 50,
this.happiness = 50,
this.energy = 50,
});
}
class PetScreen extends StatefulWidget {
const PetScreen({super.key});
@override
State<PetScreen> createState() => _PetScreenState();
}
class _PetScreenState extends State<PetScreen> {
final Pet _pet = Pet(name: 'Sparky', hunger: 80, happiness: 30, energy: 60);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_pet.name),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const Text(
'🐱',
style: TextStyle(fontSize: 80),
),
const SizedBox(height: 16),
Text(
_pet.name,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
_buildStatBar('Hunger', _pet.hunger, Colors.orange),
const SizedBox(height: 12),
_buildStatBar('Happiness', _pet.happiness, Colors.pink),
const SizedBox(height: 12),
_buildStatBar('Energy', _pet.energy, Colors.blue),
],
),
),
);
}
Widget _buildStatBar(String label, int value, Color color) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$label: $value/100'),
const SizedBox(height: 4),
LinearProgressIndicator(
value: value / 100,
backgroundColor: color.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 12,
),
],
);
}
}
Your pet is on screen with visible stats. Next up, you'll add action buttons so users can feed, play with, and rest their pet to change those stats in real time.
Add and Complete Todos
Feed, Play, and Rest Your Pet
Your todo list displays items nicely, but right now those items are hardcoded. A real todo app needs to let users type in their own tasks and mark them as done.
In this step, you'll add a text input field where users can type new todos, a button to add them, and checkboxes that visually strike through completed items.
In this step, get ready to:
- Add a text input field and button for creating new todos.
- Implement add functionality that saves typed todos to the list.
- Implement toggle functionality that strikes through completed todos.
Add the input field
The hardcoded sample todos helped you verify the list renders correctly. Now it's time to remove them and let real user input drive the list.
Flutter uses a TextEditingController to read what a user types into a text field. You'll also need to clean up the controller when the screen is removed from memory, using a dispose method.
- In lib/main.dart, find this line inside _TodoScreenState:
final List<Todo> _todos = [
Todo(title: 'Buy groceries'),
Todo(title: 'Walk the dog'),
Todo(title: 'Read a book'),
];
- Replace it with an empty list, so the line now looks like this:
final List<Todo> _todos = [];
- Directly below the _todos line, add the controller field and the dispose method by copying this code:
final TextEditingController _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
What does this code do?
- TextEditingController tracks the text a user types into a TextField. You can read the current value with _controller.text.
- The dispose() method releases the controller's memory when the screen is removed. Calling super.dispose() lets the parent class clean up too.
- Save lib/main.dart.
Since _todos is now empty, your app should hot reload and display the "No todos yet" message.
Still seeing the old sample todos?
Press r in the VS Code terminal where the app is running to trigger a hot reload manually. If the old items persist, try a full restart by pressing R (capital R) instead.
Help me debug why my Flutter app isn't hot reloading
Implement add functionality
With the controller in place, you need a method that reads the typed text, creates a new Todo from it, and adds it to the list. Flutter's setState tells the framework to rebuild the UI whenever data changes.
- Below the dispose() method and above the build method, add the _addTodo method by copying this code:
void _addTodo() {
String text = _controller.text.trim();
if (text.isEmpty) return;
setState(() {
_todos.add(Todo(title: text));
});
_controller.clear();
}
What does this code do?
- _controller.text.trim() reads whatever the user typed and removes extra whitespace from both ends.
- if (text.isEmpty) return prevents adding blank todos. The method exits early if there's nothing to add.
- setState() wraps the list modification so Flutter knows to rebuild the UI with the new item.
- _controller.clear() empties the text field after adding, so it's ready for the next todo.
- Save lib/main.dart.
You won't see any visible change yet. The method exists but nothing calls it until we update the build method in the next substep.
Implement toggle functionality and update the build method
The final piece is a method that flips a todo between complete and incomplete. Once both methods are ready, you'll replace the build method with one that includes the input field, the add button, and the toggle styling.
- Below the _addTodo method and above the build method, add the _toggleTodo method by copying this code:
void _toggleTodo(int index) {
setState(() {
_todos[index].isCompleted = !_todos[index].isCompleted;
});
}
What does this code do?
This method takes the position (index) of the tapped todo and flips its isCompleted value. If it was false, it becomes true, and vice versa. Wrapping it in setState triggers a UI rebuild so the checkbox and text style update instantly.
Now that both methods exist, you can replace the entire build method with a version that wires everything together.
- Find the existing @override and Widget build(BuildContext context) method in _TodoScreenState.
- Delete the entire build method (from @override down to its closing }) and replace it with this code:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Todos'),
),
body: Column(
children: [
// Input area with text field and add button
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Add a new todo...',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addTodo(),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _addTodo,
child: const Text('Add'),
),
],
),
),
// Todo list or empty state message
Expanded(
child: _todos.isEmpty
? const Center(
child: Text(
'No todos yet. Add one above!',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
)
: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => _toggleTodo(index),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
color: todo.isCompleted ? Colors.grey : null,
),
),
);
},
),
),
],
),
);
}
What changed in the build method?
- The body is now a Column with two children: the input area on top and the list below.
- The TextField uses _controller to track input, and onSubmitted calls _addTodo() when the user presses Enter.
- The FilledButton also calls _addTodo when pressed, giving users two ways to add items.
- Each Checkbox's onChanged now calls _toggleTodo(index) to flip the completed state.
- The todo title's TextStyle applies TextDecoration.lineThrough and a grey color when the todo is completed.
- Save lib/main.dart.
- Switch to Chrome. You should see a text field with an Add button at the top and the "No todos yet" message below.
- Type Buy groceries into the text field and press Enter.
- The todo should appear in the list below the input field.
- Tap the checkbox next to your todo. The text should strike through and turn grey.
Input field not showing or Add button not working?
- If you see a red error screen in Chrome, check that both _addTodo and _toggleTodo methods are defined inside _TodoScreenState before the build method references them.
- Make sure the body uses Column (not Expanded directly). The Expanded widget must be a child of Column.
- If the Add button does nothing, confirm that onPressed: _addTodo has no parentheses after _addTodo. Parentheses would call it immediately instead of passing it as a callback.
Help me fix my Flutter build method error
✔️ Awesome, I've got everything!
Great. Double check you've saved lib/main.dart and confirmed that adding and toggling todos works in Chrome.
ⓧ I'd like to double check the full code
Here's the complete lib/main.dart at this point:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Todo App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const TodoScreen(),
);
}
}
class Todo {
String title;
bool isCompleted;
Todo({required this.title, this.isCompleted = false});
}
class TodoScreen extends StatefulWidget {
const TodoScreen({super.key});
@override
State<TodoScreen> createState() => _TodoScreenState();
}
class _TodoScreenState extends State<TodoScreen> {
final List<Todo> _todos = [];
final TextEditingController _controller = TextEditingController();
void _addTodo() {
String text = _controller.text.trim();
if (text.isEmpty) return;
setState(() {
_todos.add(Todo(title: text));
});
_controller.clear();
}
void _toggleTodo(int index) {
setState(() {
_todos[index].isCompleted = !_todos[index].isCompleted;
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Todos'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Add a new todo...',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addTodo(),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _addTodo,
child: const Text('Add'),
),
],
),
),
Expanded(
child: _todos.isEmpty
? const Center(
child: Text(
'No todos yet. Add one above!',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
)
: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => _toggleTodo(index),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
color: todo.isCompleted ? Colors.grey : null,
),
),
);
},
),
),
],
),
);
}
}
Your app now handles adding and completing todos. Next up, you'll add the ability to delete todos with a swipe gesture and polish the overall appearance.
Delete Todos and Polish the UI
Adopt Exotic Pets and Polish the UI
Your todo app can add and complete items. That covers two of the three core CRUD operations (Create and Update).
In this step, you will add the final piece: deleting todos by swiping them away. You will also style the app bar to match your color scheme, giving the app a polished, finished look.
In this step, get ready to:
- Add swipe-to-delete with the Dismissible widget.
- Style the app bar with your Material 3 color scheme.
Add swipe-to-delete
Flutter provides a widget called Dismissible that detects horizontal swipe gestures and animates an item off-screen. You will wrap each todo's ListTile in a Dismissible to enable swipe-to-delete.
- In lib/main.dart, find the _toggleTodo method. Add the following _deleteTodo method directly below it:
void _deleteTodo(int index) {
setState(() {
_todos.removeAt(index);
});
}
- Now scroll down to the itemBuilder inside ListView.builder. Find the line that says:
return ListTile(
- Replace the entire return ListTile(...); statement (from return ListTile( down to its closing );) with this code that wraps the ListTile in a Dismissible:
return Dismissible(
// UniqueKey ensures Flutter tracks each item independently
key: UniqueKey(),
// Called after the swipe animation finishes
onDismissed: (_) => _deleteTodo(index),
// Red background revealed during the swipe
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
),
child: ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => _toggleTodo(index),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
color: todo.isCompleted ? Colors.grey : null,
),
),
),
);
What does this code do?
The Dismissible widget adds swipe-to-dismiss behavior to any child widget.
- key: UniqueKey() gives each item a unique identity so Flutter knows which one was swiped.
- onDismissed fires after the item is swiped off-screen. It calls _deleteTodo to remove the todo from your list.
- background defines what appears behind the item as it slides away. Here it is a red container with a trash icon aligned to the right.
- Save lib/main.dart.
- Switch to Chrome. Add a couple of todos, then swipe one to the left. You should see a red background with a white trash icon appear as the item slides away, and then the item disappears from the list.
Swipe not working?
- Make sure the entire return ListTile(...); block was replaced, not just the first line. The ListTile should now be nested inside child: of Dismissible.
- Check that the closing parentheses and brackets are balanced. The Dismissible has its own closing ); that replaces the old ListTile's closing.
- If the app crashed, press r in the terminal to trigger a hot reload and check the error message.
Why can't I swipe to delete my todos?
Style the app bar
The app bar currently uses the default white background. You will update it to use your Material 3 color scheme so it matches the rest of the app's theme.
- In lib/main.dart, scroll to the build method of _TodoScreenState.
- Find the AppBar section:
appBar: AppBar(
title: const Text('My Todos'),
),
- Add a backgroundColor property after the title line, so the AppBar looks like this:
appBar: AppBar(
title: const Text('My Todos'),
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
),
What is primaryContainer?
When you set up ColorScheme.fromSeed(seedColor: Colors.blue) earlier, Flutter generated a full palette of coordinated colors from that single seed. primaryContainer is a softer, lighter variant of your primary color that works well for large surfaces like app bars.
- Save lib/main.dart and check Chrome.
- Your app bar should now have a soft blue-tinted background instead of plain white.
App bar still white?
- Make sure backgroundColor is inside the AppBar() parentheses, not outside them.
- Confirm that useMaterial3: true is still set in your ThemeData at the top of the file.
Why isn't my AppBar picking up the theme color?
Your app now handles all three core interactions: adding, completing, and deleting todos. The themed app bar ties the visual design together.
✔️ Awesome, I've got everything!
Great. Double check you have saved lib/main.dart before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Todo App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const TodoScreen(),
);
}
}
class Todo {
String title;
bool isCompleted;
Todo({required this.title, this.isCompleted = false});
}
class TodoScreen extends StatefulWidget {
const TodoScreen({super.key});
@override
State<TodoScreen> createState() => _TodoScreenState();
}
class _TodoScreenState extends State<TodoScreen> {
final List<Todo> _todos = [];
final TextEditingController _controller = TextEditingController();
void _addTodo() {
String text = _controller.text.trim();
if (text.isEmpty) return;
setState(() {
_todos.add(Todo(title: text));
});
_controller.clear();
}
void _toggleTodo(int index) {
setState(() {
_todos[index].isCompleted = !_todos[index].isCompleted;
});
}
void _deleteTodo(int index) {
setState(() {
_todos.removeAt(index);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Todos'),
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Add a new todo...',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addTodo(),
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: _addTodo,
child: const Text('Add'),
),
],
),
),
Expanded(
child: _todos.isEmpty
? const Center(
child: Text(
'No todos yet. Add one above!',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
)
: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return Dismissible(
key: UniqueKey(),
onDismissed: (_) => _deleteTodo(index),
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.white),
),
child: ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => _toggleTodo(index),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
color: todo.isCompleted ? Colors.grey : null,
),
),
),
);
},
),
),
],
),
);
}
}
Your todo app is fully functional. You can add, complete, and delete todos with smooth animations and a cohesive design. Next up, you will tackle the Secret Mission and add filtering to sort your todos by status.
Secret mission
Add Pet Evolution
Your pet is happy and well-fed, but what if it could grow and change over time? What if reaching certain stat thresholds unlocked a new life stage? Build an evolution system using Flutter's SegmentedButton widget and enum-based state to track your pet through Baby, Teen, and Adult stages.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using free tools. There are no cloud resources or ongoing costs to worry about.
Decide whether to keep your resources for future development or delete them entirely.
Resources you used:
- The todo_app project folder (local files).
- Flutter SDK (installed via VS Code).
Keep everything running
No action needed. Choose this if you plan to keep building with Flutter or want to revisit this project later.
- Your Flutter SDK and todo_app project remain on your machine for future development.
- You can reopen the project in VS Code at any time and run it with F5.
Pause - I'll come back to this later
Stop the running app to free up browser and system resources. Your files stay on disk.
- In the VS Code terminal, press Ctrl+C to stop the Flutter development server.
- Close the Chrome tab that was running your app.
- Your project files and Flutter SDK stay installed. Reopen VS Code and press F5 to pick up where you left off.
Delete - I don't want to use this again
Remove all project resources and the Flutter SDK from your machine.
- Stop the running app by pressing Ctrl+C in the VS Code terminal.
- Delete the todo_app project folder by running the appropriate command for your OS:
macOS
rm -rf ~/Desktop/todo_app
Windows
Remove-Item -Recurse -Force -Path "$HOME\Desktop\todo_app"
- To uninstall the Flutter SDK entirely, follow the official uninstall guide at docs.flutter.dev/install/uninstall.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional virtual pet app with Flutter, running right in your browser.
You've learned how to:
- Built a complete Flutter app from scratch using widget composition to nest widgets into a Tamagotchi-style UI.
- Managed interactive state with StatefulWidget and setState, powering pet stats, action buttons, and real-time UI updates.
- Built an adopt screen with ListView.builder for choosing exotic pets like a Cat, Dog, or Dragon, each with unique starting stats.
- Secret Mission: Added pet evolution stages using enums and the SegmentedButton widget to track Baby, Teen, and Adult forms.
Ready to quiz yourself?