Lesson 11 / 19
Local Storage
A saved preference can make a card flexible, and a stale preference keeps producing the same record. The lesson reads the preference from a key-value store and measures who owns the width of the card.
Published Updated
In this lesson
- Tells the concepts of preference, key-value store, and embedded database apart
- Measures that a saved preference makes the card stretched
- Shows that the card stays chosen when there is no saved preference
- Shows that the record does not change when the preference the screen read goes stale
Contents
The previous lesson showed that a width request that comes from the server is clamped when it falls outside the range, and that the origin does not change the result. In this lesson the request comes from the phone: a preference the user chose earlier and had stored. Everything an app must remember when it is closed and opened again lives in local storage. The course question is the same again: who determines this widget’s width? The lesson looks for the answer around a preference read from the store.
Preference, Key-Value, and Database
Local storage comes in three forms. A preference is a small setting: the theme, the language, or the dense and wide view of a screen. A key-value store is the simplest arrangement that keeps preferences under a name; writing and reading are bound to a name. An embedded database stores larger and related data that needs tables and queries. The first two are the model of this lesson; the third is only mentioned.
Access to the store is asynchronous, because the data is on a persistent medium, not in memory. In this lesson the store is an in-memory Map, and access is expressed with a Future; there is no real file or database.
The Core
Every lesson uses the same core. measure walks the tree with a tight constraint at the screen width, produces a record for each node, and gives the sizing in the record by a rule: if the constraint is tight, forced; if the request stays inside the range, chosen; if the request falls outside the range, or there is no request and there is no upper bound, clamped; if there is no request and the upper bound is finite, stretched. The four names are written in ASCII in the output: forced, chosen, clamped, stretched. The parent: line is the total of forced, clamped, and stretched; it counts the records whose width is not the widget’s own request.
// sizing.dart — classifies who decides a widget's width
const sizings = ['forced', 'chosen', 'clamped', 'stretched'];
class Limit {
final int min;
final int? max; // null: unbounded
const Limit(this.min, this.max);
bool get tight => max == min;
Limit loosen() => Limit(0, max);
Limit deflate(int by) => Limit(_floor(min - by), max == null ? null : _floor(max! - by));
String bounds() => '$min..${max ?? 'inf'}';
}
int _floor(int value) => value < 0 ? 0 : value;
class Part {
final String kind;
final String name;
final int? width; // null: as wide as it can get
final List<Part> children;
final int factor;
final bool tightFlex;
const Part(this.kind, this.name, this.width, this.children,
{this.factor = 0, this.tightFlex = true});
}
Part leaf(String name, int width) => Part('leaf', name, width, const []);
Part fill(String name) => Part('leaf', name, null, const []);
Part pad(String name, int padding, Part child) => Part('pad', name, padding, [child]);
Part center(String name, Part child) => Part('center', name, null, [child]);
Part fixed(String name, int width, Part child) => Part('fixed', name, width, [child]);
Part column(String name, List<Part> children) => Part('column', name, null, children);
Part row(String name, List<Part> children) => Part('row', name, null, children);
Part flex(String name, int factor, Part child, {bool tight = true}) =>
Part('flex', name, null, [child], factor: factor, tightFlex: tight);
class Sizing {
final String sizing;
final String path;
final int width;
final Limit limit;
const Sizing(this.sizing, this.path, this.width, this.limit);
}
Sizing classify(String path, int? desire, Limit limit) {
if (limit.tight) return Sizing('forced', path, limit.min, limit);
if (desire == null) {
return limit.max == null
? Sizing('clamped', path, limit.min, limit)
: Sizing('stretched', path, limit.max!, limit);
}
if (desire < limit.min) return Sizing('clamped', path, limit.min, limit);
if (limit.max != null && desire > limit.max!) {
return Sizing('clamped', path, limit.max!, limit);
}
return Sizing('chosen', path, desire, limit);
}
int layout(Part part, Limit limit, List<Sizing> log, [String path = '']) {
final here = '$path/${part.name}';
final below = <Sizing>[];
if (part.kind == 'fixed') {
final own = classify(here, part.width, limit);
layout(part.children.first, Limit(own.width, own.width), below, here);
log..add(own)..addAll(below);
return own.width;
}
int? desire;
switch (part.kind) {
case 'leaf':
desire = part.width;
case 'pad':
final space = 2 * part.width!;
desire = layout(part.children.first, limit.deflate(space), below, here) + space;
case 'center':
final inner = layout(part.children.first, limit.loosen(), below, here);
desire = limit.max == null ? inner : null;
case 'flex':
desire = layout(part.children.first, limit, below, here);
case 'column':
var widest = 0;
for (final child in part.children) {
final width = layout(child, limit.loosen(), below, here);
if (width > widest) widest = width;
}
desire = widest;
case 'row':
final logs = [for (final _ in part.children) <Sizing>[]];
final bounded = limit.max != null;
var used = 0;
var factors = 0;
for (var i = 0; i < part.children.length; i++) {
final child = part.children[i];
if (child.kind == 'flex' && bounded) {
factors += child.factor;
} else {
used += layout(child, const Limit(0, null), logs[i], here);
}
}
final left = bounded ? _floor(limit.max! - used) : 0;
var given = 0;
var seen = 0;
for (var i = 0; i < part.children.length; i++) {
final child = part.children[i];
if (child.kind != 'flex' || !bounded) continue;
seen += child.factor;
final share = (seen == factors ? left : left * seen ~/ factors) - given;
given += share;
final room = child.tightFlex ? Limit(share, share) : Limit(0, share);
used += layout(child, room, logs[i], here);
}
for (final entries in logs) {
below.addAll(entries);
}
desire = bounded ? null : used;
}
final own = classify(here, desire, limit);
log..add(own)..addAll(below);
return own.width;
}
List<Sizing> measure(Part root, int screen) {
final log = <Sizing>[];
layout(root, Limit(screen, screen), log);
return log;
}
void printSizes(List<Sizing> log) {
for (final entry in log) {
print('${entry.sizing}: ${entry.path} width=${entry.width} constraint=${entry.limit.bounds()}');
}
}
void countSizings(List<Sizing> log) {
for (final name in sizings) {
print('$name: ${log.where((entry) => entry.sizing == name).length}');
}
print('parent: ${log.where((entry) => entry.sizing != 'chosen').length}');
}
The Framework Side
The snippet below shows the framework side of reading and writing a preference. The snippet is not measured; the model does not run the framework’s code.
// Platform code: preference store
Future<String> loadMode(SharedPreferencesAsync prefs) async {
final saved = await prefs.getString('mode');
return saved ?? 'compact';
}
Future<void> saveMode(SharedPreferencesAsync prefs, String mode) async {
await prefs.setString('mode', mode);
}
loadMode reads the saved preference and returns compact if there is no record; saveMode writes the new preference. The result of the read changes how a tree is built. The question of this lesson is exactly in that construction: how the card’s request comes out.
From Preference to Card
The preference store lives in a helper file. An in-memory Map holds the key-value mapping; reading and writing each return a Future. The helper file prints nothing to the output.
// prefs.dart — in-memory preference store; reading and writing return a Future
class PrefStore {
final Map<String, String> _data;
PrefStore([Map<String, String>? initial]) : _data = {...?initial};
Future<String?> read(String key) async => _data[key];
Future<void> write(String key, String value) async {
_data[key] = value;
}
}
buildPrefs builds the card according to the preference. In the full preference the card has no width it asks for, and the card spreads into the space it is given: fill('Card'). In the compact preference the card asks for 200 units. The tree is a column inside the screen, and inside the column a title and the card.
// main.dart
import 'sizing.dart';
import 'prefs.dart';
Part buildPrefs(String mode) {
final card = mode == 'full' ? fill('Card') : leaf('Card', 200);
return pad('Screen', 16, column('Body', [
leaf('Title', 120),
card,
]));
}
Future<void> main() async {
final store = PrefStore({'mode': 'full'});
final mode = await store.read('mode') ?? 'compact';
print('preference=$mode');
final log = measure(buildPrefs(mode), 360);
printSizes(log);
countSizings(log);
}
preference=full forced: /Screen width=360 constraint=360..360 forced: /Screen/Body width=328 constraint=328..328 chosen: /Screen/Body/Title width=120 constraint=0..328 stretched: /Screen/Body/Card width=328 constraint=0..328 forced: 2 chosen: 1 clamped: 0 stretched: 1 parent: 3
The preference full was read, and the card has no request. The column gives its children the range 0..328; because the upper bound is finite, the card spreads over this range. The /Screen/Body/Card record is stretched: the width is 328, but the card did not ask for this width; it is the upper bound of the range. The count shows one stretched, one chosen, and two forced; the parent is three.
When the Preference Changes
Now read the store in three situations: when the preference has never been written, when compact has been written, and when full has been written. In each situation, rebuild the tree and print only the record of the card.
// main.dart
import 'sizing.dart';
import 'prefs.dart';
Part buildPrefs(String mode) {
final card = mode == 'full' ? fill('Card') : leaf('Card', 200);
return pad('Screen', 16, column('Body', [
leaf('Title', 120),
card,
]));
}
Future<void> main() async {
final store = PrefStore();
for (final saved in [null, 'compact', 'full']) {
if (saved != null) {
await store.write('mode', saved);
}
final mode = await store.read('mode') ?? 'compact';
final log = measure(buildPrefs(mode), 360);
final card = log.firstWhere((entry) => entry.path == '/Screen/Body/Card');
print('saved=${saved ?? 'none'} preference=$mode ${card.sizing} width=${card.width} constraint=${card.limit.bounds()}');
}
}
saved=none preference=compact chosen width=200 constraint=0..328 saved=compact preference=compact chosen width=200 constraint=0..328 saved=full preference=full stretched width=328 constraint=0..328
When there is no record, the preference falls back to compact and the card asks for 200; because this request stays inside the range 0..328, the record is chosen. When compact is written, the result does not change. When full is written, the card turns stretched and fills 328. The sizing is determined not by the name of the preference itself, but by whether the preference makes the card’s request exist or not. When the request is removed, the owner of the width stops being the card and moves to the constraint.
The Bounding Measurement
A value in the store can differ from what the screen read: the user changed the preference somewhere else, and the screen has not been rebuilt yet. The measurement below builds the screen with the full preference, then writes compact to the store. The tree the screen holds does not change; the tree changes only when the preference is read again and rebuilt.
// main.dart
import 'sizing.dart';
import 'prefs.dart';
Part buildPrefs(String mode) {
final card = mode == 'full' ? fill('Card') : leaf('Card', 200);
return pad('Screen', 16, column('Body', [
leaf('Title', 120),
card,
]));
}
void show(String label, String shown, String stored, Part tree) {
final log = measure(tree, 360);
final card = log.firstWhere((entry) => entry.path == '/Screen/Body/Card');
print('$label shown=$shown stored=$stored ${card.sizing} width=${card.width}');
}
Future<void> main() async {
final store = PrefStore({'mode': 'full'});
final shown = (await store.read('mode'))!;
final onScreen = buildPrefs(shown);
show('read', shown, shown, onScreen);
await store.write('mode', 'compact');
final stored = (await store.read('mode'))!;
show('stale', shown, stored, onScreen);
final fresh = buildPrefs(stored);
show('refreshed', stored, stored, fresh);
}
read shown=full stored=full stretched width=328 stale shown=full stored=compact stretched width=328 refreshed shown=compact stored=compact chosen width=200
The focus node is /Screen/Body/Card. In the first line the card is stretched and 328. In the second line the store has become compact, but the tree the screen holds is the same, and the record comes out exactly the same: stretched, 328. A stale preference also yields a stretched width. The rule does not see the store; it sees only the tree it measures. In the third line the tree has been rebuilt, and the card becomes chosen with a width of 200.
The question does this screen match the model asks whether the screen matches the current state of the data. This lesson does not ask it: a stale tree also produces a record that is consistent within itself, and the rule classifies that record.
AS1. The model measures only the width. Whether a write is persistent, the order of two simultaneous writes, the size of the data in the store, and the content of the card are not in the model. The model builds this sizing’s rule; the model is not the framework itself. That the preference carries one of two values, full or compact, is an assumption of the lesson; a real store can also carry other values and version differences.
A practical consequence: the on-screen counterpart of a preference does not have to be identical to the record in the store. To see the owner of the width, one must look not at the store but at the tree that is on the screen at that moment.
Summary
- This lesson’s sizing: stretched. The focus node is
/Screen/Body/Card: in thefullpreference the card has no request and takes the upper bound of the0..328range the column gives, 328. - A preference is a small setting in a key-value store; an embedded database stores larger, related data. Access is asynchronous.
- With no record and with
compactwritten, the card asked for 200 and came out chosen; whenfullwas written the request was removed and the card became stretched. - With the screen built from
fulland the store later changed tocompact, the measurement gave the same record:stretched, 328. In the rebuilt tree the card became chosen with 200. - The rule classifies the tree it measures, not the store; a stale preference does not write into the record that it is stale.
Next Step
In this lesson, what removed the request was a saved preference. The next lesson, Assets and Fonts, measures an input that enlarges the request: when the font scale increases the request of a button, it looks at whether the button still fits inside the range.
To keep your progress and take notes, Log in
My notes
Log in to take notes.