Lesson 45 minNEW

Project 1 — Console Expense Tracker

00:00 / 00:00

Project 1 — Console Expense Tracker

Welcome to your first real Dart project! In this lesson, we'll build a fully functional Console Expense Tracker — a menu-driven CLI application that lets you add, view, filter, and summarize expenses. By the end, you'll have a complete program that ties together everything you've learned so far.


🎯 What We're Building

A command-line expense tracker with the following features:

FeatureDescription
➕ Add ExpenseRecord amount, category, and description
📋 View AllList every recorded expense
🔍 Filter by CategoryShow only Food, Transport, etc.
💰 Get TotalSum all or category-filtered expenses
💾 Persistence HintSave expenses to a file

🧠 Concepts Used

  • ✅ Variables & Data Types
  • ✅ Functions
  • ✅ Classes & Constructors
  • ✅ Lists & Maps
  • stdin.readLineSync() for user input
  • DateTime for timestamps
  • ✅ String interpolation & formatting
  • ✅ File I/O (bonus section)

📁 Project Structure

expense_tracker/
├── bin/
│   └── main.dart         ← Entry point
├── lib/
│   ├── expense.dart      ← Expense model class
│   └── tracker.dart      ← ExpenseTracker logic
└── pubspec.yaml

Step 1 — Set Up the Project

Open your terminal and run:

bash
dart create expense_tracker cd expense_tracker

Your pubspec.yaml is already set up. No extra packages needed for the core app.


Step 2 — The Expense Model Class

Create lib/expense.dart:

dart
// lib/expense.dart class Expense { final String id; // Unique ID for the expense final double amount; // How much was spent final String category; // e.g., Food, Transport, Entertainment final String description; // Short note about the expense final DateTime date; // When was it added? // Constructor — id is auto-generated from timestamp Expense({ required this.amount, required this.category, required this.description, DateTime? date, }) : id = DateTime.now().millisecondsSinceEpoch.toString(), date = date ?? DateTime.now(); // Convert Expense to a Map (useful for saving to a file later) Map<String, dynamic> toMap() { return { 'id': id, 'amount': amount, 'category': category, 'description': description, 'date': date.toIso8601String(), }; } // Create an Expense from a Map (useful for loading from a file) factory Expense.fromMap(Map<String, dynamic> map) { return Expense( amount: map['amount'], category: map['category'], description: map['description'], date: DateTime.parse(map['date']), ); } // Pretty-print an expense String toString() { final formattedDate = '${date.day}/${date.month}/${date.year}'; return '[$formattedDate] ${category.toUpperCase()} | ' '\$${amount.toStringAsFixed(2)}$description'; } }

💡 Tip: The id uses millisecondsSinceEpoch to guarantee uniqueness without any external package. In production apps you'd use the uuid package.


Step 3 — The ExpenseTracker Class

Create lib/tracker.dart:

dart
// lib/tracker.dart import 'expense.dart'; class ExpenseTracker { // Internal list that holds all expenses final List<Expense> _expenses = []; // ─── CRUD OPERATIONS ────────────────────────────────────────── /// Add a new expense to the tracker void addExpense(Expense expense) { _expenses.add(expense); print('\n✅ Expense added successfully!'); } /// Return all recorded expenses (unmodifiable view) List<Expense> getAllExpenses() { return List.unmodifiable(_expenses); } /// Filter expenses by a given category (case-insensitive) List<Expense> getByCategory(String category) { return _expenses .where((e) => e.category.toLowerCase() == category.toLowerCase()) .toList(); } // ─── TOTALS ─────────────────────────────────────────────────── /// Total of ALL expenses double getTotal() { if (_expenses.isEmpty) return 0.0; return _expenses.fold(0.0, (sum, e) => sum + e.amount); } /// Total for a specific category double getTotalByCategory(String category) { final filtered = getByCategory(category); return filtered.fold(0.0, (sum, e) => sum + e.amount); } // ─── SUMMARY ────────────────────────────────────────────────── /// Get a breakdown of spending per category Map<String, double> getCategorySummary() { final summary = <String, double>{}; for (final expense in _expenses) { summary[expense.category] = (summary[expense.category] ?? 0) + expense.amount; } return summary; } /// How many expenses are tracked? int get count => _expenses.length; /// List of all unique categories used List<String> get categories => _expenses.map((e) => e.category).toSet().toList(); }

💡 Tip: Notice List.unmodifiable(_expenses) — this prevents callers from accidentally mutating the internal list. This is a great defensive programming practice!


Step 4 — The Main Entry Point & CLI Menu

Now create bin/main.dart:

dart
// bin/main.dart import 'dart:io'; import '../lib/expense.dart'; import '../lib/tracker.dart'; // Our global tracker instance final tracker = ExpenseTracker(); void main() { printBanner(); // Keep showing the menu until the user chooses to exit while (true) { printMenu(); final choice = prompt('Enter your choice'); switch (choice) { case '1': handleAddExpense(); case '2': handleViewAll(); case '3': handleFilterByCategory(); case '4': handleViewTotal(); case '5': handleCategorySummary(); case '6': print('\n👋 Goodbye! Keep tracking those expenses!\n'); exit(0); default: print('\n⚠️ Invalid choice. Please enter 1–6.\n'); } } } // ─── MENU HANDLERS ──────────────────────────────────────────────── void handleAddExpense() { print('\n──── Add New Expense ────'); final amountStr = prompt('Amount (e.g., 12.50)'); final amount = double.tryParse(amountStr ?? ''); if (amount == null || amount <= 0) { print('❌ Invalid amount. Please enter a positive number.'); return; } final category = prompt('Category (Food/Transport/Entertainment/Other)'); if (category == null || category.trim().isEmpty) { print('❌ Category cannot be empty.'); return; } final description = prompt('Description'); if (description == null || description.trim().isEmpty) { print('❌ Description cannot be empty.'); return; } final expense = Expense( amount: amount, category: category.trim(), description: description.trim(), ); tracker.addExpense(expense); print(' $expense'); } void handleViewAll() { print('\n──── All Expenses (${tracker.count} total) ────'); final expenses = tracker.getAllExpenses(); if (expenses.isEmpty) { print('📭 No expenses recorded yet.'); return; } for (int i = 0; i < expenses.length; i++) { print(' ${i + 1}. ${expenses[i]}'); } print('\n 💰 Grand Total: \$${tracker.getTotal().toStringAsFixed(2)}'); } void handleFilterByCategory() { print('\n──── Filter by Category ────'); print('Available categories: ${tracker.categories.join(', ')}'); final category = prompt('Enter category'); if (category == null || category.trim().isEmpty) { print('❌ Category cannot be empty.'); return; } final filtered = tracker.getByCategory(category.trim()); if (filtered.isEmpty) { print('📭 No expenses found for category: "$category"'); return; } print('\n Results for "${category.toUpperCase()}":'); for (int i = 0; i < filtered.length; i++) { print(' ${i + 1}. ${filtered[i]}'); } final total = tracker.getTotalByCategory(category); print('\n Subtotal: \$${total.toStringAsFixed(2)}'); } void handleViewTotal() { print('\n──── Expense Summary ────'); print(' Total Expenses: ${tracker.count}'); print(' Grand Total: \$${tracker.getTotal().toStringAsFixed(2)}'); } void handleCategorySummary() { print('\n──── Category Breakdown ────'); final summary = tracker.getCategorySummary(); if (summary.isEmpty) { print('📭 No expenses to summarize.'); return; } // Sort categories by spending (highest first) final sorted = summary.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); for (final entry in sorted) { final bar = '█' * (entry.value ~/ 5).clamp(1, 30); print(' ${entry.key.padRight(15)} \$${entry.value.toStringAsFixed(2).padLeft(8)} $bar'); } } // ─── HELPER UTILITIES ───────────────────────────────────────────── /// Print a styled prompt and return the user's input String? prompt(String message) { stdout.write(' 👉 $message: '); return stdin.readLineSync(); } /// App banner shown at startup void printBanner() { print(''' ╔══════════════════════════════════════╗ ║ 💸 DART EXPENSE TRACKER ║ ║ Track every penny! ║ ╚══════════════════════════════════════╝ '''); } /// Print the main navigation menu void printMenu() { print(''' ┌─── MENU ───────────────────────────┐ │ 1. ➕ Add Expense │ │ 2. 📋 View All Expenses │ │ 3. 🔍 Filter by Category │ │ 4. 💰 View Total │ │ 5. 📊 Category Breakdown │ │ 6. 🚪 Exit │ └────────────────────────────────────┘'''); }

Step 5 — Run the Application

bash
dart run bin/main.dart

🔥 Bonus: Save Expenses to a File

Want data to persist between runs? Add this to lib/tracker.dart:

dart
import 'dart:convert'; import 'dart:io'; // Add these two methods inside ExpenseTracker: /// Save all expenses to a JSON file Future<void> saveToFile(String filePath) async { final file = File(filePath); final data = _expenses.map((e) => e.toMap()).toList(); await file.writeAsString(jsonEncode(data)); print('💾 Expenses saved to $filePath'); } /// Load expenses from a JSON file Future<void> loadFromFile(String filePath) async { final file = File(filePath); if (!await file.exists()) { print('📂 No saved data found. Starting fresh.'); return; } final content = await file.readAsString(); final List<dynamic> data = jsonDecode(content); _expenses.clear(); _expenses.addAll(data.map((m) => Expense.fromMap(m))); print('📂 Loaded ${_expenses.length} expenses from file.'); }

Then update main() to be async and call load/save:

dart
void main() async { const saveFile = 'expenses.json'; await tracker.loadFromFile(saveFile); printBanner(); while (true) { printMenu(); final choice = prompt('Enter your choice'); switch (choice) { case '1': handleAddExpense(); await tracker.saveToFile(saveFile); // Auto-save after every addition // ... rest of cases } } }

💡 Key Takeaways

ConceptWhere We Used It
classExpense and ExpenseTracker
List<T>Storing and filtering expenses
Map<String, double>Category summary
fold()Calculating totals
where()Filtering by category
stdin.readLineSync()Reading user input
stdout.write()Inline prompt (no newline)
factory constructorExpense.fromMap()
jsonEncode/DecodeFile persistence

🧪 Challenge Yourself

  1. Edit Expense — Add option to edit an existing expense by ID
  2. Delete Expense — Remove an expense from the list
  3. Date Filter — Show expenses only from this week or this month
  4. Export to CSV — Write expenses as a .csv file for Excel
  5. Budget Alerts — Warn the user when a category exceeds a set limit

Summary

Congratulations! 🎉 You built a real, working CLI application in Dart from scratch. This project demonstrates:

  • Class design with model and service separation
  • List operations with where, fold, and map
  • User interaction via stdin/stdout
  • File persistence with JSON encoding
  • Clean code with helper methods and descriptive names

This is exactly the kind of project you can show in your portfolio. In the next project, we'll build a CLI Notes Application with full file-based persistence!

WhatsApp