Introduction to OOP & Classes in Dart
What Is Object-Oriented Programming?
Object-Oriented Programming (OOP) is a programming paradigm that organizes code around objects rather than functions and logic. An object is a bundle of related data (called properties or fields) and behavior (called methods). Think of it like the real world: a car has properties (color, speed, fuel level) and behaviors (accelerate, brake, turn). OOP lets you model your code the same way.
Dart is a fully object-oriented language -- in fact, everything in Dart is an object. Even numbers, strings, and booleans are objects with methods. When you write 42.toString() or 'hello'.toUpperCase(), you are calling methods on objects.
Why OOP Matters for Flutter
Flutter is built entirely on OOP concepts. Every widget you create is a class. Every screen is an object. Understanding OOP is not optional for Flutter -- it is the foundation everything is built upon. Here is a quick preview:
Flutter is OOP
// Every Flutter widget is a class
class MyApp extends StatelessWidget { // Inheritance
const MyApp({super.key}); // Constructor
@override // Polymorphism
Widget build(BuildContext context) { // Method
return MaterialApp(home: HomeScreen());
}
}
The Four Pillars of OOP
OOP is built on four fundamental principles:
- Encapsulation -- Bundling data and methods together, and controlling access to internal details.
- Abstraction -- Hiding complex implementation details and showing only what is necessary.
- Inheritance -- Creating new classes based on existing ones, reusing code.
- Polymorphism -- Objects of different classes responding to the same method in different ways.
We will explore each of these throughout this tutorial. For now, let’s start with the most important building block: classes.
Creating Your First Class
A class is a blueprint for creating objects. It defines what properties an object has and what it can do. An object (also called an instance) is a concrete thing created from that blueprint.
Defining a Simple Class
// The class is the blueprint
class Person {
// Properties (data)
String name = '';
int age = 0;
// Method (behavior)
void introduce() {
print('Hi, I am $name and I am $age years old.');
}
}
void main() {
// Create an object (instance) from the blueprint
Person person1 = Person();
person1.name = 'Ahmed';
person1.age = 25;
person1.introduce(); // Hi, I am Ahmed and I am 25 years old.
// Create another object from the same blueprint
Person person2 = Person();
person2.name = 'Sara';
person2.age = 30;
person2.introduce(); // Hi, I am Sara and I am 30 years old.
// Each object is independent
print(person1.name); // Ahmed (unchanged)
}
Constructors
Setting properties one by one after creating an object is tedious and error-prone. A constructor is a special method that runs automatically when you create an object, letting you initialize properties right away.
Default Constructor
class Person {
String name;
int age;
// Constructor -- same name as the class
Person(this.name, this.age);
// 'this.name' is Dart shorthand for: this.name = name
void introduce() {
print('Hi, I am $name and I am $age years old.');
}
}
void main() {
// Now we pass values directly when creating the object
Person person = Person('Ahmed', 25);
person.introduce(); // Hi, I am Ahmed and I am 25 years old.
// Much cleaner than setting each property manually!
Person person2 = Person('Sara', 30);
person2.introduce(); // Hi, I am Sara and I am 30 years old.
}
Named Parameters in Constructors
For classes with many properties, named parameters make the code more readable. Wrap them in curly braces {}:
Named Parameters
class User {
String username;
String email;
int age;
bool isActive;
// Named parameters with required and default values
User({
required this.username,
required this.email,
required this.age,
this.isActive = true, // Default value
});
void displayInfo() {
print('User: $username ($email), Age: $age, Active: $isActive');
}
}
void main() {
User user1 = User(
username: 'ahmed_dev',
email: 'ahmed@example.com',
age: 25,
);
user1.displayInfo();
// User: ahmed_dev (ahmed@example.com), Age: 25, Active: true
User user2 = User(
username: 'sara_code',
email: 'sara@example.com',
age: 30,
isActive: false, // Override default
);
user2.displayInfo();
// User: sara_code (sara@example.com), Age: 30, Active: false
}
required. This pattern will become second nature as you build Flutter apps. For example: Text('Hello', style: TextStyle(fontSize: 20)).Properties and Methods
Properties hold the object’s data. Methods define the object’s behavior. Together, they form a complete object.
Properties and Methods in Action
class BankAccount {
String owner;
double _balance; // Underscore makes it "private"
BankAccount({required this.owner, double initialBalance = 0})
: _balance = initialBalance;
// Getter -- read-only access to private data
double get balance => _balance;
// Method: deposit money
void deposit(double amount) {
if (amount <= 0) {
print('Error: Deposit amount must be positive.');
return;
}
_balance += amount;
print('Deposited \$${amount.toStringAsFixed(2)}. New balance: \$${_balance.toStringAsFixed(2)}');
}
// Method: withdraw money
bool withdraw(double amount) {
if (amount <= 0) {
print('Error: Withdrawal amount must be positive.');
return false;
}
if (amount > _balance) {
print('Error: Insufficient funds.');
return false;
}
_balance -= amount;
print('Withdrew \$${amount.toStringAsFixed(2)}. New balance: \$${_balance.toStringAsFixed(2)}');
return true;
}
// Method: display account info
void displayInfo() {
print('Account Owner: $owner, Balance: \$${_balance.toStringAsFixed(2)}');
}
}
void main() {
BankAccount account = BankAccount(owner: 'Ahmed', initialBalance: 1000);
account.displayInfo(); // Account Owner: Ahmed, Balance: $1000.00
account.deposit(500); // Deposited $500.00. New balance: $1500.00
account.withdraw(200); // Withdrew $200.00. New balance: $1300.00
account.withdraw(2000); // Error: Insufficient funds.
print(account.balance); // 1300.0 (using the getter)
// account._balance = 999; // ERROR in other files! (private)
}
_) makes a property or method library-private, not class-private. This means it is accessible within the same file but hidden from other files. This is Dart’s approach to encapsulation. We will cover this in more detail in a later lesson.The this Keyword
The this keyword refers to the current instance of the class. It is useful when parameter names match property names, or when you need to return the current object:
Using this
class Rectangle {
double width;
double height;
// 'this' in constructor shorthand
Rectangle(this.width, this.height);
double get area => width * height;
double get perimeter => 2 * (width + height);
// Method chaining using 'this'
Rectangle scale(double factor) {
width *= factor;
height *= factor;
return this; // Return the same object for chaining
}
void display() {
print('Rectangle: ${width}x$height, Area: $area, Perimeter: $perimeter');
}
}
void main() {
Rectangle rect = Rectangle(10, 5);
rect.display(); // Rectangle: 10.0x5.0, Area: 50.0, Perimeter: 30.0
// Method chaining
rect.scale(2).display(); // Rectangle: 20.0x10.0, Area: 200.0, Perimeter: 60.0
}
Multiple Objects from One Class
Each object created from a class is completely independent. Changing one object does not affect another:
Independent Objects
class Counter {
String label;
int _count = 0;
Counter(this.label);
int get count => _count;
void increment() => _count++;
void decrement() => _count--;
void reset() => _count = 0;
@override
String toString() => '$label: $_count';
}
void main() {
Counter likes = Counter('Likes');
Counter views = Counter('Views');
likes.increment();
likes.increment();
likes.increment();
views.increment();
views.increment();
views.increment();
views.increment();
views.increment();
print(likes); // Likes: 3
print(views); // Views: 5
// They are completely independent
likes.reset();
print(likes); // Likes: 0
print(views); // Views: 5 (unchanged)
}
The toString Method
Every class in Dart inherits from Object, which has a toString() method. By default, it returns something like Instance of 'ClassName'. Override it to provide a meaningful string representation:
Overriding toString
class Product {
String name;
double price;
int quantity;
Product({required this.name, required this.price, this.quantity = 0});
double get totalValue => price * quantity;
@override
String toString() {
return 'Product($name, \$${price.toStringAsFixed(2)}, qty: $quantity)';
}
}
void main() {
Product laptop = Product(name: 'Laptop', price: 999.99, quantity: 5);
// Without toString override: Instance of 'Product'
// With toString override:
print(laptop); // Product(Laptop, $999.99, qty: 5)
// Also works in string interpolation
print('Item: $laptop, Total: \$${laptop.totalValue}');
// Item: Product(Laptop, $999.99, qty: 5), Total: $4999.95
}
Class vs Object: The Key Distinction
• A class is a blueprint/template -- it defines the structure.
• An object (or instance) is a concrete thing created from the class -- it holds actual data.
• You can create many objects from one class.
• Each object has its own copy of the properties.
• All objects share the same methods (the code), but each runs methods on its own data.
Practical Example: Building a Task Manager
Task Manager with Classes
class Task {
String title;
String description;
bool isCompleted;
DateTime createdAt;
Task({
required this.title,
this.description = '',
this.isCompleted = false,
}) : createdAt = DateTime.now();
void complete() {
isCompleted = true;
print('Task "$title" marked as completed.');
}
void reopen() {
isCompleted = false;
print('Task "$title" reopened.');
}
@override
String toString() {
String status = isCompleted ? '[DONE]' : '[TODO]';
return '$status $title';
}
}
class TaskManager {
String projectName;
List<Task> _tasks = [];
TaskManager(this.projectName);
// Add a new task
void addTask(String title, {String description = ''}) {
_tasks.add(Task(title: title, description: description));
print('Added: "$title"');
}
// Get pending tasks
List<Task> get pendingTasks =>
_tasks.where((t) => !t.isCompleted).toList();
// Get completed tasks
List<Task> get completedTasks =>
_tasks.where((t) => t.isCompleted).toList();
// Complete a task by index
void completeTask(int index) {
if (index >= 0 && index < _tasks.length) {
_tasks[index].complete();
}
}
// Display summary
void showSummary() {
print('\n--- $projectName ---');
print('Total: ${_tasks.length} | Pending: ${pendingTasks.length} | Done: ${completedTasks.length}');
for (var task in _tasks) {
print(' $task');
}
}
}
void main() {
TaskManager manager = TaskManager('Flutter App');
manager.addTask('Set up project structure');
manager.addTask('Create login screen');
manager.addTask('Add API integration');
manager.addTask('Write unit tests');
manager.completeTask(0);
manager.completeTask(1);
manager.showSummary();
// --- Flutter App ---
// Total: 4 | Pending: 2 | Done: 2
// [DONE] Set up project structure
// [DONE] Create login screen
// [TODO] Add API integration
// [TODO] Write unit tests
}
Practice Exercise
Open DartPad and build the following: (1) Create a Student class with properties: name, studentId, and a private list of _grades (List<double>). (2) Add a constructor with required named parameters for name and studentId. (3) Add an addGrade(double grade) method that only accepts grades between 0 and 100. (4) Add a getter averageGrade that calculates and returns the average. (5) Add a getter letterGrade that returns A/B/C/D/F based on the average. (6) Override toString() to show the student’s info. (7) Create 3 Student objects, add grades to each, and print their info.