Skip to content

Commit 4008e4e

Browse files
committed
Fix cloud lists, hidden flex children, ink frames and cleared text fields
- A snapshot listener that throws no longer stalls the action chain waiting on the first snapshot; dates in documents read through timeAgo and date. - A flexing child hidden at runtime gives up its space with flex 0 and a zero size, so a visible sibling fills it and a hidden scrollable never sees unbounded constraints (a hidden grid computed NaN offsets). - A sized frame with an ink well fills its size with the ink and children instead of shrinking to its unpositioned children, which hid the photo in a grid tile when its likes badge was hidden. - Image fills whose URL path resolves to nothing load no image. - A text field bound to a text variable clears when the variable is set to empty. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VJ3E2NmBuiBkBnmDDmQBcz
1 parent 59c130c commit 4008e4e

7 files changed

Lines changed: 145 additions & 44 deletions

‎lib/src/data/cloud_database.dart‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -599,13 +599,22 @@ class FirestoreCloudDatabase extends CloudDatabase {
599599
'Updating variable ${variable.value.name} with success state.',
600600
name: name,
601601
);
602-
// Set the variable with success state.
603-
variable.set(
604-
variable.value.copyWith(
605-
value: CloudDatabaseVariableUtils.success(docs),
606-
),
607-
);
608-
if (!firstSnapshot.isCompleted) firstSnapshot.complete();
602+
// Set the variable with success state. The chain waiting on the
603+
// first snapshot continues even if a listener of the variable throws.
604+
try {
605+
variable.set(
606+
variable.value.copyWith(
607+
value: CloudDatabaseVariableUtils.success(docs),
608+
),
609+
);
610+
} catch (error, stackTrace) {
611+
DebugLogger.instance.printInfo(
612+
'Listener of ${variable.value.name} failed: $error\n$stackTrace',
613+
name: name,
614+
);
615+
} finally {
616+
if (!firstSnapshot.isCompleted) firstSnapshot.complete();
617+
}
609618
},
610619
onError: (error) {
611620
DebugLogger.instance.printInfo(

‎lib/src/transformers/node_transformers/passive_rectangle_transformer.dart‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ class PassiveRectangleWidget extends StatelessWidget {
144144
stackAlignment: stackAlignment,
145145
applyPadding: applyPadding,
146146
),
147+
alignment:
148+
stackAlignment.flutterAlignment ?? AlignmentDirectional.topStart,
147149
),
148150
],
149151
);
@@ -177,8 +179,9 @@ class PassiveRectangleWidget extends StatelessWidget {
177179
List<Widget> wrapWithInkWell(
178180
BuildContext context,
179181
BaseNode node,
180-
List<Widget> children,
181-
) {
182+
List<Widget> children, {
183+
AlignmentGeometry alignment = AlignmentDirectional.topStart,
184+
}) {
182185
if (node is! ReactionMixin) return children;
183186

184187
final InkWellModel? inkWell = node is BlendMixin ? node.inkWell : null;
@@ -220,10 +223,18 @@ List<Widget> wrapWithInkWell(
220223
highlightColor: inkWell.highlightColor?.toFlutterColor(),
221224
hoverColor: inkWell.hoverColor?.toFlutterColor(),
222225
focusColor: inkWell.focusColor?.toFlutterColor(),
223-
child: Stack(children: children),
226+
child: Stack(alignment: alignment, children: children),
224227
),
225228
);
226229

230+
// A node with a set size fills it with its ink and children. Otherwise the
231+
// inner stack sizes to its unpositioned children, and when those are
232+
// hidden (a likes badge on a photo tile) everything else in it gets no
233+
// space.
234+
if (node.horizontalFit != SizeFit.shrinkWrap &&
235+
node.verticalFit != SizeFit.shrinkWrap) {
236+
return [Positioned.fill(child: widget)];
237+
}
227238
return [widget];
228239
}
229240

@@ -720,6 +731,11 @@ Widget buildFill(
720731
nullSubstitutionMode: settings.nullSubstitutionMode,
721732
scopedValues: scopedValues,
722733
);
734+
// A URL path that resolves to nothing, like a cover that isn't set
735+
// yet, has no image to load.
736+
if (bytes == null && imageURL.trim().isEmpty) {
737+
return const SizedBox.shrink();
738+
}
723739
Widget child;
724740

725741
if (imageFillBuilder != null) {

‎lib/src/transformers/node_transformers/passive_row_column_transformer.dart‎

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,45 @@ class PassiveRowColumnWidget extends StatelessWidget {
285285
final NodeTransformerManager manager;
286286
final WidgetBuildSettings settings;
287287

288+
/// A flexing child hidden at runtime gives up its share of the free space,
289+
/// so a visible sibling (an empty state next to a hidden spinner) can take
290+
/// all of it. The flex drops to zero rather than removing the [Flexible] so
291+
/// the child keeps its state when it shows again.
292+
Widget _collapseWhenHidden(
293+
BuildContext context,
294+
BaseNode child,
295+
Widget sizedWidget,
296+
) {
297+
final NodeTransformerManager manager = this.manager;
298+
if (sizedWidget is! Flexible || manager is! PassiveNodeTransformerManager) {
299+
return sizedWidget;
300+
}
301+
302+
Widget buildFlexible(BuildContext context) {
303+
final bool hidden = manager.runtimeVisibility(context, child) == false;
304+
// Without flex the hidden child would be laid out with unbounded
305+
// main-axis constraints, which a scrollable inside it can't take
306+
// (a hidden grid computed NaN offsets), so it gets no space at all.
307+
return Flexible(
308+
flex: hidden ? 0 : sizedWidget.flex,
309+
fit: sizedWidget.fit,
310+
child: SizedBox(
311+
width: hidden ? 0 : null,
312+
height: hidden ? 0 : null,
313+
child: sizedWidget.child,
314+
),
315+
);
316+
}
317+
318+
final List<Listenable> listenables = manager.listenablesFor(context, child);
319+
if (listenables.isEmpty) return buildFlexible(context);
320+
return ManagedListenableBuilder(
321+
key: ValueKey('${child.id}-flex'),
322+
listenables: listenables,
323+
builder: buildFlexible,
324+
);
325+
}
326+
288327
@override
289328
Widget build(BuildContext context) {
290329
final List<Widget> widgetChildren = children.map((child) {
@@ -294,12 +333,14 @@ class PassiveRowColumnWidget extends StatelessWidget {
294333
settings: settings,
295334
);
296335

297-
return PassiveRowColumnTransformer.wrapChildWithSizeFits(
298-
child,
299-
node,
300-
builtWidget,
301-
child.flex,
302-
);
336+
final Widget sizedWidget =
337+
PassiveRowColumnTransformer.wrapChildWithSizeFits(
338+
child,
339+
node,
340+
builtWidget,
341+
child.flex,
342+
);
343+
return _collapseWhenHidden(context, child, sizedWidget);
303344
}).toList();
304345

305346
final Widget child = (node is DefaultShapeNode)

‎lib/src/transformers/node_transformers/passive_text_field_transformer.dart‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -175,12 +175,16 @@ class _PassiveTextFieldWidgetState extends State<PassiveTextFieldWidget> {
175175
// different from the current controller text.
176176
// widget.node.variables['inputValue'] only works when a sync variable is specified. If it is not then
177177
// we rely on initial text to be able to see if controller text needs to be updated.
178-
String? currentPropertyValue = widget.node.variables['inputValue'] != null
178+
final String? boundPath = widget.node.variables['inputValue'];
179+
String? currentPropertyValue = boundPath != null
179180
? PropertyValueDelegate.getPropertyValue<String>(
180-
widget.node,
181-
'inputValue',
182-
scopedValues: scopedValues,
183-
)
181+
widget.node,
182+
'inputValue',
183+
scopedValues: scopedValues,
184+
) ??
185+
// An empty text variable reads as null; a field bound to one
186+
// still clears, e.g. after a comment is sent.
187+
(_isExistingVariable(boundPath, scopedValues) ? '' : null)
184188
: findIndexedValueIfApplied(scopedValues) ??
185189
PropertyValueDelegate.getPropertyValueFromNodeValues<String>(
186190
widget.node,
@@ -201,6 +205,16 @@ class _PassiveTextFieldWidgetState extends State<PassiveTextFieldWidget> {
201205
}
202206
}
203207

208+
bool _isExistingVariable(String path, ScopedValues scopedValues) {
209+
final VariableMatch? match = VariableMatch.parse(
210+
path.wrapWithVariableSyntax(),
211+
);
212+
if (match == null || match.hasPath || match.isPredefinedVariable) {
213+
return false;
214+
}
215+
return scopedValues.variables.values.findByNameOrNull(match.name) != null;
216+
}
217+
204218
/// The purpose of this function is to find the indexed value of the variable
205219
/// 'item' if it is applied to the initial text. This is used to determine if
206220
/// the controller text needs to be updated when the variable 'inputValue' is

‎lib/src/transformers/passive_transformer_manager.dart‎

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,34 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager {
137137

138138
if (!enabled) return const SizedBox.shrink();
139139

140+
final CodelesslyContext codelesslyContext = context
141+
.read<CodelesslyContext>();
142+
final List<Listenable> listenables = listenablesFor(context, node);
143+
144+
return NodeStateProviderWidget(
145+
key: ValueKey(node.id),
146+
node: node,
147+
codelesslyContext: codelesslyContext,
148+
child: Builder(
149+
builder: (context) {
150+
if (listenables.isNotEmpty) {
151+
return ManagedListenableBuilder(
152+
key: ValueKey(node.id),
153+
listenables: listenables,
154+
builder: (context) => builder(context),
155+
);
156+
} else {
157+
return builder(context);
158+
}
159+
},
160+
),
161+
);
162+
}
163+
164+
/// Returns what the widget of [node] rebuilds on: the node's local values,
165+
/// the variables its properties use and the variables of the conditions
166+
/// that affect it.
167+
List<Listenable> listenablesFor(BuildContext context, BaseNode node) {
140168
final CodelesslyContext codelesslyContext = context
141169
.read<CodelesslyContext>();
142170

@@ -251,24 +279,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager {
251279
}
252280
}
253281

254-
return NodeStateProviderWidget(
255-
key: ValueKey(node.id),
256-
node: node,
257-
codelesslyContext: codelesslyContext,
258-
child: Builder(
259-
builder: (context) {
260-
if (listenables.isNotEmpty) {
261-
return ManagedListenableBuilder(
262-
key: ValueKey(node.id),
263-
listenables: listenables,
264-
builder: (context) => builder(context),
265-
);
266-
} else {
267-
return builder(context);
268-
}
269-
},
270-
),
271-
);
282+
return listenables;
272283
}
273284

274285
Listenable? getStorageListenerFor(VariableMatch match, BuildContext context) {

‎lib/src/transformers/utils/property_value_delegate.dart‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,11 @@ class PropertyValueDelegate {
605605
'ceil': value.ceil(),
606606
},
607607
bool() => {'toggled': !value},
608+
// Cloud documents carry createdAt and updatedAt as dates.
609+
DateTime() => {
610+
'timeAgo': formatTimeAgo(value),
611+
'date': formatDate(value),
612+
},
608613
Map() => {
609614
'length': value.length,
610615
'isEmpty': value.isEmpty,

‎lib/src/transformers/widget_node_transformer_manager.dart‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,23 @@ abstract class WidgetNodeTransformerManager
4646
return Opacity(opacity: opacity, child: widget);
4747
}
4848

49+
/// Returns the visibility a condition or variable gives [node] at runtime,
50+
/// or null when nothing overrides the node's own visibility.
51+
bool? runtimeVisibility(BuildContext context, BaseNode node) =>
52+
PropertyValueDelegate.getPropertyValue<bool>(
53+
node,
54+
'visible',
55+
scopedValues: ScopedValues.of(context),
56+
);
57+
4958
/// Convenience method to handle widget visibility.
5059
Widget applyWidgetVisibility(
5160
BuildContext context,
5261
BaseNode node,
5362
Widget widget, {
5463
required bool maintainState,
5564
}) {
56-
final bool? runtimeVisible = PropertyValueDelegate.getPropertyValue<bool>(
57-
node,
58-
'visible',
59-
scopedValues: ScopedValues.of(context),
60-
);
65+
final bool? runtimeVisible = runtimeVisibility(context, node);
6166
final bool visible = runtimeVisible ?? node.visible;
6267

6368
if (visible) return widget;

0 commit comments

Comments
 (0)