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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
## Unreleased

- Screen readers: a tappable frame or shape reads as a button. One with no
text inside (an icon button) is named after its node, or its icon when the
node keeps a default name like "Frame 3". Nodes no longer offer a long
press that does nothing.
- Show toast action: shows a message near the bottom of the app over every
route, with variables and formulas in the text. A new toast replaces the
one on screen.
- Formulas: `${= expression}` in any text, bound property, condition or
action value computes a result, e.g. `${= money(qty * price)}`,
`${= sum(storage.cart, 'total')}` or `${= count > 1 ? 'drinks' : 'drink'}`.
They support arithmetic, comparisons, `&& || !`, `a ? b : c`, lists, and
the functions money, fixed, round, floor, ceil, abs, min, max, sum, count,
length, if, plural, join, upper, lower, contains, number, text, isEmpty and
isNotEmpty. A value that is exactly one formula keeps its type. Nodes
rebuild when the variables a formula reads change.
- Style properties at runtime: `fill`, `stroke`, `opacity`, `width` and
`height` follow conditions, variables or formulas bound to them, and set
value actions.

## 7.2.0

- Require codelessly_api 5.2.0.
Expand Down
9 changes: 9 additions & 0 deletions example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,12 @@ dev_dependencies:

flutter:
uses-material-design: true

# Temporary: the show toast action needs codelessly_api from
# Codelessly/CodelesslyAPI#37, merged but not yet published. Remove once
# codelessly_api is released.
dependency_overrides:
codelessly_api:
git:
url: https://github.com/Codelessly/CodelesslyAPI.git
ref: master
1 change: 1 addition & 0 deletions lib/codelessly_sdk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export 'src/utils/extensions.dart';
export 'src/utils/flutter_icons_data_map.dart';
export 'src/utils/flutter_icons_names_map.dart';
export 'src/utils/font_parser.dart';
export 'src/utils/formula.dart';
export 'src/utils/image_builder.dart';
export 'src/utils/observable.dart';
export 'src/utils/rgb_to_color.dart';
Expand Down
156 changes: 156 additions & 0 deletions lib/src/functions/app_toast.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import 'dart:async';

import 'package:codelessly_api/codelessly_api.dart';
import 'package:flutter/material.dart';

import '../../codelessly_sdk.dart';

/// Shows [ShowToastAction] messages: a pill near the bottom of the screen
/// that fades in, stays for the action's duration and fades out. A new toast
/// replaces the one on screen.
class AppToast {
AppToast._();

static OverlayEntry? _current;

/// Shows [action]'s message. Returns false when there is no overlay to
/// show it in.
static bool show(BuildContext context, ShowToastAction action) {
final OverlayState? overlay = Overlay.maybeOf(context, rootOverlay: true);
if (overlay == null) return false;

// Read variables now: the action that follows may navigate away.
final String message = PropertyValueDelegate.substituteVariables(
action.message,
scopedValues: ScopedValues.of(context),
nullSubstitutionMode: NullSubstitutionMode.emptyString,
);

hide();
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _ToastView(
action: action,
message: message,
onDone: () {
if (_current == entry) _current = null;
if (entry.mounted) entry.remove();
},
),
);
_current = entry;
overlay.insert(entry);
return true;
}

/// Removes the toast on screen, if any.
static void hide() {
final OverlayEntry? entry = _current;
_current = null;
if (entry != null && entry.mounted) entry.remove();
}
}

class _ToastView extends StatefulWidget {
const _ToastView({
required this.action,
required this.message,
required this.onDone,
});

final ShowToastAction action;
final String message;
final VoidCallback onDone;

@override
State<_ToastView> createState() => _ToastViewState();
}

class _ToastViewState extends State<_ToastView> {
static const Duration _fade = Duration(milliseconds: 220);

bool _visible = false;
Timer? _timer;

@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _visible = true);
});
_timer = Timer(Duration(milliseconds: widget.action.duration), () {
if (!mounted) return;
setState(() => _visible = false);
_timer = Timer(_fade, widget.onDone);
});
}

@override
void dispose() {
_timer?.cancel();
super.dispose();
}

@override
Widget build(BuildContext context) {
final ShowToastAction action = widget.action;
final Color textColor = action.textColor.toFlutterColor();
final Widget? icon = action.icon == null
? null
: retrieveIconWidget(action.icon!, 18);
return Positioned(
left: 16,
right: 16,
bottom: action.bottomOffset,
child: IgnorePointer(
child: Semantics(
liveRegion: true,
label: widget.message,
child: AnimatedOpacity(
opacity: _visible ? 1 : 0,
duration: _fade,
child: Center(
child: Material(
type: MaterialType.transparency,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
decoration: BoxDecoration(
color: action.backgroundColor.toFlutterColor(),
borderRadius: BorderRadius.circular(999),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 18,
offset: Offset(0, 6),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[icon, const SizedBox(width: 8)],
Flexible(
child: Text(
widget.message,
style: DefaultTextStyle.of(context).style.copyWith(
color: textColor,
fontSize: 14,
fontWeight: FontWeight.w600,
decoration: TextDecoration.none,
),
),
),
],
),
),
),
),
),
),
),
);
}
}
57 changes: 51 additions & 6 deletions lib/src/functions/functions_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import '../logging/stat_tracker.dart';
import '../ui/codelessly_dialog_widget.dart';
import 'action_chain.dart';
import 'app_auth.dart';
import 'app_toast.dart';
import 'cloud_files.dart';

/// Enum representing the types of API requests.
Expand Down Expand Up @@ -134,6 +135,8 @@ class FunctionsRepository {
action as CloudFilesAction,
runActions: (actions) => runActions(context, actions),
);
case ActionType.showToast:
return AppToast.show(context, action as ShowToastAction);
}
}

Expand Down Expand Up @@ -942,6 +945,48 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
payload.nodeValues[action.nodeID]!.set(updateValues, notify: notify);
}

/// Substitutes variables in the [template] of a value to set. For list and
/// map values written as JSON, each JSON value is substituted on its own
/// (see [PropertyValueDelegate.substituteJsonValue]), so text holding
/// quotes or newlines can't break the JSON. Everything else is substituted
/// as text.
static String substituteNewValue(
String template,
VariableType type,
ScopedValues scopedValues,
) {
if (type.isList || type.isMap) {
// A whole-value formula such as `${= item}` keeps its list or map.
final String? expression = FormulaEvaluator.exactExpression(template);
if (expression != null) {
final Object? value = PropertyValueDelegate.evaluateFormula(
expression,
scopedValues: scopedValues,
);
if (value is List || value is Map) {
return jsonEncode(
value,
toEncodable: (value) =>
value is DateTime ? value.toIso8601String() : value.toString(),
);
}
}
final Object? parsed = tryJsonDecode(template.trim());
if (parsed is List || parsed is Map) {
return jsonEncode(
PropertyValueDelegate.substituteJsonValue(parsed, scopedValues),
toEncodable: (value) =>
value is DateTime ? value.toIso8601String() : value.toString(),
);
}
}
return PropertyValueDelegate.substituteVariables(
template,
nullSubstitutionMode: NullSubstitutionMode.emptyString,
scopedValues: scopedValues,
);
}

/// Sets given [action.newValue] for given [action.variable] to a variable
/// from [CodelesslyContext.variables].
/// Returns `true` if variable was found and updated, `false` otherwise.
Expand All @@ -958,10 +1003,10 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
);
if (variableNotifier == null) return false;

String newValue = PropertyValueDelegate.substituteVariables(
String newValue = substituteNewValue(
action.newValue,
nullSubstitutionMode: NullSubstitutionMode.emptyString,
scopedValues: scopedValues,
variableNotifier.value.type,
scopedValues,
);

Object? currentValue = variableNotifier.value.getValue();
Expand Down Expand Up @@ -1275,10 +1320,10 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json.
scopedValues: scopedValues,
);

final newValue = PropertyValueDelegate.substituteVariables(
final newValue = substituteNewValue(
action.newValue,
nullSubstitutionMode: NullSubstitutionMode.emptyString,
scopedValues: scopedValues,
action.variableType,
scopedValues,
);

final match = VariableMatch.parse(storageKey.wrapWithVariableSyntax());
Expand Down
Loading
Loading