Dart Object Oriented For Beginner : Expense Manager Case Study Part 9
Lesson 9: Building the Complete Expense Manager App
Duration: 60 minutes
App Feature: 🎉 Putting It All Together
What You’ll Build: A full-featured expense tracking system
Prerequisites: Complete Lessons 1-8
What We’ll Learn Today
By the end of this lesson, you’ll be able to:
- ✅ Combine all OOP concepts into one application
- ✅ Organize code into multiple files
- ✅ Create a command-line interface
- ✅ Handle user input and validation
- ✅ Implement CRUD operations (Create, Read, Update, Delete)
- ✅ Save and load data
- ✅ Structure a real-world Dart application
- ✅ Prepare code for Flutter integration
Part 1: Application Architecture
Before we start coding, let’s plan our application structure:
expense_manager/
├── lib/
│ ├── models/
│ │ ├── expense.dart
│ │ ├── recurring_expense.dart
│ │ ├── one_time_expense.dart
│ │ └── payment_method.dart
│ │
│ ├── managers/
│ │ └── expense_manager.dart
│ │
│ ├── utils/
│ │ ├── input_helper.dart
│ │ └── display_helper.dart
│ │
│ └── app.dart
│
└── bin/
└── main.dart
Why this structure?
- models/ – Data classes (Expense, PaymentMethod, etc.)
- managers/ – Business logic (ExpenseManager)
- utils/ – Helper functions (input, display)
- app.dart – Main application logic
- main.dart – Entry point
Part 2: Complete Models
expense.dart
class Expense {
static int _idCounter = 0;
final int id;
String description;
double amount;
String category;
DateTime date;
String? notes;
bool isPaid;
String? paymentMethodName;
Expense({
required this.description,
required this.amount,
required this.category,
DateTime? date,
this.notes,
this.isPaid = false,
this.paymentMethodName,
}) : id = ++_idCounter,
date = date ?? DateTime.now() {
if (amount < 0) {
throw Exception('Amount cannot be negative');
}
if (description.trim().isEmpty) {
throw Exception('Description cannot be empty');
}
}
// Getters
bool isMajorExpense() => amount > 100;
bool isThisMonth() {
DateTime now = DateTime.now();
return date.year == now.year && date.month == now.month;
}
bool isToday() {
DateTime now = DateTime.now();
return date.year == now.year &&
date.month == now.month &&
date.day == now.day;
}
int getDaysAgo() {
return DateTime.now().difference(date).inDays;
}
String getFormattedAmount() => '$${amount.toStringAsFixed(2)}';
String getFormattedDate() {
return '${date.month}/${date.day}/${date.year}';
}
String getCategoryEmoji() {
switch (category) {
case 'Food': return '🍔';
case 'Transport': return '🚗';
case 'Bills': return '💡';
case 'Entertainment': return '🎬';
case 'Health': return '💊';
case 'Shopping': return '🛍️';
default: return '📝';
}
}
String getSummary() {
String emoji = isMajorExpense() ? '🔴' : '🟢';
String paid = isPaid ? '✅' : '❌';
return '$emoji $paid #$id: $description - ${getFormattedAmount()} [$category]';
}
void printDetails() {
print('─────────────────────────────────────');
print('ID: #$id');
print('${getCategoryEmoji()} $description');
print('Amount: ${getFormattedAmount()}');
print('Category: $category');
print('Date: ${getFormattedDate()} (${getDaysAgo()} days ago)');
print('Status: ${isPaid ? "✅ Paid" : "❌ Unpaid"}');
if (paymentMethodName != null) {
print('Payment: $paymentMethodName');
}
if (notes != null && notes!.isNotEmpty) {
print('Notes: $notes');
}
print('─────────────────────────────────────');
}
// Convert to/from Map for saving
Map<String, dynamic> toMap() {
return {
'id': id,
'description': description,
'amount': amount,
'category': category,
'date': date.toIso8601String(),
'notes': notes,
'isPaid': isPaid,
'paymentMethodName': paymentMethodName,
};
}
static Expense fromMap(Map<String, dynamic> map) {
return Expense(
description: map['description'],
amount: map['amount'],
category: map['category'],
date: DateTime.parse(map['date']),
notes: map['notes'],
isPaid: map['isPaid'],
paymentMethodName: map['paymentMethodName'],
);
}
}
recurring_expense.dart
import 'expense.dart';
class RecurringExpense extends Expense {
String frequency;
DateTime? nextDueDate;
RecurringExpense({
required String description,
required double amount,
required String category,
required this.frequency,
DateTime? date,
this.nextDueDate,
String? notes,
bool isPaid = false,
}) : super(
description: description,
amount: amount,
category: category,
date: date,
notes: notes,
isPaid: isPaid,
) {
nextDueDate ??= _calculateNextDueDate();
}
DateTime _calculateNextDueDate() {
switch (frequency) {
case 'daily':
return date.add(Duration(days: 1));
case 'weekly':
return date.add(Duration(days: 7));
case 'monthly':
return DateTime(date.year, date.month + 1, date.day);
case 'yearly':
return DateTime(date.year + 1, date.month, date.day);
default:
return date;
}
}
double yearlyTotal() {
switch (frequency) {
case 'daily': return amount * 365;
case 'weekly': return amount * 52;
case 'monthly': return amount * 12;
case 'yearly': return amount;
default: return amount;
}
}
bool isDueSoon(int daysThreshold) {
if (nextDueDate == null) return false;
int daysUntilDue = nextDueDate!.difference(DateTime.now()).inDays;
return daysUntilDue <= daysThreshold && daysUntilDue >= 0;
}
@override
void printDetails() {
print('─────────────────────────────────────');
print('🔄 RECURRING EXPENSE #$id');
print('${getCategoryEmoji()} $description');
print('Amount: ${getFormattedAmount()}/$frequency');
print('Yearly Total: $${yearlyTotal().toStringAsFixed(2)}');
print('Category: $category');
print('Last Payment: ${getFormattedDate()}');
if (nextDueDate != null) {
print('Next Due: ${nextDueDate!.month}/${nextDueDate!.day}/${nextDueDate!.year}');
if (isDueSoon(3)) {
print('⚠️ Due soon!');
}
}
print('Status: ${isPaid ? "✅ Paid" : "❌ Unpaid"}');
if (notes != null && notes!.isNotEmpty) {
print('Notes: $notes');
}
print('─────────────────────────────────────');
}
@override
Map<String, dynamic> toMap() {
var map = super.toMap();
map['type'] = 'recurring';
map['frequency'] = frequency;
map['nextDueDate'] = nextDueDate?.toIso8601String();
return map;
}
}
one_time_expense.dart
import 'expense.dart';
class OneTimeExpense extends Expense {
String occasion;
OneTimeExpense({
required String description,
required double amount,
required String category,
required this.occasion,
DateTime? date,
String? notes,
bool isPaid = false,
}) : super(
description: description,
amount: amount,
category: category,
date: date,
notes: notes,
isPaid: isPaid,
);
bool isSpecialOccasion() {
return occasion == 'birthday' ||
occasion == 'anniversary' ||
occasion == 'holiday' ||
occasion == 'wedding';
}
String getOccasionEmoji() {
switch (occasion) {
case 'birthday': return '🎂';
case 'anniversary': return '💝';
case 'holiday': return '🎄';
case 'wedding': return '💒';
case 'emergency': return '🚨';
case 'vacation': return '✈️';
default: return '🎯';
}
}
@override
void printDetails() {
print('─────────────────────────────────────');
print('🎯 ONE-TIME EXPENSE #$id');
print('${getOccasionEmoji()} $occasion: $description');
print('${getCategoryEmoji()} Category: $category');
print('Amount: ${getFormattedAmount()}');
print('Date: ${getFormattedDate()} (${getDaysAgo()} days ago)');
print('Status: ${isPaid ? "✅ Paid" : "❌ Unpaid"}');
if (isSpecialOccasion()) {
print('✨ Special occasion');
}
if (notes != null && notes!.isNotEmpty) {
print('Notes: $notes');
}
print('─────────────────────────────────────');
}
@override
Map<String, dynamic> toMap() {
var map = super.toMap();
map['type'] = 'onetime';
map['occasion'] = occasion;
return map;
}
}
Part 3: Expense Manager
import 'dart:io';
import 'dart:convert';
import 'expense.dart';
import 'recurring_expense.dart';
import 'one_time_expense.dart';
class ExpenseManager {
List<Expense> _expenses = [];
String _dataFile = 'expenses.json';
// Add expense
void addExpense(Expense expense) {
_expenses.add(expense);
print('✅ Added expense #${expense.id}');
}
// Get all expenses
List<Expense> getAllExpenses() => List.from(_expenses);
int getCount() => _expenses.length;
bool isEmpty() => _expenses.isEmpty;
// Get by ID
Expense? getExpenseById(int id) {
try {
return _expenses.firstWhere((e) => e.id == id);
} catch (e) {
return null;
}
}
// Filter methods
List<Expense> getByCategory(String category) {
return _expenses.where((e) => e.category == category).toList();
}
List<Expense> getThisMonth() {
return _expenses.where((e) => e.isThisMonth()).toList();
}
List<Expense> getUnpaid() {
return _expenses.where((e) => !e.isPaid).toList();
}
List<RecurringExpense> getRecurringExpenses() {
return _expenses.whereType<RecurringExpense>().toList();
}
List<OneTimeExpense> getOneTimeExpenses() {
return _expenses.whereType<OneTimeExpense>().toList();
}
// Statistics
double getTotalSpending() {
return _expenses.fold(0.0, (sum, e) => sum + e.amount);
}
double getTotalByCategory(String category) {
return _expenses
.where((e) => e.category == category)
.fold(0.0, (sum, e) => sum + e.amount);
}
double getTotalUnpaid() {
return _expenses
.where((e) => !e.isPaid)
.fold(0.0, (sum, e) => sum + e.amount);
}
double getAverageExpense() {
if (_expenses.isEmpty) return 0;
return getTotalSpending() / _expenses.length;
}
Expense? getLargestExpense() {
if (_expenses.isEmpty) return null;
return _expenses.reduce((a, b) => a.amount > b.amount ? a : b);
}
List<String> getAllCategories() {
return _expenses.map((e) => e.category).toSet().toList();
}
Map<String, double> getCategoryBreakdown() {
Map<String, double> breakdown = {};
for (var expense in _expenses) {
breakdown[expense.category] =
(breakdown[expense.category] ?? 0) + expense.amount;
}
return breakdown;
}
// Sort methods
List<Expense> sortByAmountDesc() {
List<Expense> sorted = List.from(_expenses);
sorted.sort((a, b) => b.amount.compareTo(a.amount));
return sorted;
}
List<Expense> sortByDateDesc() {
List<Expense> sorted = List.from(_expenses);
sorted.sort((a, b) => b.date.compareTo(a.date));
return sorted;
}
// Update
bool updateExpense(int id, {
String? description,
double? amount,
String? category,
String? notes,
bool? isPaid,
}) {
var expense = getExpenseById(id);
if (expense == null) return false;
if (description != null) expense.description = description;
if (amount != null) expense.amount = amount;
if (category != null) expense.category = category;
if (notes != null) expense.notes = notes;
if (isPaid != null) expense.isPaid = isPaid;
print('✏️ Updated expense #$id');
return true;
}
// Delete
bool deleteExpense(int id) {
int initialLength = _expenses.length;
_expenses.removeWhere((e) => e.id == id);
bool removed = _expenses.length < initialLength;
if (removed) {
print('🗑️ Deleted expense #$id');
}
return removed;
}
// Mark as paid
bool markAsPaid(int id, String paymentMethod) {
var expense = getExpenseById(id);
if (expense == null) return false;
expense.isPaid = true;
expense.paymentMethodName = paymentMethod;
print('✅ Marked expense #$id as paid');
return true;
}
// Search
List<Expense> search(String query) {
String lowerQuery = query.toLowerCase();
return _expenses.where((e) =>
e.description.toLowerCase().contains(lowerQuery) ||
e.category.toLowerCase().contains(lowerQuery) ||
(e.notes?.toLowerCase().contains(lowerQuery) ?? false)
).toList();
}
// Reports
void printSummary() {
print('n${"═" * 50}');
print('💰 EXPENSE SUMMARY'.padRight(50));
print('${"═" * 50}');
print('Total expenses: ${getCount()}');
print('Total spent: $${getTotalSpending().toStringAsFixed(2)}');
print('Average: $${getAverageExpense().toStringAsFixed(2)}');
print('Unpaid: $${getTotalUnpaid().toStringAsFixed(2)}');
var largest = getLargestExpense();
if (largest != null) {
print('Largest: ${largest.description} (${largest.getFormattedAmount()})');
}
var recurring = getRecurringExpenses();
if (recurring.isNotEmpty) {
double yearlyRecurring = recurring.fold(0.0, (sum, e) => sum + e.yearlyTotal());
print('Yearly recurring: $${yearlyRecurring.toStringAsFixed(2)}');
}
print('${"═" * 50}n');
}
void printCategoryReport() {
print('n📊 CATEGORY BREAKDOWNn');
var breakdown = getCategoryBreakdown();
double total = getTotalSpending();
if (breakdown.isEmpty) {
print('No expenses to show');
return;
}
breakdown.forEach((category, amount) {
double percentage = total > 0 ? (amount / total) * 100 : 0;
int count = _expenses.where((e) => e.category == category).length;
print('$category:');
print(' Amount: $${amount.toStringAsFixed(2)} (${percentage.toStringAsFixed(1)}%)');
print(' Count: $count expenses');
print('');
});
}
void printMonthlyReport() {
print('n📅 MONTHLY REPORTn');
var thisMonth = getThisMonth();
if (thisMonth.isEmpty) {
print('No expenses this month');
return;
}
double total = thisMonth.fold(0.0, (sum, e) => sum + e.amount);
double unpaid = thisMonth.where((e) => !e.isPaid).fold(0.0, (sum, e) => sum + e.amount);
print('This month: ${thisMonth.length} expenses');
print('Total spent: $${total.toStringAsFixed(2)}');
print('Unpaid: $${unpaid.toStringAsFixed(2)}');
print('nExpenses:');
for (var expense in thisMonth) {
print(' ${expense.getSummary()}');
}
print('');
}
// Save/Load
Future<void> saveToFile() async {
try {
List<Map<String, dynamic>> data = _expenses.map((e) => e.toMap()).toList();
String json = JsonEncoder.withIndent(' ').convert(data);
await File(_dataFile).writeAsString(json);
print('💾 Saved ${_expenses.length} expenses');
} catch (e) {
print('❌ Error saving: $e');
}
}
Future<void> loadFromFile() async {
try {
if (!await File(_dataFile).exists()) {
print('No saved data found');
return;
}
String json = await File(_dataFile).readAsString();
List<dynamic> data = jsonDecode(json);
_expenses.clear();
for (var item in data) {
String type = item['type'] ?? 'regular';
if (type == 'recurring') {
_expenses.add(RecurringExpense(
description: item['description'],
amount: item['amount'],
category: item['category'],
frequency: item['frequency'],
date: DateTime.parse(item['date']),
nextDueDate: item['nextDueDate'] != null
? DateTime.parse(item['nextDueDate'])
: null,
notes: item['notes'],
isPaid: item['isPaid'],
));
} else if (type == 'onetime') {
_expenses.add(OneTimeExpense(
description: item['description'],
amount: item['amount'],
category: item['category'],
occasion: item['occasion'],
date: DateTime.parse(item['date']),
notes: item['notes'],
isPaid: item['isPaid'],
));
} else {
_expenses.add(Expense.fromMap(item));
}
}
print('📂 Loaded ${_expenses.length} expenses');
} catch (e) {
print('❌ Error loading: $e');
}
}
}
Part 4: Helper Utilities
input_helper.dart
import 'dart:io';
class InputHelper {
static String readString(String prompt) {
stdout.write('$prompt: ');
return stdin.readLineSync() ?? '';
}
static int readInt(String prompt, {int? min, int? max}) {
while (true) {
stdout.write('$prompt: ');
String? input = stdin.readLineSync();
if (input == null || input.isEmpty) {
print('❌ Please enter a number');
continue;
}
int? number = int.tryParse(input);
if (number == null) {
print('❌ Invalid number');
continue;
}
if (min != null && number < min) {
print('❌ Number must be at least $min');
continue;
}
if (max != null && number > max) {
print('❌ Number must be at most $max');
continue;
}
return number;
}
}
static double readDouble(String prompt, {double? min}) {
while (true) {
stdout.write('$prompt: ');
String? input = stdin.readLineSync();
if (input == null || input.isEmpty) {
print('❌ Please enter a number');
continue;
}
double? number = double.tryParse(input);
if (number == null) {
print('❌ Invalid number');
continue;
}
if (min != null && number < min) {
print('❌ Number must be at least $min');
continue;
}
return number;
}
}
static bool readYesNo(String prompt) {
while (true) {
stdout.write('$prompt (y/n): ');
String? input = stdin.readLineSync()?.toLowerCase();
if (input == 'y' || input == 'yes') return true;
if (input == 'n' || input == 'no') return false;
print('❌ Please enter y or n');
}
}
static String readChoice(String prompt, List<String> options) {
print('$prompt:');
for (int i = 0; i < options.length; i++) {
print(' ${i + 1}. ${options[i]}');
}
int choice = readInt('Choose (1-${options.length})',
min: 1, max: options.length);
return options[choice - 1];
}
static void pause() {
print('nPress Enter to continue...');
stdin.readLineSync();
}
}
display_helper.dart
class DisplayHelper {
static void clearScreen() {
print('n' * 50); // Simple clear for cross-platform
}
static void printHeader(String title) {
print('n${"═" * 60}');
print(title.toUpperCase().padRight(60));
print('${"═" * 60}n');
}
static void printSuccess(String message) {
print('✅ $message');
}
static void printError(String message) {
print('❌ $message');
}
static void printWarning(String message) {
print('⚠️ $message');
}
static void printInfo(String message) {
print('ℹ️ $message');
}
static void printDivider() {
print('─' * 60);
}
}
Part 5: Main Application
app.dart
import 'dart:io';
import 'expense_manager.dart';
import 'expense.dart';
import 'recurring_expense.dart';
import 'one_time_expense.dart';
import 'input_helper.dart';
import 'display_helper.dart';
class ExpenseApp {
final ExpenseManager _manager = ExpenseManager();
bool _running = true;
Future<void> run() async {
DisplayHelper.clearScreen();
print('🚀 Loading...');
await _manager.loadFromFile();
while (_running) {
_showMainMenu();
int choice = InputHelper.readInt('nChoose an option', min: 1, max: 11);
DisplayHelper.clearScreen();
switch (choice) {
case 1:
_addExpense();
break;
case 2:
_viewAllExpenses();
break;
case 3:
_viewByCategory();
break;
case 4:
_searchExpenses();
break;
case 5:
_viewExpenseDetails();
break;
case 6:
_updateExpense();
break;
case 7:
_deleteExpense();
break;
case 8:
_markAsPaid();
break;
case 9:
_viewReports();
break;
case 10:
await _saveAndExit();
break;
case 11:
_running = false;
print('👋 Goodbye!');
break;
}
if (_running && choice != 10 && choice != 11) {
InputHelper.pause();
}
}
}
void _showMainMenu() {
DisplayHelper.clearScreen();
DisplayHelper.printHeader('💰 Expense Manager v1.0');
if (!_manager.isEmpty()) {
print('Total: ${_manager.getCount()} expenses | ');
print('Spent: $${_manager.getTotalSpending().toStringAsFixed(2)} | ');
print('Unpaid: $${_manager.getTotalUnpaid().toStringAsFixed(2)}n');
}
print('1. ➕ Add Expense');
print('2. 📋 View All Expenses');
print('3. 📁 View by Category');
print('4. 🔍 Search Expenses');
print('5. 🔎 View Expense Details');
print('6. ✏️ Update Expense');
print('7. 🗑️ Delete Expense');
print('8. 💳 Mark as Paid');
print('9. 📊 View Reports');
print('10. 💾 Save & Exit');
print('11. 🚪 Exit Without Saving');
}
void _addExpense() {
DisplayHelper.printHeader('➕ Add Expense');
String type = InputHelper.readChoice('Expense type', [
'Regular',
'Recurring (subscriptions, bills)',
'One-time (special occasion)',
]);
print('');
String description = InputHelper.readString('Description');
double amount = InputHelper.readDouble('Amount ($)', min: 0);
String category = InputHelper.readChoice('Category', [
'Food',
'Transport',
'Bills',
'Entertainment',
'Health',
'Shopping',
'Other',
]);
String? notes;
if (InputHelper.readYesNo('Add notes?')) {
notes = InputHelper.readString('Notes');
}
try {
if (type == 'Regular') {
_manager.addExpense(Expense(
description: description,
amount: amount,
category: category,
notes: notes,
));
} else if (type == 'Recurring (subscriptions, bills)') {
String frequency = InputHelper.readChoice('Frequency', [
'daily',
'weekly',
'monthly',
'yearly',
]);
_manager.addExpense(RecurringExpense(
description: description,
amount: amount,
category: category,
frequency: frequency,
notes: notes,
));
} else {
String occasion = InputHelper.readChoice('Occasion', [
'birthday',
'anniversary',
'holiday',
'wedding',
'emergency',
'vacation',
'other',
]);
_manager.addExpense(OneTimeExpense(
description: description,
amount: amount,
category: category,
occasion: occasion,
notes: notes,
));
}
DisplayHelper.printSuccess('Expense added successfully!');
} catch (e) {
DisplayHelper.printError('Failed to add expense: $e');
}
}
void _viewAllExpenses() {
DisplayHelper.printHeader('📋 All Expenses');
if (_manager.isEmpty()) {
DisplayHelper.printInfo('No expenses yet');
return;
}
String sortBy = InputHelper.readChoice('Sort by', [
'Date (newest first)',
'Amount (highest first)',
'ID',
]);
List<Expense> expenses;
if (sortBy == 'Date (newest first)') {
expenses = _manager.sortByDateDesc();
} else if (sortBy == 'Amount (highest first)') {
expenses = _manager.sortByAmountDesc();
} else {
expenses = _manager.getAllExpenses();
}
print('');
for (var expense in expenses) {
print(expense.getSummary());
}
print('nTotal: ${expenses.length} expenses');
}
void _viewByCategory() {
DisplayHelper.printHeader('📁 View by Category');
var categories = _manager.getAllCategories();
if (categories.isEmpty) {
DisplayHelper.printInfo('No expenses yet');
return;
}
String category = InputHelper.readChoice('Category', categories);
var expenses = _manager.getByCategory(category);
print('');
if (expenses.isEmpty) {
DisplayHelper.printInfo('No expenses in this category');
return;
}
for (var expense in expenses) {
print(expense.getSummary());
}
double total = expenses.fold(0.0, (sum, e) => sum + e.amount);
print('n$category Total: ${total.toStringAsFixed(2)}');
}
void _searchExpenses() {
DisplayHelper.printHeader('🔍 Search Expenses');
String query = InputHelper.readString('Search for');
var results = _manager.search(query);
print('');
if (results.isEmpty) {
DisplayHelper.printInfo('No results found');
return;
}
print('Found ${results.length} result(s):n');
for (var expense in results) {
print(expense.getSummary());
}
}
void _viewExpenseDetails() {
DisplayHelper.printHeader('🔎 View Expense Details');
if (_manager.isEmpty()) {
DisplayHelper.printInfo('No expenses yet');
return;
}
int id = InputHelper.readInt('Enter expense ID');
var expense = _manager.getExpenseById(id);
print('');
if (expense == null) {
DisplayHelper.printError('Expense not found');
return;
}
expense.printDetails();
}
void _updateExpense() {
DisplayHelper.printHeader('✏️ Update Expense');
if (_manager.isEmpty()) {
DisplayHelper.printInfo('No expenses yet');
return;
}
int id = InputHelper.readInt('Enter expense ID');
var expense = _manager.getExpenseById(id);
if (expense == null) {
DisplayHelper.printError('Expense not found');
return;
}
print('nCurrent details:');
expense.printDetails();
print('nWhat would you like to update?');
print('1. Description');
print('2. Amount');
print('3. Category');
print('4. Notes');
print('5. Cancel');
int choice = InputHelper.readInt('Choose', min: 1, max: 5);
if (choice == 5) {
print('Cancelled');
return;
}
switch (choice) {
case 1:
String newDesc = InputHelper.readString('New description');
_manager.updateExpense(id, description: newDesc);
break;
case 2:
double newAmount = InputHelper.readDouble('New amount', min: 0);
_manager.updateExpense(id, amount: newAmount);
break;
case 3:
String newCategory = InputHelper.readChoice('New category', [
'Food', 'Transport', 'Bills', 'Entertainment',
'Health', 'Shopping', 'Other'
]);
_manager.updateExpense(id, category: newCategory);
break;
case 4:
String newNotes = InputHelper.readString('New notes');
_manager.updateExpense(id, notes: newNotes);
break;
}
}
void _deleteExpense() {
DisplayHelper.printHeader('🗑️ Delete Expense');
if (_manager.isEmpty()) {
DisplayHelper.printInfo('No expenses yet');
return;
}
int id = InputHelper.readInt('Enter expense ID');
var expense = _manager.getExpenseById(id);
if (expense == null) {
DisplayHelper.printError('Expense not found');
return;
}
print('nExpense to delete:');
print(expense.getSummary());
if (InputHelper.readYesNo('nAre you sure?')) {
_manager.deleteExpense(id);
} else {
print('Cancelled');
}
}
void _markAsPaid() {
DisplayHelper.printHeader('💳 Mark as Paid');
var unpaid = _manager.getUnpaid();
if (unpaid.isEmpty) {
DisplayHelper.printInfo('No unpaid expenses');
return;
}
print('Unpaid expenses:n');
for (var expense in unpaid) {
print(expense.getSummary());
}
print('');
int id = InputHelper.readInt('Enter expense ID');
var expense = _manager.getExpenseById(id);
if (expense == null) {
DisplayHelper.printError('Expense not found');
return;
}
if (expense.isPaid) {
DisplayHelper.printWarning('This expense is already paid');
return;
}
String paymentMethod = InputHelper.readChoice('Payment method', [
'Cash',
'Credit Card',
'Debit Card',
'Digital Wallet',
'Bank Transfer',
'Other',
]);
_manager.markAsPaid(id, paymentMethod);
}
void _viewReports() {
DisplayHelper.printHeader('📊 Reports');
if (_manager.isEmpty()) {
DisplayHelper.printInfo('No expenses yet');
return;
}
print('1. Summary');
print('2. Category Breakdown');
print('3. Monthly Report');
print('4. All Reports');
int choice = InputHelper.readInt('Choose', min: 1, max: 4);
print('');
switch (choice) {
case 1:
_manager.printSummary();
break;
case 2:
_manager.printCategoryReport();
break;
case 3:
_manager.printMonthlyReport();
break;
case 4:
_manager.printSummary();
_manager.printCategoryReport();
_manager.printMonthlyReport();
break;
}
}
Future<void> _saveAndExit() async {
DisplayHelper.printHeader('💾 Saving');
await _manager.saveToFile();
_running = false;
print('n✅ Data saved. Goodbye! 👋');
}
}
main.dart
import 'app.dart';
void main() async {
var app = ExpenseApp();
await app.run();
}
Part 6: Running the Application
How to Run:
- Create the project structure:
mkdir expense_manager
cd expense_manager
mkdir lib bin
-
Create all the files in the appropriate folders
-
Run the application:
dart run bin/main.dart
Sample Interaction:
═══════════════════════════════════════════════════════════
💰 EXPENSE MANAGER V1.0
═══════════════════════════════════════════════════════════
1. ➕ Add Expense
2. 📋 View All Expenses
3. 📁 View by Category
4. 🔍 Search Expenses
5. 🔎 View Expense Details
6. ✏️ Update Expense
7. 🗑️ Delete Expense
8. 💳 Mark as Paid
9. 📊 View Reports
10. 💾 Save & Exit
11. 🚪 Exit Without Saving
Choose an option: 1
═══════════════════════════════════════════════════════════
➕ ADD EXPENSE
═══════════════════════════════════════════════════════════
Expense type:
1. Regular
2. Recurring (subscriptions, bills)
3. One-time (special occasion)
Choose (1-3): 2
Description: Netflix Subscription
Amount ($): 15.99
Category:
1. Food
2. Transport
3. Bills
4. Entertainment
5. Health
6. Shopping
7. Other
Choose (1-7): 4
Frequency:
1. daily
2. weekly
3. monthly
4. yearly
Choose (1-4): 3
Add notes? (y/n): y
Notes: Premium 4K plan
✅ Added expense #1
Part 7: Key Features Implemented
✅ CRUD Operations
- Create: Add expenses (regular, recurring, one-time)
- Read: View all, by category, search, view details
- Update: Modify description, amount, category, notes
- Delete: Remove expenses with confirmation
✅ Data Persistence
- Save to JSON file
- Load from JSON file
- Auto-save on exit
✅ Different Expense Types
- Regular expenses
- Recurring expenses (with frequency and yearly total)
- One-time expenses (with occasion)
✅ Filtering & Sorting
- By category
- By date
- By amount
- Search by keyword
✅ Statistics & Reports
- Total spending
- Category breakdown
- Monthly report
- Unpaid expenses
✅ Payment Tracking
- Mark as paid
- Track payment method
- View unpaid expenses
Part 8: Extending the Application
Ideas for Enhancement:
1. Budget Management
class Budget {
String category;
double limit;
int month;
int year;
bool isOverBudget(ExpenseManager manager) {
double spent = manager.getTotalByCategory(category);
return spent > limit;
}
}
2. Export to CSV
void exportToCsv(String filename) {
var file = File(filename);
var csv = 'ID,Description,Amount,Category,Date,Paidn';
for (var expense in _expenses) {
csv += '${expense.id},${expense.description},${expense.amount},';
csv += '${expense.category},${expense.getFormattedDate()},${expense.isPaid}n';
}
file.writeAsStringSync(csv);
print('📄 Exported to $filename');
}
3. Charts & Visualizations
void printBarChart() {
var breakdown = getCategoryBreakdown();
double max = breakdown.values.reduce((a, b) => a > b ? a : b);
breakdown.forEach((category, amount) {
int bars = (amount / max * 20).round();
print('$category: ${"█" * bars} ${amount.toStringAsFixed(2)}');
});
}
4. Recurring Payment Reminders
void checkUpcomingPayments() {
var recurring = getRecurringExpenses();
for (var expense in recurring) {
if (expense.isDueSoon(3)) {
print('⚠️ ${expense.description} due in ${expense.nextDueDate!.difference(DateTime.now()).inDays} days');
}
}
}
5. Income Tracking
class Income {
String source;
double amount;
DateTime date;
Income(this.source, this.amount, this.date);
}
class FinanceManager {
ExpenseManager expenseManager;
List<Income> incomes = [];
double getNetBalance() {
double totalIncome = incomes.fold(0.0, (sum, i) => sum + i.amount);
double totalExpense = expenseManager.getTotalSpending();
return totalIncome - totalExpense;
}
}
🎯 Practice Exercises
Exercise 1: Add Expense Categories Management (Easy)
Allow users to create custom categories instead of using predefined ones.
Hint:
class CategoryManager {
List<String> _categories = ['Food', 'Transport', 'Bills'];
void addCategory(String category) { ... }
void removeCategory(String category) { ... }
List<String> getAllCategories() { ... }
}
Exercise 2: Implement Budget System (Medium)
Create a budget management system with alerts when spending exceeds limits.
Requirements:
- Set monthly budget per category
- Track spending vs budget
- Show warnings when approaching limit
- Generate budget report
Exercise 3: Multi-User Support (Hard)
Add user accounts so multiple people can track their expenses separately.
Requirements:
- User login system
- Separate expense lists per user
- User profiles with settings
- Data saved per user
Part 9: Preparing for Flutter
This command-line app is a perfect foundation for a Flutter app!
Structure Similarities:
Command-line:
ExpenseApp (manages UI flow)
└── ExpenseManager (manages data)
└── Expense models
Flutter:
ExpenseApp (MaterialApp)
└── Screens (HomeScreen, AddScreen, etc.)
└── ExpenseProvider (state management)
└── ExpenseManager (same!)
└── Expense models (same!)
What Stays the Same:
✅ All model classes (Expense, RecurringExpense, etc.)
✅ ExpenseManager class
✅ Business logic
✅ Data persistence
What Changes:
❌ Command-line UI → Flutter widgets
❌ stdin/stdout → TextFields, Buttons
❌ print() → Text widgets
Next Steps for Flutter:
- Keep all your model and manager classes
- Replace InputHelper with Flutter Forms
- Replace DisplayHelper with Flutter widgets
- Add state management (Provider, Riverpod, or Bloc)
- Create beautiful UI screens
Common Mistakes & How to Fix Them
Mistake 1: Not Handling Null Values
// ❌ Wrong - might crash
var expense = _manager.getExpenseById(id);
expense.printDetails(); // Crash if null!
// ✅ Correct - check for null
var expense = _manager.getExpenseById(id);
if (expense == null) {
print('Expense not found');
return;
}
expense.printDetails();
Mistake 2: Not Validating User Input
// ❌ Wrong - no validation
double amount = double.parse(input);
// ✅ Correct - validate and handle errors
double? amount = double.tryParse(input);
if (amount == null || amount < 0) {
print('Invalid amount');
return;
}
Mistake 3: Forgetting to Save Data
// ❌ Wrong - data lost on exit
void exit() {
_running = false;
}
// ✅ Correct - save before exit
Future<void> exit() async {
await _manager.saveToFile();
_running = false;
}
Mistake 4: Not Using async/await Properly
// ❌ Wrong - doesn't wait for save
void saveData() {
_manager.saveToFile();
print('Saved!'); // Prints before save completes!
}
// ✅ Correct - await the operation
Future<void> saveData() async {
await _manager.saveToFile();
print('Saved!'); // Prints after save completes
}
Key Concepts Review
✅ Project organization – separate concerns into folders
✅ Model classes – represent data structures
✅ Manager classes – handle business logic
✅ Helper classes – reusable utility functions
✅ CRUD operations – Create, Read, Update, Delete
✅ Data persistence – save/load JSON
✅ User input validation – ensure data quality
✅ Error handling – graceful failure management
✅ Menu-driven UI – clear user flow
Self-Check Questions
1. Why separate models, managers, and helpers into different files?
Answer:
- Organization: Easy to find and maintain code
- Reusability: Can use same classes in different projects
- Testing: Can test each component independently
- Collaboration: Multiple developers can work on different parts
- Scalability: Easy to add new features without breaking existing code
2. What’s the difference between a model class and a manager class?
Answer:
- Model: Represents a single entity (one expense) with properties and methods related to that entity
- Manager: Handles collections and operations on multiple entities (all expenses, filtering, sorting, statistics)
3. Why use async/await for file operations?
Answer:
File operations are slow (I/O bound). Using async/await prevents the app from freezing while waiting for the file operation to complete, allowing other code to run in the meantime.
What’s Next?
Congratulations! 🎉
You’ve completed all 9 lessons and built a complete, working expense manager application using OOP principles!
What You’ve Learned:
✅ Lesson 1: OOP basics
✅ Lesson 2: Classes and objects
✅ Lesson 3: Constructors
✅ Lesson 4: Encapsulation
✅ Lesson 5: Methods
✅ Lesson 6: Collections management
✅ Lesson 7: Inheritance
✅ Lesson 8: Polymorphism & abstraction
✅ Lesson 9: Complete application
Next Steps:
- Enhance the CLI app with the practice exercises
- Learn Flutter – convert this to a mobile app
- Add database – SQLite for better data storage
- Cloud sync – Firebase for multi-device support
- Advanced features – charts, budgets, categories
- Publish – share your app with others!
Final Project Ideas
Beginner Projects:
- Todo list manager
- Contact book
- Note-taking app
- Simple calculator with history
Intermediate Projects:
- Habit tracker
- Inventory management
- Recipe organizer
- Time tracker
Advanced Projects:
- Budget planner with forecasting
- Investment portfolio tracker
- Multi-currency expense manager
- Business expense reporting system
Resources for Continued Learning
Official Documentation:
- Dart: https://dart.dev/guides
- Flutter: https://flutter.dev/docs
- Effective Dart: https://dart.dev/guides/language/effective-dart
Practice Platforms:
- DartPad: https://dartpad.dev (online Dart playground)
- LeetCode: Algorithm practice
- HackerRank: Coding challenges
Community:
- Flutter Discord: Join the community
- r/FlutterDev: Reddit community
- Stack Overflow: Ask questions
Closing Thoughts
You’ve accomplished a lot! 🌟
You started knowing nothing about OOP, and now you have:
- A solid understanding of all core OOP principles
- Practical experience building a real application
- Clean, organized, maintainable code
- A foundation ready for Flutter development
Keep practicing, keep building, and most importantly – have fun coding! 💪
The best way to solidify your knowledge is to build more projects. Take the concepts you’ve learned and create something new. Every project teaches you something valuable.
Good luck on your coding journey! 🚀
End of Course ✨