Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,40 @@
## 8.4.0

Requires codelessly_api 6.3.0.

### Layout

- A node's `x` and `y` in a freeform parent can be bound to data.
- Stacked lists (`ListViewProperties.layout: stack`) place each item at its
bound `x`/`y`, for diagrams drawn from data.
- `${parent.item}` and `${parent.index}` read the item of the list one
level out.
- A canvas in a dialog, sheet or popover picks its responsive layout by
the screen width.

### Actions and triggers

- Drag, drag end, pointer down and pointer up reactions, with
`${result.dx}`, `${result.deltaX}`, `${result.x}` and the like.
- Key Down reactions on screens, with `${result.key}` and modifiers.
- Show dialog as a popover menu next to the node that opened it, or as a
full height bottom sheet whose canvas scrolls its own content.
- Copy to Clipboard actions copy their text, variables substituted.
- A show dialog popover with an end alignment lines up with its anchor's
right edge.

### Formulas and variables

- Functions: `with`, `at`, `flatMap` and `feetInches`.
- `auth` reads as signed out where there is no app user (design canvases
and previews).
- A computed text that gives nothing shows nothing.
- A formula that gives empty text shows nothing on the design canvas.

### Layout fixes

- A freeform child is placed at its bound width and height.

## 8.3.0

Requires codelessly_api 6.2.0.
Expand Down
93 changes: 84 additions & 9 deletions lib/src/functions/functions_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:codelessly_api/codelessly_api.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import 'package:rfc_6901/rfc_6901.dart';
Expand Down Expand Up @@ -152,9 +153,25 @@ class FunctionsRepository {
return runCloudData(context, action as CloudDataAction);
case ActionType.cloudBatch:
return runCloudBatch(context, action as CloudBatchAction);
case ActionType.copyToClipboard:
return copyToClipboard(context, action as CopyToClipboardAction);
}
}

/// Copies the text of [action], its variables substituted.
static Future<bool> copyToClipboard(
BuildContext context,
CopyToClipboardAction action,
) async {
final String text = PropertyValueDelegate.substituteVariables(
action.text,
nullSubstitutionMode: NullSubstitutionMode.emptyString,
scopedValues: ActionChain.scopedValuesOf(context),
);
await Clipboard.setData(ClipboardData(text: text));
return true;
}

/// How long [action] waits: its [DelayAction.duration] formula when that
/// gives a number, else its fixed [DelayAction.milliseconds].
static int delayMilliseconds(DelayAction action, ScopedValues scopedValues) {
Expand Down Expand Up @@ -595,9 +612,30 @@ class FunctionsRepository {
showCloseButton: action.showCloseButton,
presentation: action.presentation,
alignment: action.alignment.flutterAlignment ?? Alignment.center,
fullHeight: action.fullHeight,
);
}

/// The rect of the node that [context] belongs to (the node that ran the
/// action), which a popover opens next to. Null when it has no laid-out
/// box.
static Rect? anchorRectOf(BuildContext context) {
if (!context.mounted) return null;
final RenderObject? object = context.findRenderObject();
if (object is! RenderBox || !object.hasSize || !object.attached) {
return null;
}
// In the coordinates of the navigator the popover opens in, which need
// not start at the screen's corner (a nested navigator under a bar).
final RenderObject? overlay = Navigator.maybeOf(context)?.overlay?.context
.findRenderObject();
return object.localToGlobal(
Offset.zero,
ancestor: overlay is RenderBox && overlay.attached ? overlay : null,
) &
object.size;
}

/// The id of the published layout that holds the canvas [canvasId], if any.
static String? layoutIdForCanvas(Codelessly codelessly, String canvasId) =>
codelessly.dataManager.publishModel?.layouts.values
Expand All @@ -616,6 +654,7 @@ class FunctionsRepository {
bool showCloseButton = false,
DialogPresentation presentation = DialogPresentation.dialog,
AlignmentGeometry alignment = Alignment.center,
bool fullHeight = false,
}) async {
final ScopedValues scopedValues = ActionChain.scopedValuesOf(context);
final parsedParams = substituteVariablesInMap(params, scopedValues);
Expand Down Expand Up @@ -666,6 +705,12 @@ class FunctionsRepository {
barrierColor: barrierColor,
showCloseButton: showCloseButton,
routeSettings: RouteSettings(arguments: parsedParams),
fullHeight: fullHeight,
// Where the node that opened a popover is now: the opener may rebuild
// or go away while the popover shows.
anchor: presentation == DialogPresentation.popover
? anchorRectOf(context)
: null,
builder: (context) => ActionChainOpener(
openerHosts: openerHosts,
child: CodelesslyWidget(codelessly: codelessly, layoutID: layoutId),
Expand All @@ -681,9 +726,10 @@ class FunctionsRepository {
return result;
}

/// Opens what [builder] builds as a dialog at [alignment] or as a bottom
/// sheet, per [presentation] (adaptive: a sheet below 600 px wide), and
/// completes with the value it is closed with.
/// Opens what [builder] builds as a dialog at [alignment], as a bottom
/// sheet, or as a popover menu next to [anchor], per [presentation]
/// (adaptive: a sheet below 600 px wide), and completes with the value it
/// is closed with. A [fullHeight] sheet takes 90% of the screen.
static Future<T?> presentCanvas<T>(
BuildContext context, {
required WidgetBuilder builder,
Expand All @@ -693,18 +739,44 @@ class FunctionsRepository {
Color? barrierColor,
bool showCloseButton = false,
RouteSettings? routeSettings,
Rect? anchor,
bool fullHeight = false,
}) {
if (presentation == DialogPresentation.popover) {
return showGeneralDialog<T>(
context: context,
useRootNavigator: false,
barrierDismissible: barrierDismissible,
barrierLabel: 'Close',
barrierColor: barrierColor ?? Colors.transparent,
transitionDuration: const Duration(milliseconds: 120),
routeSettings: routeSettings,
pageBuilder: (context, _, _) => CodelesslyPopoverWidget(
anchor: anchor,
builder: builder,
// A popover aligned to the end (right) side lines up with the
// anchor's right edge.
alignEnd: (alignment.resolve(TextDirection.ltr).x) > 0,
),
transitionBuilder: (context, animation, _, child) =>
FadeTransition(opacity: animation, child: child),
);
}
final bool asSheet = switch (presentation) {
DialogPresentation.dialog => false,
DialogPresentation.dialog || DialogPresentation.popover => false,
DialogPresentation.bottomSheet => true,
DialogPresentation.adaptive =>
(MediaQuery.maybeSizeOf(context)?.width ?? 1024) < 600,
};
Widget content(BuildContext context) => CodelesslyDialogWidget(
showCloseButton: showCloseButton,
alignment: asSheet ? Alignment.bottomCenter : alignment,
builder: builder,
);
// A full height sheet is as tall as it can be, with the canvas filling
// it, so the canvas scrolls its own content under a fixed header.
Widget content(BuildContext context) => asSheet && fullHeight
? CodelesslyBottomSheetWidget(builder: builder)
: CodelesslyDialogWidget(
showCloseButton: showCloseButton,
alignment: asSheet ? Alignment.bottomCenter : alignment,
builder: builder,
);
if (asSheet) {
return showModalBottomSheet<T>(
context: context,
Expand Down Expand Up @@ -1375,6 +1447,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
ReactionMixin? node,
dynamic value,
List<Reaction>? reactions,
Object? result,
}) async {
final filteredReactions = (reactions ?? node?.reactions ?? []).where(
(reaction) => reaction.trigger.type == type,
Expand Down Expand Up @@ -1405,9 +1478,11 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.

// The chain captures the trigger's scope and host now, so its actions
// read the item that was tapped and still run if the row unmounts.
// [result] is what `${result}` starts as, e.g. a drag's deltas.
return ActionChain.run(
() => _runReactions(context, filteredReactions, value),
context: context,
result: result,
);
}

Expand Down
110 changes: 101 additions & 9 deletions lib/src/transformers/node_transformers/passive_canvas_transformer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -544,15 +544,17 @@ class CanvasViewport extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
final box = resolve(node, constraints, sizing: sizing);
// The width the canvas content is laid out at: the canvas box for a
// responsive canvas, the design width for an autoScale one (its
// content is laid out at design size and then scaled).
final Widget content = CanvasWidthScope(
width: node.scaleMode == ScaleMode.autoScale
? node.basicBoxLocal.width
: box.width,
child: child,
);
// The width that picks the canvas's responsive layouts: the canvas
// box for a responsive canvas, the design width for an autoScale one
// (its content is laid out at design size and then scaled). A canvas
// at its design size in a dialog, sheet or popover isn't the screen:
// a 520 wide dialog on a desktop shows its desktop layout.
final double width = sizing == CanvasSizing.design
? MediaQuery.maybeSizeOf(context)?.width ?? box.width
: node.scaleMode == ScaleMode.autoScale
? node.basicBoxLocal.width
: box.width;
final Widget content = CanvasWidthScope(width: width, child: child);
if (box.height != null) {
return SizedBox(width: box.width, height: box.height, child: content);
}
Expand Down Expand Up @@ -671,6 +673,7 @@ class _PassiveCanvasWidgetState extends State<PassiveCanvasWidget> {
codelessly?.removeNavigationListener('canvas-${widget.node.id}');
_gateUser?.removeListener(_enforceSignIn);
_stopWatchingValues();
HardwareKeyboard.instance.removeHandler(_onKey);
super.dispose();
}

Expand Down Expand Up @@ -758,6 +761,10 @@ class _PassiveCanvasWidgetState extends State<PassiveCanvasWidget> {
super.didUpdateWidget(oldWidget);
if (widget.node != oldWidget.node) {
onLoadActions = collectOnLoadActions();
if (!widget.settings.isPreview && didPerformOnLoadActions) {
HardwareKeyboard.instance.removeHandler(_onKey);
_listenToKeys();
}
}
}

Expand All @@ -779,8 +786,83 @@ class _PassiveCanvasWidgetState extends State<PassiveCanvasWidget> {

triggerOnLoadActions(context);
_watchValues(context);
_listenToKeys();
}

/// The screen's "Key Down" reactions, while it listens to the keyboard.
List<Reaction> _keyReactions = const [];

/// Runs the screen's "Key Down" actions for keys pressed (and held) while
/// it is the screen on top and no text field has focus.
void _listenToKeys() {
_keyReactions = [
for (final Reaction reaction in widget.node.reactions)
if (reaction.trigger.type == TriggerType.keyDown &&
reaction.action.enabled)
reaction,
];
if (_keyReactions.isNotEmpty) {
HardwareKeyboard.instance.addHandler(_onKey);
}
}

bool _onKey(KeyEvent event) {
if (event is KeyUpEvent || !mounted) return false;
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return false;
final BuildContext? focused = FocusManager.instance.primaryFocus?.context;
if (focused != null &&
(focused.widget is EditableText ||
focused.findAncestorWidgetOfExactType<EditableText>() != null)) {
return false;
}
// Shift, Control, Alt and Meta alone are modifiers of the next key.
if (_modifierKeys.contains(event.logicalKey)) return false;
final String key = keyTriggerLabel(event.logicalKey);
final List<Reaction> reactions = [
for (final Reaction reaction in _keyReactions)
if (reaction.trigger.key == null ||
reaction.trigger.key!.isEmpty ||
reaction.trigger.key!.toLowerCase() == key.toLowerCase())
reaction,
];
if (reactions.isEmpty) return false;
final HardwareKeyboard keyboard = HardwareKeyboard.instance;
FunctionsRepository.triggerAction(
context,
TriggerType.keyDown,
reactions: reactions,
result: {
'key': key,
'shift': keyboard.isShiftPressed,
'alt': keyboard.isAltPressed,
'meta': keyboard.isMetaPressed || keyboard.isControlPressed,
},
);
// A reaction for a specific key takes it (an arrow doesn't also scroll
// the page); a reaction for any key lets it through, so Tab and the
// browser's shortcuts keep working.
return reactions.any(
(reaction) => reaction.trigger.key?.isNotEmpty ?? false,
);
}

static final Set<LogicalKeyboardKey> _modifierKeys = {
LogicalKeyboardKey.shift,
LogicalKeyboardKey.shiftLeft,
LogicalKeyboardKey.shiftRight,
LogicalKeyboardKey.control,
LogicalKeyboardKey.controlLeft,
LogicalKeyboardKey.controlRight,
LogicalKeyboardKey.alt,
LogicalKeyboardKey.altLeft,
LogicalKeyboardKey.altRight,
LogicalKeyboardKey.meta,
LogicalKeyboardKey.metaLeft,
LogicalKeyboardKey.metaRight,
LogicalKeyboardKey.capsLock,
LogicalKeyboardKey.fn,
};

List<ActionModel> collectOnLoadActions() => widget.node.reactions
.whereTriggerType(TriggerType.load)
.map((e) => e.action)
Expand Down Expand Up @@ -854,3 +936,13 @@ class _PassiveCanvasWidgetState extends State<PassiveCanvasWidget> {
);
}
}

/// The label a "Key Down" trigger's `${result.key}` reads for [key]: the
/// character of a character key (`a`, `1`, `/`), else the key's name
/// without spaces (`ArrowLeft`, `Delete`, `Escape`, `Enter`, `Space`).
String keyTriggerLabel(LogicalKeyboardKey key) {
if (key == LogicalKeyboardKey.space) return 'Space';
final String label = key.keyLabel;
if (label.length == 1) return label.toLowerCase();
return label.replaceAll(' ', '');
}
Loading
Loading