Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a26a5a7
Clamp text span ranges to the text
claude Oct 22, 2026
af62b87
Render ChartNode with a CustomPaint transformer
claude Oct 22, 2026
ee981d6
Show a canvas as a bottom sheet, a side sheet or an aligned dialog
claude Oct 22, 2026
379d7d6
Lay out non-scrolling lists without a viewport so stretched rows can …
claude Oct 23, 2026
d7db538
Bind an icon's glyph by name to a variable, formula or condition
claude Oct 23, 2026
d0d91fc
Design canvas preview: a list item reads its own element
claude Oct 23, 2026
3a06ec2
Wrapping list views
claude Oct 23, 2026
20a31e5
Bind corner radius and padding to variables and formulas
claude Oct 23, 2026
6513325
Batch cloud writes; bind stroke weight
claude Oct 23, 2026
4de3bdd
Multi-page publishing: keep other pages' layout maps, index layout ca…
claude Oct 23, 2026
3c8cd67
Run the rest of a chain after a replace navigation
claude Oct 23, 2026
00b44b5
Text fields: decoration content padding
claude Oct 23, 2026
6895c64
Toast: action color, close button, radius, font size, bound offset
claude Oct 23, 2026
4bfa770
Flexible scrolling rows/columns, overlay height caps, hugging sheets,…
claude Oct 23, 2026
6ecd61d
codelessly_sdk 8.4.0
claude Oct 23, 2026
7cc32bf
Format and const fixes for CI
claude Oct 23, 2026
f487b0d
One canvas-to-layout lookup: resolveLayoutId falls back to the layout…
claude Oct 23, 2026
64d4cb8
Format the merged dialog presentation code
claude Oct 24, 2026
871ee0f
Toasts take ShowToastAction.fontFamily and fontWeight
claude Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,46 @@
## 8.4.0

### Layout

- Charts: `ChartNode` renders as a line/area chart; tapping or dragging
snaps the selection, writes the bound variable and fires `changed`.
- A list that doesn't scroll, hugs its items and has a known item count
lays them out without a viewport, so a stretched row of cards keeps
equal heights with a list in one of them; with `wrap` its items flow onto
runs.
- A flexible row or column that scrolls sizes to its content, up to the
space left.
- Text span ranges are clamped to the text.

### Bindings

- `cornerRadius`, `padding` and `strokeWeight` can be bound to data like
fill, opacity and size.
- Bind an icon's glyph by name (`icon`); `materialIconVersions` gives the
SVG version of each Material icon.
- Variable overrides are looked up by name.

### Overlays and actions

- Side sheets (`DialogPresentation.sideSheet`), dialogs kept `insets` from
the screen edges, and a maximum height as a share of the screen
(`maxHeightFactor`) for dialogs and bottom sheets; a canvas that hugs its
content gives a content-height sheet or dialog.
- The rest of an action chain runs after a replace navigation.
- Batch cloud writes: `removeDocuments` and `setDocuments` commit in
Firestore batches of up to 500.
- Toasts: action color, close button, corner radius, font size, family and weight and a bound
offset; without their own font they use the app's text style.
- Text fields use their decoration's `contentPadding`.

### Publishing and design canvas

- Navigation, dialogs and links resolve a canvas on any published page
(`layoutCanvases` index); publishing one page keeps the other pages'
fonts and APIs.
- List items on the design canvas read their own element of the list's
data (`VariablePreview.scopeFor`).

## 8.3.0

Requires codelessly_api 6.2.0.
Expand Down
1 change: 1 addition & 0 deletions lib/codelessly_sdk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export 'src/ui/zoomable_box.dart';
export 'src/ui/skeleton.dart';
export 'src/ui/stripe_painter.dart';
export 'src/utils/cupertino_icons_by_codepoint.dart';
export 'src/utils/material_icon_versions.dart';
export 'src/utils/extensions.dart';
export 'src/utils/flutter_icons_data_map.dart';
export 'src/utils/flutter_icons_names_map.dart';
Expand Down
192 changes: 192 additions & 0 deletions lib/src/data/cloud_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,69 @@ abstract class CloudDatabase extends ChangeNotifier {
/// it will return true.
Future<bool> removeDocument(String path, String documentId);

/// The most writes Firestore takes in one batch.
static const int maxBatchWrites = 500;

/// Removes many documents of the collection at [path] at once and returns
/// how many were removed.
///
/// Removes the documents with [documentIds] when given; otherwise those
/// matching [filters], or every document of the collection when there are
/// none. Implementations remove them in as few writes as they can (see
/// [FirestoreCloudDatabase.removeDocuments]); this default removes the
/// [documentIds] one by one and can't query.
Future<int> removeDocuments(
String path, {
Iterable<String>? documentIds,
List<CloudWhereFilter> filters = const [],
}) async {
if (documentIds == null) {
throw UnsupportedError('$runtimeType can only remove documents by id.');
}
int removed = 0;
for (final String id in documentIds) {
if (id.trim().isEmpty) continue;
if (await removeDocument(path, id.trim())) removed++;
}
return removed;
}

/// Writes [documents] to the collection at [path] at once and returns how
/// many were written.
///
/// Each document's id is its [idField]; a document without one gets a
/// generated id. A document replaces the stored one unless [merge] is
/// true. With [replaceCollection], the collection's other documents are
/// removed, so it holds exactly [documents] (restoring seed data).
///
/// This default writes them one by one and can't [replaceCollection];
/// see [FirestoreCloudDatabase.setDocuments].
Future<int> setDocuments(
String path,
List<Map<String, dynamic>> documents, {
String idField = 'id',
bool merge = false,
bool replaceCollection = false,
}) async {
if (replaceCollection) {
throw UnsupportedError('$runtimeType cannot replace a collection.');
}
int written = 0;
for (final Map<String, dynamic> document in documents) {
final String id = document[idField]?.toString().trim() ?? '';
final bool ok = merge && id.isNotEmpty
? await updateDocument(path, documentId: id, value: document)
: await addDocument(
path,
documentId: id.isEmpty ? generateId() : id,
skipCreationIfDocumentExists: false,
value: document,
);
if (ok) written++;
}
return written;
}

/// Retrieves the document data from the cloud storage at the given [path]
/// with the given [documentId].
///
Expand Down Expand Up @@ -483,6 +546,135 @@ class FirestoreCloudDatabase extends CloudDatabase {
return true;
}

/// Removes the documents in as few batched writes as Firestore allows
/// ([CloudDatabase.maxBatchWrites] deletes each). Without [documentIds],
/// the documents to remove are read first: those matching [filters] (run
/// in the app past their `==` filters, so no index is needed), and in an
/// owner-only collection only the signed-in user's.
@override
Future<int> removeDocuments(
String path, {
Iterable<String>? documentIds,
List<CloudWhereFilter> filters = const [],
}) async {
DebugLogger.instance.printFunction(
'removeDocuments(path: $path, ids: $documentIds, filters: $filters)',
name: name,
);
final CollectionReference<Map<String, dynamic>> collection =
getCollectionPath(path);
final List<DocumentReference<Map<String, dynamic>>> refs =
documentIds != null
? [
for (final String id in {
for (final String id in documentIds)
if (id.trim().isNotEmpty) id.trim(),
})
collection.doc(id),
]
: [
for (final doc in await _matchingDocuments(path, filters))
doc.reference,
];
await _commitInBatches([
for (final ref in refs) (WriteBatch batch) => batch.delete(ref),
], 'cloudDatabase/removeDocuments');
return refs.length;
}

/// Writes the documents in as few batched writes as Firestore allows
/// ([CloudDatabase.maxBatchWrites] each), applying the collection's rules
/// to each like [addDocument] does.
@override
Future<int> setDocuments(
String path,
List<Map<String, dynamic>> documents, {
String idField = 'id',
bool merge = false,
bool replaceCollection = false,
}) async {
DebugLogger.instance.printFunction(
'setDocuments(path: $path, count: ${documents.length}, '
'merge: $merge, replaceCollection: $replaceCollection)',
name: name,
);
final CollectionReference<Map<String, dynamic>> collection =
getCollectionPath(path);
final String? uid = currentUserId();
final Set<String> written = {};
final List<void Function(WriteBatch batch)> writes = [];
for (final Map<String, dynamic> document in documents) {
final String id = document[idField]?.toString().trim() ?? '';
final DocumentReference<Map<String, dynamic>> ref = id.isEmpty
? collection.doc()
: collection.doc(id);
written.add(ref.id);
Map<String, dynamic> data = rules.prepareWrite(path, document, uid);
data = sanitizeCloudDataToSend(data, docId: ref.id, keepCreatedAt: merge);
final Map<String, dynamic> resolved = resolveFieldOperations(data);
writes.add(
(WriteBatch batch) =>
batch.set(ref, resolved, SetOptions(merge: merge)),
);
}
if (replaceCollection) {
for (final doc in await _matchingDocuments(path, const [])) {
if (written.contains(doc.id)) continue;
writes.add((WriteBatch batch) => batch.delete(doc.reference));
}
}
await _commitInBatches(writes, 'cloudDatabase/setDocuments');
return documents.length;
}

/// The documents of the collection at [path] that pass [filters] and the
/// collection's read rules for the signed-in user.
Future<List<QueryDocumentSnapshot<Map<String, dynamic>>>> _matchingDocuments(
String path,
List<CloudWhereFilter> filters,
) async {
final List<CloudWhereFilter>? readFilters = rules.readFilters(
path,
currentUserId(),
);
if (readFilters == null) return [];
final CloudQueryPlan plan = CloudQuery(
filters: [...readFilters, ...filters],
).plan(forceClient: true);
final QuerySnapshot<Map<String, dynamic>> snapshot = await plan
.applyTo(getCollectionPath(path))
.get();
StatTracker.instance.track(StatType.read, 'cloudDatabase/batchQuery');
return plan.finish(
snapshot.docs,
data: (doc) => doc.data(),
id: (doc) => doc.id,
);
}

/// Commits [writes] in batches of at most [CloudDatabase.maxBatchWrites].
Future<void> _commitInBatches(
List<void Function(WriteBatch batch)> writes,
String stat,
) async {
for (
int start = 0;
start < writes.length;
start += CloudDatabase.maxBatchWrites
) {
final WriteBatch batch = firestore.batch();
final int end = (start + CloudDatabase.maxBatchWrites).clamp(
0,
writes.length,
);
for (final write in writes.sublist(start, end)) {
write(batch);
}
await batch.commit();
StatTracker.instance.track(StatType.write, stat);
}
}

@override
Future<Map<String, dynamic>> getDocumentData(
String path,
Expand Down
67 changes: 62 additions & 5 deletions lib/src/functions/action_chain.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import '../transformers/utils/scoped_values.dart';
/// removed its document, the remaining actions run with the first host
/// still mounted, so toasts, dialogs and navigation still happen. See
/// [contextFor].
/// * the screen a `replace` navigation of the chain put in place of the
/// trigger's: its [ActionChainHost] joins the chain ([ActionChainAdopter]),
/// so the actions after the navigation run there once the old screen is
/// gone.
///
/// Something the chain registers to run later, such as a toast's Undo
/// button, runs on the same chain through [defer].
Expand All @@ -39,7 +43,8 @@ class ActionChain {
this.scopedValues,
this._context,
this._hosts = const [],
});
List<BuildContext>? adopted,
}) : _adopted = adopted ?? [];

/// Creates a chain for a trigger that fired in [context], capturing its
/// scope and hosts.
Expand All @@ -62,6 +67,17 @@ class ActionChain {
final BuildContext? _context;
final List<BuildContext> _hosts;

/// Hosts of screens a navigation of this chain replaced the trigger's
/// with, oldest first. Shared with nested chains.
final List<BuildContext> _adopted;

/// Makes [host] a context the chain's remaining actions can run with,
/// ahead of the trigger's and its hosts: the screen a `replace`
/// navigation put in their place. See [ActionChainAdopter].
void adoptHost(BuildContext host) {
if (!_adopted.contains(host)) _adopted.add(host);
}

static const Symbol _zoneKey = #codelesslyActionChain;

/// The chain the running action belongs to, if any.
Expand All @@ -85,6 +101,7 @@ class ActionChain {
scopedValues: scopedValues,
context: _context,
hosts: _hosts,
adopted: _adopted,
);

/// Runs [body] in a new chain whose `${result}` starts as [result].
Expand Down Expand Up @@ -149,14 +166,19 @@ class ActionChain {
current?.scopedValues ?? ScopedValues.of(context);

/// A mounted context to run the next action with: [context] while it is
/// mounted, else the chain's trigger context, else the first of its hosts
/// still mounted. Null when all of them have unmounted, e.g. the app
/// replaced the whole screen.
/// mounted, else the host of a screen a `replace` navigation of the chain
/// opened (see [adoptHost]), else the chain's trigger context, else the
/// first of its hosts still mounted. Null when all of them have unmounted,
/// e.g. the app replaced the whole screen.
static BuildContext? contextFor(BuildContext context) {
if (context.mounted) return context;
final ActionChain? chain = current;
if (chain == null) return null;
for (final BuildContext? candidate in [chain._context, ...chain._hosts]) {
for (final BuildContext? candidate in [
...chain._adopted,
chain._context,
...chain._hosts,
]) {
if (candidate != null && candidate.mounted) return candidate;
}
return null;
Expand All @@ -176,6 +198,9 @@ class ActionChainHost extends InheritedWidget {
static BuildContext? maybeOf(BuildContext context) =>
context.getElementForInheritedWidgetOfExactType<ActionChainHost>();

@override
InheritedElement createElement() => _ActionChainHostElement(this);

/// The contexts of the hosts a chain triggered in [context] can fall back
/// to, nearest first: every [ActionChainHost] above [context] (a layout
/// embedded in another has its own), then, past an [ActionChainOpener],
Expand Down Expand Up @@ -225,3 +250,35 @@ class ActionChainOpener extends InheritedWidget {
@override
bool updateShouldNotify(ActionChainOpener oldWidget) => false;
}

class _ActionChainHostElement extends InheritedElement {
_ActionChainHostElement(super.widget);

@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
final Widget? adopter =
getElementForInheritedWidgetOfExactType<ActionChainAdopter>()?.widget;
if (adopter is ActionChainAdopter) adopter.chain.adoptHost(this);
}
}

/// Wraps a screen that an action of [chain] put in place of the screen the
/// chain was triggered on (a `replace` navigation): the first
/// [ActionChainHost] that mounts below it joins [chain], so the chain's
/// remaining actions (e.g. a toast after "pop the dialog, then replace the
/// page") run with the new screen once the old one unmounts.
class ActionChainAdopter extends InheritedWidget {
/// Creates an [ActionChainAdopter] above [child].
const ActionChainAdopter({
super.key,
required this.chain,
required super.child,
});

/// The chain the new screen's hosts join.
final ActionChain chain;

@override
bool updateShouldNotify(ActionChainAdopter oldWidget) => false;
}
Loading
Loading