Flutter makes it possible to go from zero to a polished mobile app in a single afternoon. This tutorial walks you through building two complete apps — a counter and a todo list — while teaching you the core concepts of widgets, state, and layout. By the end, you will have a solid foundation for building real applications.
Setting Up Your Environment
Before writing any code, you need Flutter installed. Run these commands to get started:
# Download and install Flutter (Linux/macOS)
git clone https://github.com/flutter/flutter.git -b stable
export PATH="$PATH:$(pwd)/flutter/bin"
# Verify installation
flutter doctor
# Create a new project
flutter create my_first_app
cd my_first_app
flutter run
The flutter doctor command checks your environment and tells you if anything is missing — Android SDK, Xcode, Chrome, or connected devices. Fix any issues it reports before continuing.
Understanding Widgets
Everything in Flutter is a widget. Text, buttons, padding, rows, columns — all widgets. They come in two flavours: StatelessWidget for static UI and StatefulWidget for UI that changes over time. Let us start with a minimal app that displays a greeting:
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(
home: Scaffold(
appBar: AppBar(title: const Text('My First App')),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.flutter_dash, size: 80, color: Colors.blue),
SizedBox(height: 16),
Text(
'Welcome to Flutter!',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text('Build beautiful apps with Dart'),
],
),
),
),
);
}
}
The Column widget stacks children vertically, Center positions its child in the middle, and SizedBox adds spacing. This compositional model is what makes Flutter so expressive.
Building the Counter App
Now let us add interactivity. The counter app introduces StatefulWidget and setState:
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _count = 0;
void _increment() => setState(() => _count++);
void _decrement() => setState(() {
if (_count > 0) _count--;
});
void _reset() => setState(() => _count = 0);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Counter'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _reset,
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Current Count:', style: TextStyle(fontSize: 18)),
const SizedBox(height: 8),
Text(
'$_count',
style: Theme.of(context).textTheme.displayLarge?.copyWith(
fontWeight: FontWeight.bold,
color: _count > 10 ? Colors.red : Colors.indigo,
),
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton.tonalIcon(
onPressed: _decrement,
icon: const Icon(Icons.remove),
label: const Text('Decrease'),
),
const SizedBox(width: 16),
FilledButton.icon(
onPressed: _increment,
icon: const Icon(Icons.add),
label: const Text('Increase'),
),
],
),
],
),
),
);
}
}
When you call setState, Flutter re-runs the build method and efficiently updates only the parts of the UI that changed. Notice the conditional colour change when the count exceeds 10 — this shows how you can make the UI reactive to state.
Building a Todo List
A todo app is the classic project for learning CRUD operations. Let us build one with add, toggle, and delete functionality:
class Todo {
final String id;
final String title;
bool isDone;
Todo({required this.title, this.isDone = false})
: id = DateTime.now().millisecondsSinceEpoch.toString();
}
class TodoPage extends StatefulWidget {
const TodoPage({super.key});
@override
State<TodoPage> createState() => _TodoPageState();
}
class _TodoPageState extends State<TodoPage> {
final List<Todo> _todos = [];
final TextEditingController _controller = TextEditingController();
void _addTodo() {
final text = _controller.text.trim();
if (text.isEmpty) return;
setState(() {
_todos.insert(0, Todo(title: text));
_controller.clear();
});
}
void _toggleTodo(int index) {
setState(() => _todos[index].isDone = !_todos[index].isDone);
}
void _deleteTodo(int index) {
setState(() => _todos.removeAt(index));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final pending = _todos.where((t) => !t.isDone).length;
return Scaffold(
appBar: AppBar(
title: Text('Todos ($pending pending)'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'What needs to be done?',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addTodo(),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: _addTodo,
child: const Text('Add'),
),
],
),
),
Expanded(
child: _todos.isEmpty
? const Center(child: Text('No todos yet. Add one above!'))
: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return Dismissible(
key: ValueKey(todo.id),
onDismissed: (_) => _deleteTodo(index),
background: Container(
color: Colors.red.shade100,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.delete, color: Colors.red),
),
child: ListTile(
leading: Checkbox(
value: todo.isDone,
onChanged: (_) => _toggleTodo(index),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isDone
? TextDecoration.lineThrough
: null,
color: todo.isDone ? Colors.grey : null,
),
),
),
);
},
),
),
],
),
);
}
}
This introduces several important concepts: TextEditingController for managing text input, ListView.builder for efficient scrollable lists, Dismissible for swipe-to-delete, and proper dispose to prevent memory leaks.
Introduction to State Management
For small apps, setState works fine. But as your app grows, you need a way to share state across multiple widgets. The simplest solution is Provider. Here is how you would refactor the counter to use it:
// Add to pubspec.yaml: provider: ^6.1.0
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
void decrement() {
if (_count > 0) {
_count--;
notifyListeners();
}
}
}
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: const MaterialApp(home: ProviderCounterPage()),
),
);
}
class ProviderCounterPage extends StatelessWidget {
const ProviderCounterPage({super.key});
@override
Widget build(BuildContext context) {
final counter = context.watch<CounterModel>();
return Scaffold(
appBar: AppBar(title: const Text('Provider Counter')),
body: Center(
child: Text(
'${counter.count}',
style: Theme.of(context).textTheme.displayLarge,
),
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
heroTag: 'inc',
onPressed: counter.increment,
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
FloatingActionButton(
heroTag: 'dec',
onPressed: counter.decrement,
child: const Icon(Icons.remove),
),
],
),
);
}
}
The key benefit: CounterModel is now completely separate from the UI. You can test it independently, and any widget in the tree can access the counter without passing it through constructor parameters.
Conclusion
You have now built a counter and a todo list, and you have seen how to manage state with both setState and Provider. The next steps are to explore navigation (routing between screens), HTTP requests for fetching data from APIs, and local storage with packages like shared_preferences or hive. Flutter’s documentation at docs.flutter.dev is excellent, and the widget catalog is your best friend when you need to find the right component. Start building, break things, and learn by doing.