Skip to content

Instantly share code, notes, and snippets.

@roipeker
Last active October 27, 2023 12:51
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save roipeker/674eb79ec06b099166f140f1b655e29c to your computer and use it in GitHub Desktop.
Save roipeker/674eb79ec06b099166f140f1b655e29c to your computer and use it in GitHub Desktop.
TextField concept for GetX (WIP) ...
/// copyright 2020, roipeker
class FormValidations {
static String email(String val) {
val = val.trim();
if (val.isEmpty) return 'Email cant be empty';
if (val.length <= 4) return 'Email is too short';
if (!val.isEmail) return 'Invalid email';
return null;
}
static String matchPasswords(String val1, String val2) {
if (val1.isEmpty || val2.isEmpty)
return 'Password cant be empty';
if (val1 != val2) return 'Passwords doesnt match';
return null;
}
static String password(String val) {
if (val.isEmpty) return 'Password cant be empty';
if (val.length <= 4) return 'Password is too short';
if (val.isAlphabetOnly) return 'Password must be alpha numeric';
return null;
}
static String name(String val, {String label}) {
val = val.trim();
if (val.isEmpty) return '$label can not be empty';
if (val.length <= 2) return '$label is too short';
return null;
}
static String phone(String val) {
val = val.trim();
val = val.replaceAll(' ', '');
if (val.isEmpty) return 'Phone is required';
if (val.length <= 7) return 'Phone seems to be too short';
if (!val.isNumericOnly) return 'Use only numbers';
return null;
}
}
class FormInputFormatters {
static List<TextInputFormatter> priceFormatter = [
TextInputFormatter.withFunction((oldValue, newValue) {
return newValue;
}),
FilteringTextInputFormatter.allow(RegExp('[0-9\\.,\$]')),
];
}
class FormFormatters {
static var filterName =
FilteringTextInputFormatter.allow(RegExp(r'[a-z A-Z á-ú Á-Ú]'));
static var filterPhone = FilteringTextInputFormatter.digitsOnly;
}
/// Format zipcode in format #####-####
class ZipTextInputFormatter extends TextInputFormatter {
static final instance = ZipTextInputFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final int newTextLength = newValue.text.length;
int selectionIndex = newValue.selection.end;
int usedSubstringIndex = 0;
final StringBuffer newText = StringBuffer();
if (newTextLength >= 6) {
newText.write(newValue.text.substring(0, usedSubstringIndex = 5) + '-');
if (newValue.selection.end >= 5) selectionIndex++;
}
if (newTextLength >= usedSubstringIndex) {
newText.write(newValue.text.substring(usedSubstringIndex));
}
return TextEditingValue(
text: newText.toString(),
selection: TextSelection.collapsed(
offset: selectionIndex,
),
);
}
}
/// copyright 2020, roipeker
//import 'package:descontar_app/const/const.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
enum ValidationPlace {
/// calls [validate()] when you focus out the TextField.
focus,
/// calls [validate()] as you type the text.
change,
/// in [manual] you take care of calling of the errors on each TextField.
/// you can use [TextConfig.validateAll([])] to run the [validate()] on each
/// TextField, or [TextConfig.getErrors([])] to get run [onValidate()] and
/// get a List<String> of errors (and no TextField UI invalidation).
manual,
}
enum ErrorMode { none, fixed, float }
class GetInputTextConfig extends GetxController {
final InputBorder border;
final InputBorder focusedBorder;
final InputBorder enabledBorder;
final InputBorder errorBorder;
final InputBorder disabledBorder;
final InputBorder focusedErrorBorder;
final ValidationPlace validationPlace;
final TextInputType keyboardType;
final TextInputAction textInputAction;
final TextCapitalization textCapitalization;
TextStyle style;
TextStyle disabledStyle;
final bool showCounter;
final String label;
final IconData icon;
final bool isPassword;
final ErrorMode errorMode;
final bool autocorrect;
// another instance will control this one.
GetInputTextConfig _obscureController;
GetInputTextConfig get obscureController => _obscureController;
set obscureController(GetInputTextConfig value) {
_obscureController = value;
_obscureController?._childObscureToControl = this;
}
// this is the child instance to control by the obscureController.
GetInputTextConfig _childObscureToControl;
final Iterable<String> autofillHints;
final List<TextInputFormatter>
inputFormatters; //FilteringTextInputFormatter.digitsOnly,
// decoration stuffs.
final FloatingLabelBehavior floatingLabelBehavior;
/// should be in an InputDecoration object
final bool isCollapsed;
int maxLength;
bool _obscureText = false;
FocusNode _focus;
TextEditingController _controller;
FormFieldValidator<String> onValidate;
bool clearErrorOnFocus = true;
bool clearErrorOnTyping = true;
String lastError;
bool _enabled;
bool get enabled => _enabled;
TextStyle get _actualStyle {
if (_enabled ?? true) return style;
return disabledStyle;
}
set enabled(bool val) {
if (_enabled == val) return;
_enabled = val;
update();
}
static List<String> getErrors(List<GetInputTextConfig> inputs) {
final output = <String>[];
for (var i in inputs) {
final error = i.onValidate(i.value);
if (!error.isNullOrBlank) output.add(error);
}
return output;
}
/// Runs [validate()] on each element in [inputs].
/// [stopOnError] will return false on the first element with an error.
/// otherwise it will validate() the entire [inputs] List.
static bool validateAll(List<GetInputTextConfig> inputs, {bool stopOnError = true}) {
bool hasError = false;
for (var i in inputs) {
if (!i.validate()) {
hasError = true;
if (stopOnError) break;
}
}
return !hasError;
}
GetInputTextConfig({
this.onValidate,
this.validationPlace = ValidationPlace.manual,
this.errorMode = ErrorMode.fixed,
bool enabled,
this.showCounter = false,
this.isCollapsed = false,
this.floatingLabelBehavior = FloatingLabelBehavior.auto,
this.keyboardType,
this.textInputAction,
this.textCapitalization,
this.maxLength,
GetInputTextConfig obscureController,
this.autocorrect = true,
this.inputFormatters,
this.autofillHints,
this.style,
// this.disabledStyle = const TextStyle(color: Styles.darkGrey),
this.label,
this.icon,
this.border = const UnderlineInputBorder(),
this.focusedBorder,
this.enabledBorder,
this.errorBorder,
this.disabledBorder,
this.focusedErrorBorder,
this.isPassword = false,
}) {
_obscureText = isPassword;
obscureController?._childObscureToControl = this;
this.enabled = enabled;
}
FocusNode get focus => _focus ??= FocusNode();
TextEditingController get controller =>
_controller ??= TextEditingController();
String get value => controller.text;
set value(String val) {
val ??= '';
if (val == controller.text) return;
controller.value = controller.value.copyWith(text: val);
}
bool get obscureText => _obscureText;
set obscureText(bool flag) {
if (_obscureText == flag) return;
_obscureText = flag;
update();
}
@override
void onInit() {
focus.addListener(_handleFocus);
controller.addListener(_handleTextChange);
}
bool get hasFocus => focus.hasFocus;
String _value;
void _handleTextChange() {
var val = controller.text;
if (val == _value) return;
_value = val;
if (onChanged != null) onChanged(_value);
if (validationPlace == ValidationPlace.change) {
validate();
} else {
if (clearErrorOnTyping) error = '';
}
}
void _handleFocus() {
if (onFocus != null) onFocus(hasFocus);
if (!hasFocus) {
if (validationPlace == ValidationPlace.focus) {
validate();
}
} else {
if (hasError && clearErrorOnFocus) {
error = '';
}
}
}
bool validate() {
if (onValidate != null) {
error = onValidate(value);
lastError = error;
}
return !hasError;
}
bool get hasError {
// return !_error.isNullOrBlank;
return !lastError.isNullOrBlank;
// return _actualErrorText != null;
}
/// todo: give ability to access last error.../
/// make a private var, and do error public and holding the last
/// message.
String get error => _error;
set error(String val) {
if (_error == val) return;
_error = val;
if (onErrorChange != null)
onErrorChange(_error.isNullOrBlank ? null : _error);
update();
}
Widget get _counterWidget {
if (errorMode == ErrorMode.fixed) return null;
return (showCounter ?? false) ? null : Container();
}
String get _counterText {
if (errorMode == ErrorMode.fixed) return ' ';
return null; // ErrorMode.float.
}
// used by widget
String get _actualErrorText {
if (errorMode == ErrorMode.none) return null;
return _error.isNullOrBlank ? null : _error;
}
String _error;
Function(String) onChanged;
Function(bool) onFocus;
Function(String) onErrorChange;
@override
void onClose() {
controller?.removeListener(_handleTextChange);
controller?.dispose();
focus?.removeListener(_handleFocus);
focus?.dispose();
}
Widget getSuffix() {
if (!isPassword) return null;
if (obscureController != null) return null;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
obscureText = !obscureText;
_childObscureToControl?.obscureText = obscureText;
},
child: Icon(
obscureText ? Icons.visibility : Icons.visibility_off,
// color: Styles.lightGrey,
size: 18,
).paddingSymmetric(horizontal: 10, vertical: 6),
);
}
}
class GetInputText extends StatelessWidget {
final GetInputTextConfig config;
const GetInputText({Key key, this.config}) : super(key: key);
@override
Widget build(BuildContext context) {
return GetBuilder(
init: config,
global: false,
assignId: true,
builder: (_) {
return TextField(
controller: config.controller,
focusNode: config.focus,
style: config._actualStyle,
obscureText: config.obscureText,
keyboardType: config.keyboardType,
maxLength: config.maxLength,
maxLengthEnforced: !config.maxLength.isNull,
autocorrect: config.autocorrect ?? true,
autofillHints: (config.enabled ?? true) ? config.autofillHints : null,
textInputAction: config.textInputAction,
inputFormatters: config.inputFormatters,
enabled: config.enabled,
textCapitalization:
config.textCapitalization ?? TextCapitalization.none,
//const InputDecoration()
decoration: InputDecoration(
labelText: config.label,
border: config.border,
focusedBorder: config.focusedBorder,
enabledBorder: config.enabledBorder,
errorBorder: config.errorBorder,
disabledBorder: config.disabledBorder,
focusedErrorBorder: config.focusedErrorBorder,
// errorBorder: UnderlineInputBorder(
// borderSide: BorderSide(color: Styles.lightGrey),
// ),
contentPadding: EdgeInsets.symmetric(vertical: 6),
alignLabelWithHint: true,
floatingLabelBehavior:
config.floatingLabelBehavior ?? FloatingLabelBehavior.auto,
errorText: config._actualErrorText,
errorMaxLines: 1,
counterText: config._counterText,
counter: config._counterWidget,
icon: config.icon != null
? Icon(config.icon, size: 20).paddingOnly(top: 10)
: null,
isCollapsed: config.isCollapsed ?? false,
suffixIconConstraints: BoxConstraints(maxWidth: 24, maxHeight: 24),
// suffixIcon: config.isPassword ? config.getSuffix() : null,
suffix: config.isPassword ? config.getSuffix() : null,
),
);
},
);
}
}
/// copyright 2020, roipeker
import 'package:get/get.dart';
import 'signup_fields.dart';
class SignupController extends GetxController {
final fields = SignupFields();
@override
void onReady() {
fields.phone.focus.requestFocus();
}
void onSend() {
if (fields.validate()) {
print("All valid, send form");
}
}
}
/// copyright 2020, roipeker
import 'package:descontar_app/const/const.dart';
import 'package:descontar_app/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'signup_controller.dart';
class SignupView extends GetView<SignupController> {
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
toolbarHeight: 40,
elevation: 0,
),
body: CommonBackground(
showBottom: false,
children: [
Center(
child: SingleChildScrollView(
child: SizedBox(
width: 250,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset(Images.logo, fit: BoxFit.contain),
GetInputText(config: controller.fields.name),
GetInputText(config: controller.fields.lastname),
GetInputText(config: controller.fields.email),
GetInputText(config: controller.fields.phone),
GetInputText(config: controller.fields.password),
GetInputText(config: controller.fields.repeatPassword),
const Gap(24),
LineButton(
label: 'REGISTRARSE',
onTap: controller.onSend,
),
Gap(40),
],
),
),
),
)
],
),
);
}
}
/// copyright 2020, roipeker
import 'package:descontar_app/utils/form_utils.dart';
import 'package:descontar_app/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class SignupFields {
GetInputTextConfig name = GetInputTextConfig(
label: AppStrings.name_label,
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.name,
onValidate: (val) => FormValidations.name(val, label: 'nombre'),
maxLength: 15,
inputFormatters: [FormFormatters.filterName],
autofillHints: [
AutofillHints.name,
AutofillHints.creditCardName,
],
errorMode: ErrorMode.fixed,
// validationPlace: ValidationPlace.manual,
);
GetInputTextConfig lastname = GetInputTextConfig(
label: AppStrings.lastname_label,
onValidate: (val) => FormValidations.name(val, label: 'apellido'),
textCapitalization: TextCapitalization.words,
textInputAction: TextInputAction.next,
inputFormatters: [FormFormatters.filterName],
keyboardType: TextInputType.name,
maxLength: 26,
autofillHints: [
AutofillHints.familyName,
AutofillHints.givenName,
AutofillHints.creditCardFamilyName
],
errorMode: ErrorMode.fixed,
// validationPlace: ValidationPlace.manual,
);
GetInputTextConfig email = GetInputTextConfig(
label: AppStrings.email_label,
onValidate: (val) => FormValidations.email(val),
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
maxLength: 200,
autofillHints: [
AutofillHints.username,
AutofillHints.email,
],
// errorMode: ErrorMode.fixed,
// validationPlace: ValidationPlace.manual,
);
GetInputTextConfig phone = GetInputTextConfig(
label: AppStrings.phone_label,
onValidate: (val) => FormValidations.phone(val),
// inputFormatters: [FormFormatters.filterPhone],
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.phone,
maxLength: 28,
autofillHints: [
AutofillHints.telephoneNumberDevice,
AutofillHints.telephoneNumber,
AutofillHints.telephoneNumberNational,
],
// errorMode: ErrorMode.fixed,
// validationPlace: ValidationPlace.manual,
);
GetInputTextConfig password = GetInputTextConfig(
onValidate: (val) => FormValidations.password(val),
label: AppStrings.password_label,
maxLength: 255,
isPassword: true,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.send,
keyboardType: TextInputType.visiblePassword,
autofillHints: [AutofillHints.newPassword],
// errorMode: ErrorMode.float,
// validationPlace: ValidationPlace.manual,
);
GetInputTextConfig repeatPassword = GetInputTextConfig(
onValidate: (val) => FormValidations.password(val),
label: AppStrings.repeat_password_label,
maxLength: 255,
isPassword: true,
// obscureController: password,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.send,
keyboardType: TextInputType.visiblePassword,
autofillHints: [AutofillHints.newPassword],
// errorMode: ErrorMode.fixed,
// validationPlace: ValidationPlace.manual,
);
List<GetInputTextConfig> allFields;
SignupFields() {
allFields = [name, lastname, email, phone, password, repeatPassword];
repeatPassword.obscureController = password;
}
bool validate() {
var error = '';
for (var i in allFields) {
if (!i.validate()) {
error = i.error;
break;
}
}
if (error.isNotEmpty) {
AppGetUtils.showError(error);
return false;
} else {
// validate passwords stand alone.
error = FormValidations.matchPasswords(
password.value, repeatPassword.value) ??
'';
if (error.isNotEmpty) {
repeatPassword.error = error;
AppGetUtils.showError(error);
return false;
}
}
return true;
}
}
/// copyright 2020, roipeker
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
// ignore: implementation_imports
//import 'package:get/src/core/get_interface.dart';
extension MyGet on GetInterface {
double get devicePixelRatio => ui.window.devicePixelRatio;
}
extension MyExtVisible on Widget {
Widget visible(bool flag, {bool keepSize = true}) {
return Visibility(
child: this,
visible: flag,
maintainInteractivity: false,
maintainSize: keepSize,
maintainState: keepSize,
maintainAnimation: keepSize,
);
}
Widget opacity({double opacity = 1}) {
return Opacity(
child: this,
opacity: opacity,
);
}
Widget ignorePointer(bool flag, {bool ignoreSemantics = false}) {
return IgnorePointer(
child: this,
ignoring: flag,
ignoringSemantics: ignoreSemantics,
);
}
Widget animOpacity({
double opacity = 1,
Duration duration = const Duration(milliseconds: 300),
Curve curve = Curves.decelerate,
VoidCallback onEnd,
}) {
return AnimatedOpacity(
child: this,
opacity: opacity,
duration: duration,
curve: curve,
onEnd: onEnd,
);
}
}
class AppGetUtils {
static bool isPreloading = false;
static Future<void> hidePreloader() async {
if (!isPreloading) return;
isPreloading = false;
if (!Get.isSnackbarOpen) {
Get.back();
}
}
static Future<void> showPreloader() async {
if (isPreloading) return;
isPreloading = true;
Get.dialog(
Center(child: PreloaderCircular()),
barrierDismissible: false,
);
}
static bool get isSnackbarOpened =>
_lastSnackbarStatus != SnackbarStatus.OPEN;
static SnackbarStatus _lastSnackbarStatus;
static void showError(
String errors, {
String title = 'Error:',
SnackbarStatusCallback onStatus,
}) {
if (isPreloading && Get.isDialogOpen) {
hidePreloader();
}
Get.snackbar(
title,
errors,
colorText: Colors.white,
borderRadius: 0,
snackbarStatus: (status) {
_lastSnackbarStatus = status;
onStatus?.call(status);
},
// barBlur: 0,
backgroundColor: Colors.red.shade800,
icon: Icon(
Icons.error,
color: Colors.white,
),
animationDuration: 0.45.seconds,
forwardAnimationCurve: Curves.fastLinearToSlowEaseIn,
reverseAnimationCurve: Curves.easeOutExpo,
// overlayBlur: 0,
// overlayColor: Styles.primaryColor.withOpacity(.8),
// overlayColor: Colors.black54,
overlayColor: Colors.white54,
// overlayBlur: GetPlatform.isAndroid ? .1 : 5,
overlayBlur: .1,
margin: EdgeInsets.symmetric(vertical: 24, horizontal: 12),
snackStyle: SnackStyle.FLOATING,
snackPosition: SnackPosition.BOTTOM,
// snackStyle: SnackStyle.GROUNDED,
// snackPosition: SnackPosition.TOP,
);
}
static void showSuccess(
String message, {
String title,
SnackbarStatusCallback onStatus,
}) {
if (isPreloading && Get.isDialogOpen) {
hidePreloader();
}
Get.snackbar(
title,
message,
colorText: Colors.white,
borderRadius: 0,
backgroundColor: Colors.green.shade800,
icon: Icon(
Icons.check,
color: Colors.white,
),
snackbarStatus: (status) {
_lastSnackbarStatus = status;
onStatus?.call(status);
},
animationDuration: 0.3.seconds,
forwardAnimationCurve: Curves.fastLinearToSlowEaseIn,
reverseAnimationCurve: Curves.easeOutExpo,
shouldIconPulse: false,
margin: EdgeInsets.symmetric(vertical: 24, horizontal: 12),
overlayColor: Colors.white54,
snackStyle: SnackStyle.FLOATING,
snackPosition: SnackPosition.BOTTOM,
);
}
static void unfocus() {
Get.focusScope?.unfocus();
}
static void showFCM({String title, String body}) {
///{google.c.sender.id: 943373216722, google.c.a.e: 1, gcm.notification.sound2: default, aps: {alert: {title: ahi va, body: probandooo}, sound: default, mutable-content: 1}, gcm.n.e: 1, google.c.a.c_id: 1528606179344859117, google.c.a.udt: 0, gcm.message_id: 1601334741915383, google.c.a.ts: 1601334741, click_action: FLUTTER_NOTIFICATION_CLICK}
Get.snackbar(
title,
body,
colorText: Colors.white,
borderRadius: 0,
duration: 6.seconds,
barBlur: 0,
// backgroundColor: Colors.green.shade800,
backgroundColor: Colors.black87,
icon: Icon(
Icons.notification_important,
color: Colors.white,
),
// snackbarStatus: (status) {
// _lastSnackbarStatus = status;
// onStatus?.call(status);
// },
animationDuration: 0.8.seconds,
forwardAnimationCurve: Curves.easeOut,
reverseAnimationCurve: Curves.easeOutExpo,
// overlayColor: Styles.primaryColor.withOpacity(.8),
shouldIconPulse: true,
margin: EdgeInsets.zero,
// overlayColor: Colors.black54,
// overlayBlur: .1,
overlayColor: Colors.black38,
snackStyle: SnackStyle.GROUNDED,
snackPosition: SnackPosition.TOP,
);
}
}
class RootWidget extends StatelessWidget {
const RootWidget({Key key, this.child}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
ImageUtils.imageScale = Get.devicePixelRatio;
return Builder(builder: (ctx) {
return RootKeyboardManager(
child: child,
);
});
}
static Widget builder(BuildContext context, Widget screen) {
return RootWidget(child: screen);
}
}
class RootKeyboardManager extends StatelessWidget {
final Widget child;
/// Allow close keyboard on tap
static bool allowCloseKeyboard = true;
const RootKeyboardManager({Key key, this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
if (allowCloseKeyboard) {
WidgetsBinding.instance.focusManager.primaryFocus?.unfocus();
}
},
child: child,
);
}
}
class PreloaderCircular extends StatelessWidget {
final double size;
final EdgeInsets padding;
final Color preloaderColor;
final Color preloaderInactiveColor;
final Color bgColor;
const PreloaderCircular({
Key key,
this.size,
this.padding,
this.bgColor = Colors.white,
this.preloaderColor = Styles.circularProgress,
this.preloaderInactiveColor,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: size ?? 64,
height: size ?? 64,
decoration: BoxDecoration(
// color: Colors.white12,
color: bgColor,
borderRadius: BorderRadius.circular(6),
),
padding: padding ?? EdgeInsets.all(20),
child: Theme(
data: ThemeData(accentColor: Styles.circularProgress),
child: CircularProgressIndicator(
strokeWidth: 2,
// backgroundColor: Colors.white10,
// backgroundColor: Colors.grey.shade100,
backgroundColor: preloaderInactiveColor ?? Colors.grey.shade100,
),
),
),
);
}
}
@NiranjanShah
Copy link

where do we get the below packages 👍
import 'package:descontar_app/const/const.dart';
import 'package:descontar_app/utils/form_utils.dart';
import 'package:descontar_app/utils/utils.dart';
import 'package:descontar_app/widgets/widgets.dart';

@NiranjanShah
Copy link

How to achieve navigation from 1 column to other column, if user presses enter key. And i believe this code can be used for flutter web

@roipeker
Copy link
Author

roipeker commented Dec 7, 2020

where do we get the below packages 👍
import 'package:descontar_app/const/const.dart';
import 'package:descontar_app/utils/form_utils.dart';
import 'package:descontar_app/utils/utils.dart';
import 'package:descontar_app/widgets/widgets.dart';

Don't mind about missing files. Try to workaround the code from those dependencies, as this was taken from an actual project, i just saved it for future references of how to implement a custom TextField with GetX.

@sylvesterasiedu
Copy link

Thanks Roi

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment