On this page20 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. Where the Loops Are
- 2. A Generic Toolbox: IterableTools<T>
- 3. Naming the Questions: TransactionQueries
- 4. One Extension for Every Repository
- 5. A Shorter LedgerService
- 6. The Report as a Pipeline
- 7. The CLI Changes
- 8. Run It
- Code Examples
- Common Beginner Mistakes
- Best Practices
- Mini Challenge
- Practice Questions
- Practice Answers
- Quick Revision
- Key Takeaways
- What's Next?
Lecture 136 — Collections & Generics in the Project
| Category | 23 — Final Dart Project |
| Lecture | 136 of 140 |
| Estimated video length | 15 min |
| Target | Dart 3.12.2 |
| Level | Advanced |
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
democommand 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,sortand 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):
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
Moneyin any iterable withfold - 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
forloop - 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
reportcommand
1. Where the Loops Are
Four places in the Lecture 135 code loop by hand:
| Code | Job | Collection tool |
|---|---|---|
balanceOf | add up effects | fold |
historyOf | keep matching items | where |
monthlyReport | split by type, count per category | whereType, grouping |
_nextId | count items | length |
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:
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>:
/// 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>()returnsIterable<Expense>, so.categoryis available afterwards.where((t) => t is Expense)would still be anIterable<Transaction>.sortedByDatecopies with a spread first. Repositories returnList.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:
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:
import '../extensions/iterable_extensions.dart';
import '../repository/repository_queries.dart';Then replace the loops (excerpts):
/// 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;
} /// 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));
} /// 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:
/// 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 — anIterablehas nosort.limitsis a map built with collectionfor(Lecture 049): one read of the budgets instead of onefindByIdper category.shownis a set union from two spreads (Lectures 047–048): every category that has spending or a limit, each once.lineskeeps theCategory.valuesorder with collectionfor+if.topExpensessorts the local copy largest first and takestopof them.
In lib/src/models/monthly_report.dart, import transaction.dart, add
this.topExpenses = const [], to the constructor, and add the field:
/// The biggest expenses of the month, largest first.
final List<Expense> topExpenses;and export both new files from lib/budget_tracker.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):
for (final (wallet: w, :balance) in await ledger.balances()) {report accepts an optional wallet and prints the biggest expenses:
var walletId = args.length > 2 ? args[2] : null;
var report = await budgets.monthlyReport(month, walletId: walletId);
print('Report for ${month.monthLabel} (${walletId ?? 'all wallets'})'); 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:
$ 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.00Zayan 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:
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)
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);
}{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)
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()}');
}Ali spent Rs 1200.00
Everyone spent Rs 2600.00Example 3 — whereType Keeps the Type (example/incomes_only.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); // ✗
}Allowance: Rs 5000.00
Tutoring: Rs 1500.00Un-commenting the last line gives:
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)
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);
}(Food, Books, Transport)
(Fun)
4Fun 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)
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'));
}[Wallet(sara, Sara, Bank card), Wallet(sara-cash, Sara, Cash)]
3
falseExample 6 — Destructuring balances() (example/balances_table.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}');
}Ali Rs 3300.00
Zayan Rs 1400.00
Sara Rs 1200.00
Richest: AliThe 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)
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');
}2026-10-03 Food expense (groceries) Rs 1200.00
3 lines, 0 expensesSeptember has no transactions, but the three budgets still produce lines.
Example 8 — A Repository Keyed by Category (example/budget_ids.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));
}true
false
(Food)The same RepositoryQueries code, with Id = Category: exists accepts .food.
Common Beginner Mistakes
1. Sorting an Iterable
// all.expenses.sort((a, b) => a.amount.compareTo(b.amount)); // ✗Error: The method 'sort' isn't defined for the type 'Iterable<Expense>'.Call .toList() first.
2. Sorting a repository's list in place
final wallets = await repo.findAll();
// wallets.sort((a, b) => a.id.compareTo(b.id)); // ✗ crashes at run timeUnhandled exception:
Unsupported operation: Cannot modify an unmodifiable listfindAll 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 theRepositoryinterface. - Start
foldfrom 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).
ali spent Rs 1200.00
sara spent Rs 800.00
zayan spent Rs 600.00Try it yourself before opening the solution.
Show solution
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
-
Group all transactions by
walletIdand print each id with how many transactions it has. -
Print the total income of October 2026, then of November 2026.
-
This line does not compile. Fix it so that it prints the cheapest expense with
describe():// all.expenses.sort((a, b) => a.amount.compareTo(b.amount)); // ✗
Application
- Open a wallet
bilal(ownerBilal,Piggy bank, openingMoney(120000)), then print all balances richest first, with ties sorted by owner name. - Write an extension
ExpenseStatsonIterable<Expense>withMoney? averageExpense()that returnsnullfor no expenses. Print it for everyone, forsara, and for September 2026. - After
demo, runreportfor Zayan's wallet in your current month. Which lines appear, and why is Books missing? - Print the categories nobody spent on in October 2026, using a set difference.
Challenge
- Write a generic extension method
T? maxBy<K extends Comparable<K>>(K Function(T item) keyOf)onIterable<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
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}');
}
}ali: 3
zayan: 2
sara: 1The transfer counts for ali: its walletId is the sender.
Question 2 — Solution
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(),
);
}Rs 6500.00
Rs 0.00Question 3 — Solution
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());
}2026-10-06 Transport expense Rs 600.00Question 4 — Solution
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()}');
}
}Ali Rs 3300.00
Zayan Rs 1400.00
Bilal Rs 1200.00
Sara Rs 1200.00balances() returns a fresh list, so sorting it again is safe.
Question 5 — Solution
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());
}Rs 866.66
Rs 800.00
null~/ drops the leftover paisa: 260000 ÷ 3 = 86666.
Question 6 — Solution
$ 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.00Food, 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
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(', '));
}Rent, Fun, OtherQuestion 8 — Solution
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));
}2026-10-03 Food expense (groceries) Rs 1200.00
Zayan
nullMoney is an extension type and is not Comparable, so the key is amount.cents.
Quick Revision
- Which type parameter of
groupBy<K>belongs to the extension, and which to the method? - Why does
sumMoneytake a function instead of readingitem.amount? - What does
forWallet(null)return? - What is the difference between
whereType<Income>()andwhere((t) => t is Income)? - Why does
sortedByDatecopy the list before sorting? - How does one
RepositoryQueriesextension serve bothWalletandBudgetrepositories? - 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
forloop. - 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.