Watch the video (15 min)
On this page20 sections
  1. Prerequisites
  2. Connection With Previous Lectures
  3. Learning Objectives
  4. 1. Where the Loops Are
  5. 2. A Generic Toolbox: IterableTools<T>
  6. 3. Naming the Questions: TransactionQueries
  7. 4. One Extension for Every Repository
  8. 5. A Shorter LedgerService
  9. 6. The Report as a Pipeline
  10. 7. The CLI Changes
  11. 8. Run It
  12. Code Examples
  13. Common Beginner Mistakes
  14. Best Practices
  15. Mini Challenge
  16. Practice Questions
  17. Practice Answers
  18. Quick Revision
  19. Key Takeaways
  20. What's Next?

Lecture 136 — Collections & Generics in the Project

Category23 — Final Dart Project
Lecture136 of 140
Estimated video length15 min
TargetDart 3.12.2
LevelAdvanced

Our services still count, filter and add up money with hand-written loops. In this lecture we replace those loops with collection pipelines and a few generic helpers that every part of the Budget Tracker can reuse.

Contents

Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways

Every output and error message in this lecture is real. Each one was produced by running the project on Dart 3.12.2. The sessions were recorded on 2026-10-17; the demo command puts its data in the current month, so your dates will show your month.


Prerequisites

Students should already understand, from Lectures 043–078 and 132–135:

  • where, whereType, map, fold, take, sort and cascades (Lectures 020, 043–045)
  • collection for/if, spreads, sets and maps (Lectures 046–050)
  • extension methods, generic methods and bounds (Lectures 069, 073–074)
  • records and destructuring (Lectures 076, 078)
  • the project so far: models, services, Repository<T, Id> and the CLI (Lectures 133–135)

Connection With Previous Lectures

In Lecture 135 the services learned to talk to a Repository, and the app now saves its data in data/. But look inside LedgerService.balanceOf and BudgetService.monthlyReport and you still find the same pattern again and again (ledger_service.dart, Lecture 135):

dart
  Future<Money> balanceOf(String walletId) async {
    var balance = (await walletById(walletId)).openingBalance;
    for (final transaction in await _transactions.findAll()) {
      balance += transaction.effectOn(walletId);
    }
    return balance;
  }

Start a variable, loop, add, return. The report repeats it three times, with continue to skip rows and a hand-built Map<Category, Money> counter. Every loop is a little different, so every loop needs reading. Lectures 043–050 gave us a better tool for each of these jobs.


Learning Objectives

By the end of this lecture you will be able to:

  • Write a generic extension with a method-level type parameter (groupBy<K>)
  • Add up Money in any iterable with fold
  • Name common transaction filters as an extension on Iterable<Transaction>
  • Give every Repository<T, Id> new methods with one bounded generic extension
  • Return a list of records and destructure it in a for loop
  • Build the monthly report with collection for/if, a set union and a map literal
  • Add a wallet filter and a "Biggest expenses" list to the report command

1. Where the Loops Are

Four places in the Lecture 135 code loop by hand:

CodeJobCollection tool
balanceOfadd up effectsfold
historyOfkeep matching itemswhere
monthlyReportsplit by type, count per categorywhereType, grouping
_nextIdcount itemslength

We will build the tools first, then use them.


2. A Generic Toolbox: IterableTools<T>

Create lib/src/extensions/iterable_extensions.dart. The first extension works on an iterable of any type T:

dart
import '../models/money.dart';
import '../models/transaction.dart';
import 'date_extensions.dart';

/// General helpers that work on an iterable of any type [T].
extension IterableTools<T> on Iterable<T> {
  /// Groups the items into lists, using [keyOf] to pick each item's group.
  Map<K, List<T>> groupBy<K>(K Function(T item) keyOf) {
    final groups = <K, List<T>>{};
    for (final item in this) {
      (groups[keyOf(item)] ??= []).add(item);
    }
    return groups;
  }

  /// Adds up the amount that [amountOf] takes from every item.
  Money sumMoney(Money Function(T item) amountOf) =>
      fold(Money.zero, (total, item) => total + amountOf(item));
}

Two type parameters are at work. T belongs to the extension — the item type. K belongs to the method — the key type, chosen fresh at every call (Lecture 073). Group expenses by category and K is Category; group names by first letter and K is String.

groups[key] ??= [] creates the list the first time a key appears (Lecture 014), and returns the list either way, so .add(item) always works.

sumMoney cannot read item.amount — a plain T has only Object? members (Lecture 074) — so the caller passes a function that picks the amount. fold starts at Money.zero, which also makes an empty iterable sum to zero instead of crashing.


3. Naming the Questions: TransactionQueries

In the same file, add an extension on Iterable<Transaction>:

dart
/// Questions we often ask about a group of transactions.
extension TransactionQueries on Iterable<Transaction> {
  /// The transactions in the same month as [month].
  Iterable<Transaction> inMonth(DateTime month) =>
      where((t) => t.date.isSameMonth(month));

  /// The transactions that touch [walletId]; all of them when it is `null`.
  Iterable<Transaction> forWallet(String? walletId) =>
      walletId == null ? this : where((t) => t.involves(walletId));

  /// Only the incomes.
  Iterable<Income> get incomes => whereType<Income>();

  /// Only the expenses.
  Iterable<Expense> get expenses => whereType<Expense>();

  /// A new list, oldest first.
  List<Transaction> sortedByDate() =>
      [...this]..sort((a, b) => a.date.compareTo(b.date));
}
  • forWallet(null) means "all wallets" — a nullable parameter with a meaning (Lecture 013).
  • whereType<Expense>() returns Iterable<Expense>, so .category is available afterwards. where((t) => t is Expense) would still be an Iterable<Transaction>.
  • sortedByDate copies with a spread first. Repositories return List.unmodifiable, and sorting that list would crash.

These read like sentences: transactions.inMonth(month).forWallet('ali').expenses.


4. One Extension for Every Repository

Create lib/src/repository/repository_queries.dart:

dart
import '../models/entity.dart';
import 'repository.dart';

/// Extra queries that every [Repository] gets for free.
extension RepositoryQueries<T extends Entity<Id>, Id> on Repository<T, Id> {
  /// The stored items for which [test] returns true.
  Future<List<T>> where(bool Function(T item) test) async => [
    ...(await findAll()).where(test),
  ];

  /// How many items are stored.
  Future<int> count() async => (await findAll()).length;

  /// Whether an item with [id] is stored.
  Future<bool> exists(Id id) async => await findById(id) != null;
}

The extension repeats the interface's bound, T extends Entity<Id>, so it fits every repository: Repository<Wallet, String>, and also Repository<Budget, Category>, where exists takes a Category. It is written once, against the interface, so InMemoryRepository and JsonFileRepository both get it — neither file changes.


5. A Shorter LedgerService

Open lib/src/services/ledger_service.dart and import both new files:

dart
import '../extensions/iterable_extensions.dart';
import '../repository/repository_queries.dart';

Then replace the loops (excerpts):

dart
  /// Every transaction, oldest first.
  Future<List<Transaction>> transactions() async =>
      (await _transactions.findAll()).sortedByDate();

  /// Every wallet with its balance, richest first.
  Future<List<({Wallet wallet, Money balance})>> balances() async {
    final result = <({Wallet wallet, Money balance})>[];
    for (final wallet in await _wallets.findAll()) {
      result.add((wallet: wallet, balance: await balanceOf(wallet.id)));
    }
    result.sort((a, b) => b.balance.compareTo(a.balance));
    return result;
  }
dart
  /// The opening balance plus the effect of every transaction.
  Future<Money> balanceOf(String walletId) async {
    final wallet = await walletById(walletId);
    final transactions = await _transactions.findAll();
    return wallet.openingBalance +
        transactions.sumMoney((t) => t.effectOn(walletId));
  }
dart
  /// The transactions that touch [walletId], oldest first.
  Future<List<Transaction>> historyOf(String walletId) async {
    await walletById(walletId);
    final transactions = await _transactions.findAll();
    return transactions.forWallet(walletId).sortedByDate();
  }

openWallet now asks if (await _wallets.exists(id)), and _nextId uses await _transactions.count().

balances() is new. Each row is a record ({Wallet wallet, Money balance}) (Lecture 076): no class needed for a pair that only travels from the service to the screen. Sorting with b.balance.compareTo(a.balance) puts the richest first. The loop still waits for one balance at a time; we will run them in parallel in Lecture 137.


6. The Report as a Pipeline

Open lib/src/services/budget_service.dart. Replace the imports of date_extensions.dart and transaction.dart with import '../extensions/iterable_extensions.dart';, then rewrite monthlyReport:

dart
  /// Totals for the month that contains [month], optionally for one wallet.
  Future<MonthlyReport> monthlyReport(
    DateTime month, {
    String? walletId,
    int top = 3,
  }) async {
    final transactions = (await _ledger.transactions())
        .inMonth(month)
        .forWallet(walletId);
    final expenses = transactions.expenses.toList();
    final spentByCategory = expenses.groupBy((e) => e.category);
    final limits = {
      for (final budget in await _budgets.findAll())
        budget.category: budget.monthlyLimit,
    };
    final shown = {...spentByCategory.keys, ...limits.keys};

    return MonthlyReport(
      month: month,
      income: transactions.incomes.sumMoney((t) => t.amount),
      spent: expenses.sumMoney((e) => e.amount),
      lines: [
        for (final category in Category.values)
          if (shown.contains(category))
            (
              category: category,
              spent: (spentByCategory[category] ?? []).sumMoney(
                (e) => e.amount,
              ),
              limit: limits[category],
            ),
      ],
      topExpenses: (expenses..sort((a, b) => b.amount.compareTo(a.amount)))
          .take(top)
          .toList(),
    );
  }

Read it top to bottom:

  • .toList() freezes the lazy expenses pipeline once. We use the list four times, and we sort it — an Iterable has no sort.
  • limits is a map built with collection for (Lecture 049): one read of the budgets instead of one findById per category.
  • shown is a set union from two spreads (Lectures 047–048): every category that has spending or a limit, each once.
  • lines keeps the Category.values order with collection for + if.
  • topExpenses sorts the local copy largest first and takes top of them.

In lib/src/models/monthly_report.dart, import transaction.dart, add this.topExpenses = const [], to the constructor, and add the field:

dart
  /// The biggest expenses of the month, largest first.
  final List<Expense> topExpenses;

and export both new files from lib/budget_tracker.dart:

dart
export 'src/extensions/iterable_extensions.dart';
export 'src/repository/repository_queries.dart';

7. The CLI Changes

Three small edits in bin/budget_tracker.dart (excerpts). wallets destructures each record right in the loop (Lecture 078):

dart
      for (final (wallet: w, :balance) in await ledger.balances()) {

report accepts an optional wallet and prints the biggest expenses:

dart
      var walletId = args.length > 2 ? args[2] : null;
      var report = await budgets.monthlyReport(month, walletId: walletId);
      print('Report for ${month.monthLabel} (${walletId ?? 'all wallets'})');
dart
      if (report.topExpenses.isNotEmpty) {
        print('  Biggest expenses:');
        for (final e in report.topExpenses) {
          print('    ${e.describe()}');
        }
      }

The rest of the CLI keeps its rough first-draft style; we clean it up in Lecture 139.


8. Run It

Delete the old data/ folder, then:

output
$ dart run budget_tracker demo
Demo data created for Ali, Sara and Zayan.

$ dart run budget_tracker wallets
ali     Ali     Pocket money    Rs 3300.00
zayan   Zayan   Cash            Rs 1400.00
sara    Sara    Bank card       Rs 1200.00

$ dart run budget_tracker report
Report for 2026-10 (all wallets)
  Income: Rs 6500.00
  Spent:  Rs 2600.00
  Net:    Rs 3900.00
  Food        Rs 1200.00 / Rs 1500.00   Near limit
  Transport    Rs 600.00 / Rs 500.00    OVER BUDGET
  Books        Rs 800.00 / no limit     -
  Fun            Rs 0.00 / Rs 1000.00   OK
  Biggest expenses:
    2026-10-03  Food expense (groceries)  Rs 1200.00
    2026-10-04  Books expense  Rs 800.00
    2026-10-06  Transport expense  Rs 600.00

$ dart run budget_tracker report 2026-10 ali
Report for 2026-10 (ali)
  Income: Rs 5000.00
  Spent:  Rs 1200.00
  Net:    Rs 3800.00
  Food        Rs 1200.00 / Rs 1500.00   Near limit
  Transport      Rs 0.00 / Rs 500.00    OK
  Fun            Rs 0.00 / Rs 1000.00   OK
  Biggest expenses:
    2026-10-03  Food expense (groceries)  Rs 1200.00

$ dart run budget_tracker history zayan
t0002  2026-10-02  Income from Tutoring  Rs 1500.00
t0005  2026-10-05  Transfer ali -> zayan (bus pass share)  Rs 500.00
t0006  2026-10-06  Transport expense  Rs 600.00

Zayan now comes before Sara: wallets is sorted by balance. Ali's report shows Transport and Fun because they have limits — the set union at work — but not Books. Use your own month in report yyyy-mm ali. dart analyze prints No issues found! and dart test prints +1: All tests passed!.


Code Examples

The examples are small programs in a new example/ folder of the project. Most start from the same data as demo, with fixed October 2026 dates. Create example/seed.dart once:

dart
import 'package:budget_tracker/budget_tracker.dart';

/// An in-memory app holding the `demo` data, dated October 2026.
Future<BudgetApp> seededApp() async {
  final app = BudgetApp.inMemory();
  final ledger = app.ledger;
  DateTime day(int d) => DateTime(2026, 10, d);
  await ledger.openWallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
  await ledger.openWallet(
    id: 'sara',
    owner: 'Sara',
    name: 'Bank card',
    openingBalance: Money(200000),
  );
  await ledger.openWallet(id: 'zayan', owner: 'Zayan', name: 'Cash');
  await ledger.recordIncome(
    'ali',
    Money(500000),
    source: 'Allowance',
    on: day(1),
  );
  await ledger.recordIncome(
    'zayan',
    Money(150000),
    source: 'Tutoring',
    on: day(2),
  );
  await ledger.recordExpense(
    'ali',
    Money(120000),
    .food,
    note: 'groceries',
    on: day(3),
  );
  await ledger.recordExpense('sara', Money(80000), .books, on: day(4));
  await ledger.transfer(
    'ali',
    'zayan',
    Money(50000),
    note: 'bus pass share',
    on: day(5),
  );
  await ledger.recordExpense('zayan', Money(60000), .transport, on: day(6));
  await app.budgets.setBudget(.food, Money(150000));
  await app.budgets.setBudget(.transport, Money(50000));
  await app.budgets.setBudget(.fun, Money(100000));
  return app;
}

Run each example with dart run example/<file>.dart.

Example 1 — groupBy on Plain Strings (example/group_names.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

void main() {
  final names = ['Ali', 'Sara', 'Asad', 'Zayan'];
  final byLetter = names.groupBy((name) => name[0]);
  print(byLetter);
  final byLength = names.groupBy((name) => name.length);
  print(byLength);
}
output
{A: [Ali, Asad], S: [Sara], Z: [Zayan]}
{3: [Ali], 4: [Sara, Asad], 5: [Zayan]}

The same method gives a Map<String, List<String>> and then a Map<int, List<String>>: K is inferred from the function.

Example 2 — Totals with sumMoney (example/spending_totals.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

import 'seed.dart';

Future<void> main() async {
  final app = await seededApp();
  final all = await app.ledger.transactions();
  final aliSpent = all.forWallet('ali').expenses.sumMoney((e) => e.amount);
  final everyone = all.expenses.sumMoney((e) => e.amount);
  print('Ali spent ${aliSpent.format()}');
  print('Everyone spent ${everyone.format()}');
}
output
Ali spent Rs 1200.00
Everyone spent Rs 2600.00

Example 3 — whereType Keeps the Type (example/incomes_only.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

import 'seed.dart';

Future<void> main() async {
  final all = await (await seededApp()).ledger.transactions();
  Iterable<Income> incomes = all.whereType<Income>();
  for (final income in incomes) {
    print('${income.source}: ${income.amount.format()}');
  }
  // Iterable<Income> wrong = all.where((t) => t is Income); // ✗
}
output
Allowance: Rs 5000.00
Tutoring: Rs 1500.00

Un-commenting the last line gives:

output
Error: A value of type 'Iterable<Transaction>' can't be assigned to a variable of type 'Iterable<Income>'.

Example 4 — Sets of Categories (example/category_sets.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

import 'seed.dart';

Future<void> main() async {
  final app = await seededApp();
  final all = await app.ledger.transactions();
  final spentOn = {for (final e in all.expenses) e.category};
  final limited = {for (final b in await app.budgets.budgets()) b.category};
  print(spentOn.map((c) => c.label));
  print(limited.difference(spentOn).map((c) => c.label));
  print({...spentOn, ...limited}.length);
}
output
(Food, Books, Transport)
(Fun)
4

Fun has a limit but no spending; the union has four categories — the four report lines.

Example 5 — where on a Repository (example/repository_where.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

Future<void> main() async {
  final wallets = InMemoryRepository<Wallet, String>([
    const Wallet(id: 'sara', owner: 'Sara', name: 'Bank card'),
    const Wallet(id: 'sara-cash', owner: 'Sara', name: 'Cash'),
    const Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money'),
  ]);
  final saras = await wallets.where((w) => w.owner == 'Sara');
  print(saras);
  print(await wallets.count());
  print(await wallets.exists('zayan'));
}
output
[Wallet(sara, Sara, Bank card), Wallet(sara-cash, Sara, Cash)]
3
false

Example 6 — Destructuring balances() (example/balances_table.dart)

dart
import 'seed.dart';

Future<void> main() async {
  final app = await seededApp();
  for (final (:wallet, :balance) in await app.ledger.balances()) {
    print('${wallet.owner.padRight(6)} ${balance.format()}');
  }
  final [(wallet: richest, balance: _), ...] = await app.ledger.balances();
  print('Richest: ${richest.owner}');
}
output
Ali    Rs 3300.00
Zayan  Rs 1400.00
Sara   Rs 1200.00
Richest: Ali

The second pattern combines a list pattern and a record pattern: take the first row, ignore its balance.

Example 7 — The top Parameter (example/top_expense.dart)

dart
import 'seed.dart';

Future<void> main() async {
  final app = await seededApp();
  final report = await app.budgets.monthlyReport(DateTime(2026, 10), top: 1);
  print(report.topExpenses.single.describe());
  final none = await app.budgets.monthlyReport(DateTime(2026, 9));
  print('${none.lines.length} lines, ${none.topExpenses.length} expenses');
}
output
2026-10-03  Food expense (groceries)  Rs 1200.00
3 lines, 0 expenses

September has no transactions, but the three budgets still produce lines.

Example 8 — A Repository Keyed by Category (example/budget_ids.dart)

dart
import 'package:budget_tracker/budget_tracker.dart';

Future<void> main() async {
  final budgets = InMemoryRepository<Budget, Category>([
    Budget(category: .food, monthlyLimit: Money(150000)),
    Budget(category: .fun, monthlyLimit: Money(100000)),
  ]);
  print(await budgets.exists(.food));
  print(await budgets.exists(.rent));
  final big = await budgets.where((b) => b.monthlyLimit > Money(120000));
  print(big.map((b) => b.category.label));
}
output
true
false
(Food)

The same RepositoryQueries code, with Id = Category: exists accepts .food.


Common Beginner Mistakes

1. Sorting an Iterable

dart
// all.expenses.sort((a, b) => a.amount.compareTo(b.amount)); // ✗
output
Error: The method 'sort' isn't defined for the type 'Iterable<Expense>'.

Call .toList() first.

2. Sorting a repository's list in place

dart
final wallets = await repo.findAll();
// wallets.sort((a, b) => a.id.compareTo(b.id)); // ✗ crashes at run time
output
Unhandled exception:
Unsupported operation: Cannot modify an unmodifiable list

findAll returns List.unmodifiable. Copy first, as sortedByDate does: [...wallets]..sort(...).

3. Using where with is to change the type

where((t) => t is Expense) keeps Iterable<Transaction> (Example 3). Use whereType<Expense>().

4. Writing the loop again in every service

If a loop adds, filters or groups, it probably belongs in an extension that already has a name.


Best Practices

  • Name a pipeline step once (inMonth, expenses) and reuse it everywhere.
  • Put helpers on the most general type that works: Iterable<T>, or the Repository interface.
  • Start fold from a zero value so empty collections are safe.
  • Call .toList() when a pipeline result is used more than once or must be sorted.
  • Never sort data you do not own; copy it with a spread.
  • Use a record for small results that only travel to the caller.

Mini Challenge

Print how much each wallet spent, using groupBy and sumMoney (example/per_wallet.dart).

output
ali spent Rs 1200.00
sara spent Rs 800.00
zayan spent Rs 600.00

Try it yourself before opening the solution.

Show solution
dart
import 'package:budget_tracker/budget_tracker.dart';

import 'seed.dart';

Future<void> main() async {
  final all = await (await seededApp()).ledger.transactions();
  final byWallet = all.expenses.groupBy((e) => e.walletId);
  for (final MapEntry(key: id, value: expenses) in byWallet.entries) {
    print('$id spent ${expenses.sumMoney((e) => e.amount).format()}');
  }
}

Practice Questions

Save each answer as a file in example/ that imports seed.dart.

Beginner

  1. Group all transactions by walletId and print each id with how many transactions it has.

  2. Print the total income of October 2026, then of November 2026.

  3. This line does not compile. Fix it so that it prints the cheapest expense with describe():

    dart
    // all.expenses.sort((a, b) => a.amount.compareTo(b.amount)); // ✗

Application

  1. Open a wallet bilal (owner Bilal, Piggy bank, opening Money(120000)), then print all balances richest first, with ties sorted by owner name.
  2. Write an extension ExpenseStats on Iterable<Expense> with Money? averageExpense() that returns null for no expenses. Print it for everyone, for sara, and for September 2026.
  3. After demo, run report for Zayan's wallet in your current month. Which lines appear, and why is Books missing?
  4. Print the categories nobody spent on in October 2026, using a set difference.

Challenge

  1. Write a generic extension method T? maxBy<K extends Comparable<K>>(K Function(T item) keyOf) on Iterable<T>. Use it to find the biggest expense, the longest of ['Ali', 'Sara', 'Zayan'], and the biggest of an empty <Expense>[].

Practice Answers

Each solution starts with import 'package:budget_tracker/budget_tracker.dart'; and import 'seed.dart';, left out below.

Question 1 — Solution

dart
Future<void> main() async {
  final all = await (await seededApp()).ledger.transactions();
  final byWallet = all.groupBy((t) => t.walletId);
  for (final MapEntry(:key, :value) in byWallet.entries) {
    print('$key: ${value.length}');
  }
}
output
ali: 3
zayan: 2
sara: 1

The transfer counts for ali: its walletId is the sender.

Question 2 — Solution

dart
Future<void> main() async {
  final all = await (await seededApp()).ledger.transactions();
  final october = all.inMonth(DateTime(2026, 10));
  print(october.incomes.sumMoney((t) => t.amount).format());
  print(
    all.inMonth(DateTime(2026, 11)).incomes.sumMoney((t) => t.amount).format(),
  );
}
output
Rs 6500.00
Rs 0.00

Question 3 — Solution

dart
Future<void> main() async {
  final all = await (await seededApp()).ledger.transactions();
  final cheapestFirst = all.expenses.toList()
    ..sort((a, b) => a.amount.compareTo(b.amount));
  print(cheapestFirst.first.describe());
}
output
2026-10-06  Transport expense  Rs 600.00

Question 4 — Solution

dart
Future<void> main() async {
  final app = await seededApp();
  await app.ledger.openWallet(
    id: 'bilal',
    owner: 'Bilal',
    name: 'Piggy bank',
    openingBalance: Money(120000),
  );
  final rows = await app.ledger.balances()
    ..sort((a, b) {
      final byBalance = b.balance.compareTo(a.balance);
      if (byBalance != 0) return byBalance;
      return a.wallet.owner.compareTo(b.wallet.owner);
    });
  for (final (:wallet, :balance) in rows) {
    print('${wallet.owner.padRight(6)} ${balance.format()}');
  }
}
output
Ali    Rs 3300.00
Zayan  Rs 1400.00
Bilal  Rs 1200.00
Sara   Rs 1200.00

balances() returns a fresh list, so sorting it again is safe.

Question 5 — Solution

dart
extension ExpenseStats on Iterable<Expense> {
  /// The average amount, or `null` when there are no expenses.
  Money? averageExpense() {
    if (isEmpty) return null;
    return Money(sumMoney((e) => e.amount).cents ~/ length);
  }
}

Future<void> main() async {
  final all = await (await seededApp()).ledger.transactions();
  print(all.expenses.averageExpense()?.format());
  print(all.forWallet('sara').expenses.averageExpense()?.format());
  print(all.inMonth(DateTime(2026, 9)).expenses.averageExpense()?.format());
}
output
Rs 866.66
Rs 800.00
null

~/ drops the leftover paisa: 260000 ÷ 3 = 86666.

Question 6 — Solution

output
$ dart run budget_tracker report 2026-10 zayan
Report for 2026-10 (zayan)
  Income: Rs 1500.00
  Spent:  Rs 600.00
  Net:    Rs 900.00
  Food           Rs 0.00 / Rs 1500.00   OK
  Transport    Rs 600.00 / Rs 500.00    OVER BUDGET
  Fun            Rs 0.00 / Rs 1000.00   OK
  Biggest expenses:
    2026-10-06  Transport expense  Rs 600.00

Food, Transport and Fun have limits, so the union includes them. Books has no limit and no spending in Zayan's wallet. The transfer from Ali is in the filtered list but counts as neither income nor spending.

Question 7 — Solution

dart
Future<void> main() async {
  final all = await (await seededApp()).ledger.transactions();
  final spentOn = {
    for (final e in all.inMonth(DateTime(2026, 10)).expenses) e.category,
  };
  final untouched = Category.values.toSet().difference(spentOn);
  print(untouched.map((c) => c.label).join(', '));
}
output
Rent, Fun, Other

Question 8 — Solution

dart
extension MaxBy<T> on Iterable<T> {
  /// The item with the largest key, or `null` when empty.
  T? maxBy<K extends Comparable<K>>(K Function(T item) keyOf) {
    T? best;
    K? bestKey;
    for (final item in this) {
      final key = keyOf(item);
      if (bestKey == null || key.compareTo(bestKey) > 0) {
        best = item;
        bestKey = key;
      }
    }
    return best;
  }
}

Future<void> main() async {
  final all = await (await seededApp()).ledger.transactions();
  print(all.expenses.maxBy((e) => e.amount.cents)?.describe());
  print(['Ali', 'Sara', 'Zayan'].maxBy((name) => name.length));
  print(<Expense>[].maxBy((e) => e.amount.cents));
}
output
2026-10-03  Food expense (groceries)  Rs 1200.00
Zayan
null

Money is an extension type and is not Comparable, so the key is amount.cents.


Quick Revision

  1. Which type parameter of groupBy<K> belongs to the extension, and which to the method?
  2. Why does sumMoney take a function instead of reading item.amount?
  3. What does forWallet(null) return?
  4. What is the difference between whereType<Income>() and where((t) => t is Income)?
  5. Why does sortedByDate copy the list before sorting?
  6. How does one RepositoryQueries extension serve both Wallet and Budget repositories?
  7. Why does Ali's report show Fun but not Books?

Key Takeaways

  • A generic extension on Iterable<T> gives every collection the same helpers.
  • A method-level type parameter (groupBy<K>) is chosen at each call.
  • fold(Money.zero, ...) adds up money and handles empty lists.
  • Named queries (inMonth, forWallet, expenses) turn loops into readable pipelines.
  • An extension on an interface, with the interface's bound, serves every implementation.
  • Records carry small results; destructure them right in the for loop.
  • Collection for/if, map literals and set unions build the report without counters.
  • Copy before sorting data you do not own; toList() before sorting a pipeline.

What's Next?

Lecture 137 — Async Architecture. The services are shorter now, but nothing reacts when money moves: an expense that breaks a budget is only noticed when someone asks for a report. Next we load the three data files in parallel, publish every change on a broadcast stream, and let a budget monitor and an activity log listen to it.


Video lecture

Subscribe
Placeholder video — the real recording isn't linked yet.