Skip to content
Merged
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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ Needs the `codelessly_api` release after 6.2.0 (signature, loop animation,
side sheet, confirm presentation, toast styling, `contentFit` and
`shrinkWrapRows`).

### Motion, triggers and lists

- `NodeMotion` animates a node's `MotionModel`: transforms, 3D tilt, spin,
loops, entrances (from rest or `enterFrom`, replayed on `replayKey`) and
delays.
- Timer and key press triggers on screens; swipe triggers (the mouse wheel
swipes too) and double tap on frames, rectangles, buttons, icons and text.
- Stacked and wrapping lists; vertical, inverted and rectangular-thumb
sliders with bound `min`/`max`.

### Formulas and variables

- `ComputedVariables` helpers for editors: a computed variable's inputs,
cycles and preview values over default values.
- `duration`, `seconds`, `slug`, `luminance`, `findIndex`, `sortByDesc`,
multi-key `sortBy`, text `indexOf`/`slice`/`take`/`skip`/`reverse`,
object literals and `money(v, decimals)` / `money(v, symbol, 'auto')`.

### Style bindings and navigation

- Effects, stroke weight, corner radius, font weight and icon bind as
property variables (`StyleBindings`); image fills listen to the variables
in their URL.
- A screen's route is set before its load actions run, and a link's
parameters also set the variables they name. `CodelesslyWidget.initialScreen`.
- A screen variable shadows an app variable of the same name in Set
variable; dashed strokes draw their caps, and a zero pattern no longer
hangs.

### Nodes and actions

- `PassiveSignatureTransformer` draws a `SignatureNode`: strokes in
Expand Down
3 changes: 3 additions & 0 deletions lib/codelessly_sdk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ export 'src/ui/related_documents.dart';
export 'src/ui/show_more.dart';
export 'src/ui/zoomable_box.dart';
export 'src/ui/skeleton.dart';
export 'src/ui/node_motion.dart';
export 'src/ui/swipe_detector.dart';
export 'src/ui/stripe_painter.dart';
export 'src/utils/computed_variables.dart';
export 'src/utils/cupertino_icons_by_codepoint.dart';
export 'src/utils/extensions.dart';
export 'src/utils/flutter_icons_data_map.dart';
Expand Down
48 changes: 46 additions & 2 deletions lib/src/codelessly.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/widgets.dart';
import 'package:http/http.dart';
import 'package:provider/provider.dart';

import '../codelessly_sdk.dart';
import 'functions/app_auth.dart';
Expand Down Expand Up @@ -943,14 +944,55 @@ class Codelessly {
_currentNavigatedCanvasId = canvasId;
final ModalRoute? modal = ModalRoute.of(context);
if (canvasId != null && modal is! PopupRoute) {
route.value = CodelesslyRoute.of(canvasId, modal?.settings.arguments);
route.value = _routeOf(canvasId, modal);
_openPendingRoute(context, canvasId);
}
for (final listener in [..._navigationListeners.values]) {
listener(context, layoutId, canvasId);
}
}

/// The route of screen [canvasId] shown by [modal]: the link that opened
/// the app when it names this screen, or the parameters it was opened
/// with.
CodelesslyRoute _routeOf(String canvasId, ModalRoute? modal) {
final CodelesslyRoute? pending = _resolveLink(pendingRoute);
if (pending != null && pending.canvasId == canvasId) return pending;
return CodelesslyRoute.of(canvasId, modal?.settings.arguments);
}

/// Makes [route] screen [canvasId]'s before its load actions run, so they
/// can read `${route.name}`, and applies the link that opened the app to
/// it when the link names this screen (navigation listeners only hear of
/// a screen after its first frame).
void prepareRoute(BuildContext context, String canvasId) {
final ModalRoute? modal = ModalRoute.of(context);
if (modal is PopupRoute) return;
route.value = _routeOf(canvasId, modal);
final CodelesslyRoute? pending = _resolveLink(pendingRoute);
if (pending != null && pending.canvasId == canvasId) {
setVariablesFromRoute(context, pending);
}
}

/// Sets the variables a link's parameters name to their values, so
/// `/<app>/<screen>?selectedId=kind-of-blue` opens with `selectedId` set,
/// as well as passing them as the screen's route parameters. Parameters
/// that name no variable are only route parameters.
static void setVariablesFromRoute(
BuildContext context,
CodelesslyRoute route,
) {
if (route.params.isEmpty) return;
final CodelesslyContext? codelesslyContext =
Provider.of<CodelesslyContext?>(context, listen: false);
if (codelesslyContext == null) return;
for (final MapEntry<String, String> param in route.params.entries) {
if (codelesslyContext.findVariableByName(param.key) == null) continue;
codelesslyContext.updateVariable(param.key, param.value);
}
}

/// The link for [route]: the screen's URL path with its parameters filled
/// in when it has one (`guide/bat-star` for a screen at `guide/:id`; the
/// app's first screen without a path is the empty path), else
Expand All @@ -971,7 +1013,9 @@ class Codelessly {
if (pending == null) return;
_pendingRoute = null;
final CodelesslyRoute? link = _resolveLink(pending);
if (link == null || link.canvasId == canvasId) return;
if (link == null) return;
setVariablesFromRoute(context, link);
if (link.canvasId == canvasId) return;
unawaited(
FunctionsRepository.navigate(
context,
Expand Down
17 changes: 15 additions & 2 deletions lib/src/functions/functions_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1356,10 +1356,21 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
final ScopedValues scopedValues = ActionChain.scopedValuesOf(context);
final CodelesslyContext codelesslyContext = context
.read<CodelesslyContext>();
final variableNotifier = codelesslyContext.findVariableByName(
action.variable.name,
final variableNotifier = codelesslyContext.findVariable(
id: action.variable.id,
name: action.variable.name,
);
if (variableNotifier == null) return false;
// A computed variable's value comes from its formula: setting it does
// nothing.
if (variableNotifier.value.isComputed) {
DebugLogger.instance.printInfo(
'Variable ${variableNotifier.value.name} is computed from a formula '
'and cannot be set.',
name: name,
);
return false;
}

String newValue = substituteNewValue(
action.newValue,
Expand Down Expand Up @@ -1423,6 +1434,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.

final ValueNotifier<VariableData>? variableNotifier = payload.variables[id];
if (variableNotifier == null) return false;
if (variableNotifier.value.isComputed) return false;

variableNotifier.value = variableNotifier.value.copyWith(value: value);

Expand Down Expand Up @@ -1452,6 +1464,7 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
.findVariableByName(match.name);

if (propertyNotifier == null) return false;
if (propertyNotifier.value.isComputed) return false;

if (match.name != match.fullPath &&
(propertyNotifier.value.type == VariableType.map ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,7 @@ class _PassiveCanvasWidgetState extends State<PassiveCanvasWidget> {
codelessly?.removeNavigationListener('canvas-${widget.node.id}');
_gateUser?.removeListener(_enforceSignIn);
_stopWatchingValues();
_stopTimers();
super.dispose();
}

Expand Down Expand Up @@ -825,9 +826,76 @@ class _PassiveCanvasWidgetState extends State<PassiveCanvasWidget> {
super.didUpdateWidget(oldWidget);
if (widget.node != oldWidget.node) {
onLoadActions = collectOnLoadActions();
if (_timersStarted) _startTimers();
}
}

final List<Timer> _timers = [];
bool _timersStarted = false;

/// Runs each timer reaction every [TriggerModel.intervalMs] while the
/// canvas is shown. A tick is skipped while the previous one's actions
/// are still running, so slow actions don't pile up.
void _startTimers() {
_stopTimers();
_timersStarted = true;
for (final Reaction reaction in widget.node.reactions.whereTriggerType(
TriggerType.timer,
)) {
if (!reaction.action.enabled) continue;
bool busy = false;
_timers.add(
Timer.periodic(
Duration(milliseconds: reaction.trigger.effectiveIntervalMs),
(_) async {
if (!mounted || busy) return;
busy = true;
try {
await FunctionsRepository.triggerAction(
context,
TriggerType.timer,
reactions: [reaction],
);
} finally {
busy = false;
}
},
),
);
}
}

void _stopTimers() {
for (final Timer timer in _timers) {
timer.cancel();
}
_timers.clear();
}

/// Runs the key press reactions for [event]'s key. Returns whether one
/// matched.
KeyEventResult _onKey(FocusNode _, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
final String pressed = normalizeKeyLabel(event.logicalKey.keyLabel);
final List<Reaction> matching = [
for (final Reaction reaction in widget.node.reactions.whereTriggerType(
TriggerType.keyPress,
))
if (reaction.action.enabled &&
normalizeKeyLabel(reaction.trigger.key ?? '') == pressed)
reaction,
];
if (matching.isEmpty) return KeyEventResult.ignored;
FunctionsRepository.triggerAction(
context,
TriggerType.keyPress,
reactions: matching,
);
return KeyEventResult.handled;
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
Expand All @@ -844,8 +912,10 @@ class _PassiveCanvasWidgetState extends State<PassiveCanvasWidget> {
if (_redirected) return;
}

context.read<Codelessly?>()?.prepareRoute(context, widget.node.id);
triggerOnLoadActions(context);
_watchValues(context);
_startTimers();
}

List<ActionModel> collectOnLoadActions() => widget.node.reactions
Expand Down Expand Up @@ -915,7 +985,22 @@ class _PassiveCanvasWidgetState extends State<PassiveCanvasWidget> {
statusBarIconBrightness: brightness,
statusBarBrightness: brightness,
),
child: Builder(builder: widget.builder),
child:
widget.settings.isPreview ||
!widget.node.reactions.any(
(reaction) => reaction.trigger.type == TriggerType.keyPress,
)
? Builder(builder: widget.builder)
: Focus(
autofocus: true,
onKeyEvent: _onKey,
child: Builder(builder: widget.builder),
),
);
}
}

/// A key label in the form key press triggers compare: lower case, without
/// spaces, so `Arrow Right`, `ArrowRight` and `arrowright` all match.
String normalizeKeyLabel(String label) =>
label.replaceAll(' ', '').toLowerCase();
Loading
Loading