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:

  1. Create the project structure:
mkdir expense_manager
cd expense_manager
mkdir lib bin
  1. Create all the files in the appropriate folders

  2. 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:

  1. Keep all your model and manager classes
  2. Replace InputHelper with Flutter Forms
  3. Replace DisplayHelper with Flutter widgets
  4. Add state management (Provider, Riverpod, or Bloc)
  5. 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:

  1. Enhance the CLI app with the practice exercises
  2. Learn Flutter – convert this to a mobile app
  3. Add database – SQLite for better data storage
  4. Cloud sync – Firebase for multi-device support
  5. Advanced features – charts, budgets, categories
  6. 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:

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

Similar Posts