On this page24 sections
- Prerequisites
- Connection With Previous Lectures
- Learning Objectives
- 1. The Model Layer
- 2. Money — An Extension Type Over int
- 3. Category — An Enhanced Enum
- 4. DateLabels — A Small Extension
- 5. Wallet — An Immutable Class
- 6. Budget — The Same Recipe, Smaller
- 7. Transaction — A Sealed Family
- 8. effectOn — The Heart of Every Balance
- 9. describe — One Line per Transaction
- 10. JSON With a type Key
- 11. The Compiler as a To-Do List
- 12. Wire It Up and Run
- Code Examples
- Common Beginner Mistakes
- Best Practices
- Mini Challenge
- Practice Questions
- Practice Answers
- Quick Revision
- Key Takeaways
- What's Next?
Lecture 133 — Project Models
| Category | 23 — Final Dart Project |
| Lecture | 133 of 140 |
| Estimated video length | 20 min |
| Target | Dart 3.12.2 |
| Level | Advanced |
The plan from Lecture 132 named the nouns of the Student Budget Tracker: money, categories, wallets, budgets and transactions. Today each noun becomes a small, immutable Dart type that knows how to print itself, copy itself and travel as JSON.
Contents
Prerequisites · Connection · Objectives · Lesson · Code Examples · Common Mistakes · Best Practices · Mini Challenge · Practice Questions · Answers · Takeaways
Every error message in this lecture is real. Each one was produced by running the code shown on Dart 3.12.2, so you will recognize it when you meet it yourself.
Prerequisites
Students should already understand, from Lectures 054–055, 068–070, 080–082, 109, 124 and 130:
const, factory constructors,==/hashCode,copyWithandfinal class- enhanced enums, extension methods and extension types
- map and object patterns, sealed classes,
fromJson/toJson
You also need the budget_tracker project as it stands at the end of Lecture 132.
Connection With Previous Lectures
At the end of Lecture 132 the project could only describe itself:
$ dart run
Student Budget Tracker v0.1.0
Planned commands:
...Ali needs a wallet and Zayan needs to record lunch, but nothing can hold a wallet or a lunch.
Before any rule can exist, the data needs a shape: the model layer, the bottom of
bin → services → repository → models.
Learning Objectives
By the end of this lecture you will be able to:
- Store money safely as whole paisa in an extension type with operators
- Model a fixed list of categories as an enhanced enum with a safe
tryParse - Build immutable
WalletandBudgetclasses withconst,copyWithand JSON - Model income, expense and transfer as one sealed
Transactionfamily - Compute a wallet's balance change with a single exhaustive
switch - Write and read a
typediscriminator in JSON with map patterns - Use the compiler's exhaustiveness errors as a to-do list
1. The Model Layer
Create two folders inside lib/src/ and six files:
lib/src/
├── app_info.dart (from Lecture 132)
├── extensions/
│ └── date_extensions.dart new
└── models/
├── money.dart new
├── category.dart new
├── wallet.dart new
├── budget.dart new
└── transaction.dart newModels import only each other and the date extension. They never print or read files.
2. Money — An Extension Type Over int
Lecture 132 showed that 0.1 + 0.2 is not 0.3 with double. So we count paisa in an
int: Rs 450.50 is 45050. Create lib/src/models/money.dart:
/// An amount of money stored as whole cents (paisa), so sums never lose
/// precision the way `double` does.
extension type const Money(int cents) {
/// No money at all.
static const zero = Money(0);
/// Reads text such as `250` or `12.50`; returns `null` for anything else.
static Money? tryParse(String text) {
final value = double.tryParse(text.trim());
if (value == null || value.isNaN || value.isInfinite) return null;
return Money((value * 100).round());
}
Money operator +(Money other) => Money(cents + other.cents);
Money operator -(Money other) => Money(cents - other.cents);
Money operator -() => Money(-cents);
bool operator >(Money other) => cents > other.cents;
bool operator <(Money other) => cents < other.cents;
/// Whether this amount is more than zero.
bool get isPositive => cents > 0;
/// Compares two amounts, for sorting.
int compareTo(Money other) => cents.compareTo(other.cents);
/// Formats the amount as `Rs 12.50` or `-Rs 3.00`.
String format() {
final sign = cents < 0 ? '-' : '';
final rupees = cents.abs() ~/ 100;
final paisa = (cents.abs() % 100).toString().padLeft(2, '0');
return '${sign}Rs $rupees.$paisa';
}
}Why an extension type (Lecture 070) and not a class?
- No cost. At runtime a
Moneyis anint. - No mix-ups.
Money + intis a compile error. - Erased at runtime.
print(Money(5))prints5, which is whyformat()exists.
const before Money(int cents) allows const Money(500000). The operators (Lecture 021) keep
results Money. tryParse returns null for bad text; the services will turn that into an
error message.
3. Category — An Enhanced Enum
Spending categories are a fixed list, so they are an enum (Lecture 068). Create
lib/src/models/category.dart:
/// What an expense was spent on.
enum Category {
food('Food', isEssential: true),
transport('Transport', isEssential: true),
books('Books', isEssential: true),
rent('Rent', isEssential: true),
fun('Fun'),
other('Other');
const Category(this.label, {this.isEssential = false});
/// The name shown to the user.
final String label;
/// Whether a student really needs this kind of spending.
final bool isEssential;
/// Finds a category by its name, ignoring case; `null` when unknown.
static Category? tryParse(String text) {
final wanted = text.trim().toLowerCase();
for (final category in values) {
if (category.name == wanted) return category;
}
return null;
}
/// Reads a category saved in JSON; throws [FormatException] if unknown.
static Category fromJson(String name) =>
tryParse(name) ?? (throw FormatException('Unknown category: $name'));
}Lecture 109 used values.byName for enums in JSON, but it throws an ArgumentError. We want
every bad-data problem to be a FormatException, so a damaged file can be reported in one
place; ?? (throw ...) does that in one line.
4. DateLabels — A Small Extension
Transactions print dates as 2026-10-03 and reports group by month. Create
lib/src/extensions/date_extensions.dart:
/// Small date helpers used by the models and reports.
extension DateLabels on DateTime {
/// The date as `yyyy-mm-dd`.
String get dateLabel => toIso8601String().substring(0, 10);
/// The month as `yyyy-mm`.
String get monthLabel => toIso8601String().substring(0, 7);
/// Whether this date is in the same month and year as [other].
bool isSameMonth(DateTime other) =>
year == other.year && month == other.month;
}An extension (Lecture 069) adds these to every DateTime.
5. Wallet — An Immutable Class
Create lib/src/models/wallet.dart:
import 'money.dart';
/// A place where a student keeps money, such as cash or a bank card.
final class Wallet {
const Wallet({
required this.id,
required this.owner,
required this.name,
this.openingBalance = Money.zero,
});
/// Reads a wallet from decoded JSON; throws [FormatException] if invalid.
factory Wallet.fromJson(Map<String, Object?> json) => switch (json) {
{
'id': String id,
'owner': String owner,
'name': String name,
'openingBalance': int cents,
} =>
Wallet(id: id, owner: owner, name: name, openingBalance: Money(cents)),
_ => throw FormatException('Invalid wallet: $json'),
};
final String id;
final String owner;
final String name;
final Money openingBalance;
/// Returns a new wallet with some fields replaced.
Wallet copyWith({String? owner, String? name, Money? openingBalance}) =>
Wallet(
id: id,
owner: owner ?? this.owner,
name: name ?? this.name,
openingBalance: openingBalance ?? this.openingBalance,
);
Map<String, Object?> toJson() => {
'id': id,
'owner': owner,
'name': name,
'openingBalance': openingBalance.cents,
};
@override
bool operator ==(Object other) =>
other is Wallet &&
other.id == id &&
other.owner == owner &&
other.name == name &&
other.openingBalance == openingBalance;
@override
int get hashCode => Object.hash(id, owner, name, openingBalance);
@override
String toString() => 'Wallet($id, $owner, $name)';
}finalfields and aconstconstructor: a wallet never changes (Lecture 124).copyWithbuilds a new wallet.idis not a parameter: an identity never changes.fromJson's single map pattern checks every key and its type; anything else is aFormatException. JSON stores cents:"openingBalance": 200000is Rs 2000.00.==andhashCodecompare by value, so a wallet read back from a file equals the saved one.final class(Lecture 130): other libraries can useWalletbut not subclass it.
6. Budget — The Same Recipe, Smaller
Create lib/src/models/budget.dart:
import 'category.dart';
import 'money.dart';
/// A monthly spending limit for one category.
final class Budget {
const Budget({required this.category, required this.monthlyLimit});
/// Reads a budget from decoded JSON; throws [FormatException] if invalid.
factory Budget.fromJson(Map<String, Object?> json) => switch (json) {
{'category': String name, 'monthlyLimit': int cents} => Budget(
category: Category.fromJson(name),
monthlyLimit: Money(cents),
),
_ => throw FormatException('Invalid budget: $json'),
};
final Category category;
final Money monthlyLimit;
/// Returns a new budget with a different limit.
Budget copyWith({Money? monthlyLimit}) => Budget(
category: category,
monthlyLimit: monthlyLimit ?? this.monthlyLimit,
);
Map<String, Object?> toJson() => {
'category': category.name,
'monthlyLimit': monthlyLimit.cents,
};
@override
String toString() => 'Budget(${category.label}: ${monthlyLimit.format()})';
}The category is saved by its name ("food") and read back through Category.fromJson.
7. Transaction — A Sealed Family
A transaction is always exactly one of three kinds, each with one extra field: income has
a source, an expense a category, a transfer a toWalletId. That is a sealed class
(Lecture 082). Create lib/src/models/transaction.dart; we write it in four parts. First the
shared fields and the three subclasses (the subclasses go at the bottom of the file):
import '../extensions/date_extensions.dart';
import 'category.dart';
import 'money.dart';
/// One movement of money. Every transaction is exactly one of
/// [Income], [Expense] or [Transfer].
sealed class Transaction {
const Transaction({
required this.id,
required this.walletId,
required this.amount,
required this.date,
this.note = '',
});
// fromJson goes here (section 10)
final String id;
final String walletId;
final Money amount;
final DateTime date;
final String note;
// involves, effectOn, describe and toJson go here (sections 8-10)
}
/// Money coming into a wallet, such as pocket money or a salary.
final class Income extends Transaction {
const Income({
required super.id,
required super.walletId,
required super.amount,
required super.date,
super.note,
required this.source,
});
final String source;
}
/// Money spent from a wallet on one [Category].
final class Expense extends Transaction {
const Expense({
required super.id,
required super.walletId,
required super.amount,
required super.date,
super.note,
required this.category,
});
final Category category;
/// Returns a corrected copy, for example with a different category.
Expense copyWith({Money? amount, Category? category, String? note}) =>
Expense(
id: id,
walletId: walletId,
amount: amount ?? this.amount,
date: date,
note: note ?? this.note,
category: category ?? this.category,
);
}
/// Money moved from one wallet ([walletId]) to another ([toWalletId]).
final class Transfer extends Transaction {
const Transfer({
required super.id,
required super.walletId,
required super.amount,
required super.date,
super.note,
required this.toWalletId,
});
final String toWalletId;
}super.id (Lecture 061) passes each shared field up. For a transfer, walletId is the
sender. Only Expense gets copyWith: fixing a wrong category is a real need.
8. effectOn — The Heart of Every Balance
A wallet's balance is its opening balance plus the effect of every transaction. Add these two
methods inside Transaction:
/// Whether this transaction touches the wallet with id [wallet].
bool involves(String wallet) => switch (this) {
Transfer(:final toWalletId) => walletId == wallet || toWalletId == wallet,
_ => walletId == wallet,
};
/// How this transaction changes the balance of the wallet [wallet].
Money effectOn(String wallet) {
if (!involves(wallet)) return Money.zero;
return switch (this) {
Income() => amount,
Expense() => -amount,
Transfer(:final toWalletId) => toWalletId == wallet ? amount : -amount,
};
}Income adds, an expense subtracts, a transfer subtracts from the sender and adds to the
receiver. Transfer(:final toWalletId) checks the kind and reads its field. Because
Transaction is sealed, the effectOn switch needs no _.
9. describe — One Line per Transaction
Add the method that history and reports will print:
/// A one-line description for lists and reports.
String describe() {
final what = switch (this) {
Income(:final source) => 'Income from $source',
Expense(:final category) => '${category.label} expense',
Transfer(:final toWalletId) => 'Transfer $walletId -> $toWalletId',
};
final extra = note.isEmpty ? '' : ' ($note)';
return '${date.dateLabel} $what$extra ${amount.format()}';
}10. JSON With a type Key
A JSON object has no class name, so toJson writes a discriminator, "type". Add it
inside Transaction:
Map<String, Object?> toJson() => {
'type': switch (this) {
Income() => 'income',
Expense() => 'expense',
Transfer() => 'transfer',
},
'id': id,
'walletId': walletId,
'amount': amount.cents,
'date': date.toIso8601String(),
if (note.isNotEmpty) 'note': note,
...switch (this) {
Income(:final source) => {'source': source},
Expense(:final category) => {'category': category.name},
Transfer(:final toWalletId) => {'toWalletId': toWalletId},
},
};Collection if skips an empty note; the spread switch (Lecture 046) adds each kind's own
field. Reading back is the mirror image. Add this factory right after the constructor:
/// Reads any kind of transaction from decoded JSON, using its `type` key.
factory Transaction.fromJson(Map<String, Object?> json) {
if (json case {
'id': String id,
'walletId': String walletId,
'amount': int cents,
'date': String date,
}) {
final amount = Money(cents);
final when = DateTime.parse(date);
final note = json['note'] as String? ?? '';
return switch (json) {
{'type': 'income', 'source': String source} => Income(
id: id,
walletId: walletId,
amount: amount,
date: when,
note: note,
source: source,
),
{'type': 'expense', 'category': String category} => Expense(
id: id,
walletId: walletId,
amount: amount,
date: when,
note: note,
category: Category.fromJson(category),
),
{'type': 'transfer', 'toWalletId': String toWalletId} => Transfer(
id: id,
walletId: walletId,
amount: amount,
date: when,
note: note,
toWalletId: toWalletId,
),
_ => throw FormatException('Unknown transaction type: $json'),
};
}
throw FormatException('Invalid transaction: $json');
}An if-case map pattern checks the shared fields, then a switch on type checks the kind's
own field. A factory may return a subclass, which is exactly what we need.
11. The Compiler as a To-Do List
Suppose someone later adds final class Refund extends Transaction to transaction.dart and
runs the app. Dart refuses to build:
lib/src/models/transaction.dart:73:20: Error: The type 'Transaction' is not exhaustively matched by the switch cases since it doesn't match 'Refund()'.
- 'Transaction' is from 'package:budget_tracker/src/models/transaction.dart' ('lib/src/models/transaction.dart').
Try adding a wildcard pattern or cases that match 'Refund()'.
return switch (this) {
^The same error appears for describe and both toJson switches: four places to decide what
a refund means. involves is not listed: its _ silently accepts Refund. (Remove
Refund again; it is not part of the project.)
12. Wire It Up and Run
Export the new files from lib/budget_tracker.dart:
/// Student Budget Tracker: wallets, transactions, budgets and reports.
library;
export 'src/app_info.dart';
export 'src/extensions/date_extensions.dart';
export 'src/models/budget.dart';
export 'src/models/category.dart';
export 'src/models/money.dart';
export 'src/models/transaction.dart';
export 'src/models/wallet.dart';Then replace bin/budget_tracker.dart with a temporary models demo (Lecture 135 makes bin/
the real command-line app):
import 'dart:convert';
import 'package:budget_tracker/budget_tracker.dart';
void main(List<String> arguments) {
print('$appName v$appVersion - models demo\n');
const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
final aliCard = ali.copyWith(
name: 'Bank card',
openingBalance: Money(500000),
);
print(ali);
print('${aliCard.name}: ${aliCard.openingBalance.format()}');
final transactions = <Transaction>[
Income(
id: 't0001',
walletId: 'ali',
amount: Money(300000),
date: DateTime(2026, 10, 1),
source: 'Allowance',
),
Expense(
id: 't0002',
walletId: 'ali',
amount: Money(45050),
date: DateTime(2026, 10, 3),
note: 'lunch',
category: Category.food,
),
Transfer(
id: 't0003',
walletId: 'ali',
amount: Money(100000),
date: DateTime(2026, 10, 5),
toWalletId: 'sara',
),
];
print('\nTransactions:');
for (final t in transactions) {
print(' ${t.describe()}');
}
print('\nEffect on each wallet:');
for (final t in transactions) {
print(
' ${t.id}: ali ${t.effectOn('ali').format()}, '
'sara ${t.effectOn('sara').format()}',
);
}
final json = jsonEncode(transactions);
print('\nJSON: $json');
final decoded = (jsonDecode(json) as List)
.cast<Map<String, Object?>>()
.map(Transaction.fromJson)
.toList();
print(
'Read back ${decoded.length} transactions; '
'second is ${decoded[1].runtimeType}',
);
print('\nMoney.tryParse("12.5") = ${Money.tryParse('12.5')?.format()}');
print('Money.tryParse("abc") = ${Money.tryParse('abc')}');
print('Category.tryParse("Food") = ${Category.tryParse('Food')}');
const budget = Budget(category: Category.food, monthlyLimit: Money(1000000));
print('$budget -> ${budget.copyWith(monthlyLimit: Money(800000))}');
}jsonEncode calls each transaction's toJson for us. The demo uses fixed October 2026 dates,
so your output matches this one on any day:
$ dart run
Student Budget Tracker v0.1.0 - models demo
Wallet(ali, Ali, Pocket money)
Bank card: Rs 5000.00
Transactions:
2026-10-01 Income from Allowance Rs 3000.00
2026-10-03 Food expense (lunch) Rs 450.50
2026-10-05 Transfer ali -> sara Rs 1000.00
Effect on each wallet:
t0001: ali Rs 3000.00, sara Rs 0.00
t0002: ali -Rs 450.50, sara Rs 0.00
t0003: ali -Rs 1000.00, sara Rs 1000.00
JSON: [{"type":"income","id":"t0001","walletId":"ali","amount":300000,"date":"2026-10-01T00:00:00.000","source":"Allowance"},{"type":"expense","id":"t0002","walletId":"ali","amount":45050,"date":"2026-10-03T00:00:00.000","note":"lunch","category":"food"},{"type":"transfer","id":"t0003","walletId":"ali","amount":100000,"date":"2026-10-05T00:00:00.000","toWalletId":"sara"}]
Read back 3 transactions; second is Expense
Money.tryParse("12.5") = Rs 12.50
Money.tryParse("abc") = null
Category.tryParse("Food") = Category.food
Budget(Food: Rs 10000.00) -> Budget(Food: Rs 8000.00)Finish with the quality checks. The smoke test from Lecture 132 is unchanged:
$ dart analyze
Analyzing budget_tracker...
No issues found!
$ dart test -r expanded
00:00 +0: loading test/budget_tracker_test.dart
00:00 +0: test/budget_tracker_test.dart: the app has a version
00:00 +1: All tests passed!Code Examples
Save each example in a new example/ folder (say example/wallets.dart) and run it from the
project root with dart run example/wallets.dart. These are scratch files.
Example 1 — A Wallet Compared by Value
import 'package:budget_tracker/budget_tracker.dart';
void main() {
const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
print(ali);
print(ali.openingBalance.format());
final again = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
print(ali == again);
print(identical(ali, again));
}Wallet(ali, Ali, Pocket money)
Rs 0.00
true
falseExample 2 — copyWith Leaves the Original Alone
import 'package:budget_tracker/budget_tracker.dart';
void main() {
const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
final renamed = ali.copyWith(name: 'School cash');
print(ali.name);
print(renamed.name);
print(renamed.id);
print(ali == renamed);
}Pocket money
School cash
ali
falseExample 3 — Money Arithmetic
import 'package:budget_tracker/budget_tracker.dart';
void main() {
const lunch = Money(45050);
const bus = Money(8000);
final total = lunch + bus;
print(total.format());
print((bus - lunch).format());
print(total > lunch);
print(total.cents);
print(total);
}Rs 530.50
-Rs 370.50
true
53050
53050Example 4 — Money.tryParse
import 'package:budget_tracker/budget_tracker.dart';
void main() {
for (final text in ['12.5', 'abc', ' 250 ', '0.1', 'NaN']) {
final money = Money.tryParse(text);
print('"$text" -> ${money?.format() ?? 'not an amount'}');
}
}"12.5" -> Rs 12.50
"abc" -> not an amount
" 250 " -> Rs 250.00
"0.1" -> Rs 0.10
"NaN" -> not an amountdouble.tryParse('NaN') succeeds and returns NaN, which is why tryParse checks isNaN.
Example 5 — Categories
import 'package:budget_tracker/budget_tracker.dart';
void main() {
final essentials = Category.values.where((c) => c.isEssential);
print('Essential: ${essentials.map((c) => c.label).join(', ')}');
for (final text in ['Food', ' RENT ', 'Fun', 'games']) {
print('$text -> ${Category.tryParse(text)?.label ?? 'unknown'}');
}
}Essential: Food, Transport, Books, Rent
Food -> Food
RENT -> Rent
Fun -> Fun
games -> unknownExample 6 — One Transfer, Three Points of View
import 'package:budget_tracker/budget_tracker.dart';
void main() {
final transfer = Transfer(
id: 't0010',
walletId: 'ali',
amount: Money(50000),
date: DateTime(2026, 10, 7),
note: 'bus pass share',
toWalletId: 'sara',
);
print(transfer.describe());
for (final wallet in ['ali', 'sara', 'zayan']) {
print(
'$wallet: ${transfer.involves(wallet)} '
'${transfer.effectOn(wallet).format()}',
);
}
}2026-10-07 Transfer ali -> sara (bus pass share) Rs 500.00
ali: true -Rs 500.00
sara: true Rs 500.00
zayan: false Rs 0.00Example 7 — A JSON Round Trip
import 'dart:convert';
import 'package:budget_tracker/budget_tracker.dart';
void main() {
final income = Income(
id: 't0001',
walletId: 'sara',
amount: Money(150000),
date: DateTime(2026, 10, 1),
source: 'Scholarship',
);
final json = jsonEncode(income);
print(json);
final back = Transaction.fromJson(jsonDecode(json) as Map<String, Object?>);
print(back.runtimeType);
print(back.describe());
if (back case Income(:final source)) print('source: $source');
}{"type":"income","id":"t0001","walletId":"sara","amount":150000,"date":"2026-10-01T00:00:00.000","source":"Scholarship"}
Income
2026-10-01 Income from Scholarship Rs 1500.00
source: ScholarshipExample 8 — Bad JSON Is Always a FormatException
import 'package:budget_tracker/budget_tracker.dart';
void main() {
final bad = <Map<String, Object?>>[
{
'type': 'gift',
'id': 't0009',
'walletId': 'ali',
'amount': 1000,
'date': '2026-10-09T00:00:00.000',
},
{'id': 't0011', 'walletId': 'ali', 'amount': '10.00'},
];
for (final json in bad) {
try {
Transaction.fromJson(json);
} on FormatException catch (e) {
print(e.message);
}
}
}Unknown transaction type: {type: gift, id: t0009, walletId: ali, amount: 1000, date: 2026-10-09T00:00:00.000}
Invalid transaction: {id: t0011, walletId: ali, amount: 10.00}Common Beginner Mistakes
1. Mixing Money and int
const lunch = Money(45050);
// final total = lunch + 8000; // ✗
// int cents = Money(500); // ✗Error: A value of type 'int' can't be assigned to a variable of type 'Money'.
Error: A value of type 'Money' can't be assigned to a variable of type 'int'.Write lunch + Money(8000), or read .cents when you really need the int.
2. Changing a model in place
const ali = Wallet(id: 'ali', owner: 'Ali', name: 'Pocket money');
// ali.name = 'School cash'; // ✗Error: The setter 'name' isn't defined for the type 'Wallet'.Models are immutable. Use ali.copyWith(name: 'School cash').
3. Using values.byName for user data
// print(Category.values.byName('games')); // ✗ crashes at runtimeUnhandled exception:
Invalid argument (name): No enum value with that name: "games"Use Category.tryParse (returns null) or Category.fromJson (throws FormatException).
Best Practices
- Keep models free of printing, files and business rules; they only describe data.
- Make every field
final, give classesconstconstructors, and change data withcopyWith. - Wrap meaningful numbers (money, ids) in extension types so the compiler catches mix-ups.
- Return
nullfromtryParsefor user input; throwFormatExceptionfor bad saved data. - Write a
typekey in JSON when one field can hold several kinds of object. - Avoid
_in switches over a sealed type, so a new subtype is reported everywhere.
Mini Challenge
Using only the models, add up Ali's food spending in October 2026. Use an if-case object
pattern with a when guard and isSameMonth. The September expense must not count.
Food in 2026-10: Rs 570.50Try it yourself before opening the solution.
Show solution
import 'package:budget_tracker/budget_tracker.dart';
void main() {
final month = DateTime(2026, 10);
final transactions = <Transaction>[
Expense(
id: 't1',
walletId: 'ali',
amount: Money(45050),
date: DateTime(2026, 10, 3),
category: Category.food,
),
Expense(
id: 't3',
walletId: 'ali',
amount: Money(30000),
date: DateTime(2026, 9, 28),
category: Category.food,
),
Expense(
id: 't4',
walletId: 'ali',
amount: Money(12000),
date: DateTime(2026, 10, 6),
category: Category.food,
),
];
var food = Money.zero;
for (final t in transactions) {
if (t case Expense(
category: Category.food,
:final amount,
:final date,
) when date.isSameMonth(month)) {
food = food + amount;
}
}
print('Food in ${month.monthLabel}: ${food.format()}');
}Food in 2026-10: Rs 570.50The pattern accepts only food expenses, the guard only October: Rs 450.50 + Rs 120.00.
Practice Questions
Write each answer as a program in example/.
Beginner
- Print
Money(123456),Money(5)and-Money(250)withformat(). - Sara's lunch costs Rs 320.75 and a book Rs 1200. Add them as
Moneyand print the total. - Print the label of every category that is not essential.
Application
- Predict, then check, what
Money.tryParsegives for'99.999','-20','1e3'and''. Which result should aspendcommand refuse? - Sara recorded a Rs 650 novel (note
novel, 8 October 2026) asCategory.fun. Fix it withcopyWithso it isCategory.books, and printdescribe()for both. - Sara starts with Rs 2000. She pays Rs 400 rent and transfers Rs 250 to Zayan. Compute
her balance with
foldandeffectOn. - Create a Rs 1500
Budgetfor books, print itstoJson(), then read it back withBudget.fromJsonand print the result.
Challenge
- Without editing
wallet.dart, writeWallet checkedWallet(Map<String, Object?> json)that throws aFormatExceptionfor a negativeopeningBalanceand otherwise callsWallet.fromJson. Test it with a valid wallet and with Bilal's wallet at-100cents.
Practice Answers
Question 1 — Solution
import 'package:budget_tracker/budget_tracker.dart';
void main() {
print(Money(123456).format());
print(Money(5).format());
print((-Money(250)).format());
}Rs 1234.56
Rs 0.05
-Rs 2.50Question 2 — Solution
import 'package:budget_tracker/budget_tracker.dart';
void main() {
const lunch = Money(32075);
const book = Money(120000);
print((lunch + book).format());
}Rs 1520.75Question 3 — Solution
import 'package:budget_tracker/budget_tracker.dart';
void main() {
for (final category in Category.values) {
if (!category.isEssential) print(category.label);
}
}Fun
OtherQuestion 4 — Solution
import 'package:budget_tracker/budget_tracker.dart';
void main() {
for (final text in ['99.999', '-20', '1e3', '']) {
print('"$text" -> ${Money.tryParse(text)?.format()}');
}
}"99.999" -> Rs 100.00
"-20" -> -Rs 20.00
"1e3" -> Rs 1000.00
"" -> null9999.9 paisa rounds to 10000. -20 parses, but refusing non-positive amounts is a
business rule, so the services (not tryParse) must reject it.
Question 5 — Solution
import 'package:budget_tracker/budget_tracker.dart';
void main() {
final wrong = Expense(
id: 't0020',
walletId: 'sara',
amount: Money(65000),
date: DateTime(2026, 10, 8),
note: 'novel',
category: Category.fun,
);
final fixed = wrong.copyWith(category: Category.books);
print(wrong.describe());
print(fixed.describe());
}2026-10-08 Fun expense (novel) Rs 650.00
2026-10-08 Books expense (novel) Rs 650.00Question 6 — Solution
import 'package:budget_tracker/budget_tracker.dart';
void main() {
const sara = Wallet(
id: 'sara',
owner: 'Sara',
name: 'Bank card',
openingBalance: Money(200000),
);
final transactions = <Transaction>[
Expense(
id: 't1',
walletId: 'sara',
amount: Money(40000),
date: DateTime(2026, 10, 2),
category: Category.rent,
),
Transfer(
id: 't2',
walletId: 'sara',
amount: Money(25000),
date: DateTime(2026, 10, 3),
toWalletId: 'zayan',
),
];
final balance = transactions.fold(
sara.openingBalance,
(sum, t) => sum + t.effectOn(sara.id),
);
print('${sara.owner}: ${balance.format()}');
}Sara: Rs 1350.00Question 7 — Solution
import 'package:budget_tracker/budget_tracker.dart';
void main() {
const books = Budget(category: Category.books, monthlyLimit: Money(150000));
final json = books.toJson();
print(json);
print(Budget.fromJson(json));
}{category: books, monthlyLimit: 150000}
Budget(Books: Rs 1500.00)The limit travels as cents and the category as its name; fromJson turns both back.
Question 8 — Solution
import 'package:budget_tracker/budget_tracker.dart';
Wallet checkedWallet(Map<String, Object?> json) => switch (json) {
{'openingBalance': int cents} when cents < 0 => throw FormatException(
'Negative opening balance: $json',
),
_ => Wallet.fromJson(json),
};
void main() {
final inputs = <Map<String, Object?>>[
{'id': 'ali', 'owner': 'Ali', 'name': 'Cash', 'openingBalance': 5000},
{'id': 'bilal', 'owner': 'Bilal', 'name': 'Cash', 'openingBalance': -100},
];
for (final json in inputs) {
try {
print(checkedWallet(json));
} on FormatException catch (e) {
print(e.message);
}
}
}Wallet(ali, Ali, Cash)
Negative opening balance: {id: bilal, owner: Bilal, name: Cash, openingBalance: -100}The guarded map pattern catches only the bad case; the factory still checks every field.
Quick Revision
- Why does
Moneystore paisa in anintinstead of rupees in adouble? - What does
print(Money(500))show, and why? - Why does
Category.fromJsonthrowFormatExceptioninstead of usingvalues.byName? - What does
effectOnreturn for a transfer, seen from the sender? From the receiver? - Why did adding
Refundproduce four errors and not five?
Key Takeaways
Moneyis an extension type over paisa: exact sums, no runtime cost, no mixing withint.Categoryis an enhanced enum;WalletandBudgetare immutable withcopyWithand JSON.Transactionis sealed: income, expense and transfer, each with its own field.effectOndecides every balance change in one exhaustive switch.- JSON carries a
typekey;fromJsonchecks shared fields, then switches on the type. - Every kind of bad saved data becomes a
FormatException. - A new subtype makes the compiler list every switch to fix, unless a
_hides it.
What's Next?
Lecture 134 — Project Services. Our models accept nonsense: an expense bigger than the
wallet, a transfer to a missing wallet, an income of Rs 0. Next we write LedgerService and BudgetService, which hold the business
rules, refuse bad requests with our own exceptions, and produce the first monthly report.