Project 1 — Console Expense Tracker
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:
| Feature | Description |
|---|---|
| ➕ Add Expense | Record amount, category, and description |
| 📋 View All | List every recorded expense |
| 🔍 Filter by Category | Show only Food, Transport, etc. |
| 💰 Get Total | Sum all or category-filtered expenses |
| 💾 Persistence Hint | Save expenses to a file |
🧠 Concepts Used
- ✅ Variables & Data Types
- ✅ Functions
- ✅ Classes & Constructors
- ✅ Lists & Maps
- ✅
stdin.readLineSync()for user input - ✅
DateTimefor 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:
bashdart 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
bashdart run bin/main.dart
🔥 Bonus: Save Expenses to a File
Want data to persist between runs? Add this to lib/tracker.dart:
dartimport '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:
dartvoid 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
| Concept | Where We Used It |
|---|---|
class | Expense 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 constructor | Expense.fromMap() |
jsonEncode/Decode | File persistence |
🧪 Challenge Yourself
- Edit Expense — Add option to edit an existing expense by ID
- Delete Expense — Remove an expense from the list
- Date Filter — Show expenses only from this week or this month
- Export to CSV — Write expenses as a
.csvfile for Excel - 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, andmap - 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!