diff --git a/docs/widgets/text_form_field.mdx b/docs/widgets/text_form_field.mdx index 22db15141..f96759a99 100644 --- a/docs/widgets/text_form_field.mdx +++ b/docs/widgets/text_form_field.mdx @@ -111,6 +111,64 @@ Use `inputFormatters` to restrict or transform user input. The supported formatt } ``` +## Validator Rules + +Use `validatorRules` to validate user input. Each entry is a `StacFormFieldValidator` with a `rule`, an optional `message` shown when validation fails, and an optional `options` map for parameterized rules. + +| Property | Type | Description | +|----------|----------|--------------------------------------------------------------------------------------| +| rule | `String` | The validator to apply (see below). | +| message | `String` | Error message shown when validation fails. Defaults to `Invalid input` when omitted. | +| options | `Map` | Arguments for parameterized rules. Ignored by rules that take no arguments. | + +Validators are powered by the [`flutter_validators`](https://pub.dev/packages/flutter_validators) package. + + +`flutter_validators` treats an empty string as invalid (e.g. `isEmail('')` is +`false`), so **any field with `validatorRules` is effectively required** — an +empty value fails validation. There is no built-in "validate only when filled" +option. For an optional field, omit `validatorRules`; if you need conditional +validation, handle the blank-input check in your app before applying the rules. + + +**No-argument rules:** `isAlpha`, `isAlphanumeric`, `isAscii`, `isBase32`, `isBase58`, `isBoolean`, `isCreditCard`, `isDate`, `isDecimal`, `isEmail`, `isFQDN`, `isHexColor`, `isHexadecimal`, `isInt`, `isIP`, `isJson`, `isJWT`, `isLatLong`, `isLowercase`, `isMACAddress`, `isMD5`, `isMongoId`, `isNumeric`, `isOctal`, `isPhone`, `isPort`, `isSemVer`, `isSlug`, `isUppercase`, `isURL`, `isUUID`. + +**Parameterized rules** (read arguments from `options`): + +| Rule | Options | +|--------------------|-------------------------------------------------------------------------| +| `isLength` | `min` (int), `max` (int, optional) | +| `isByteLength` | `min` (int), `max` (int, optional) | +| `isFloat` | `min` (double, optional), `max` (double, optional) | +| `isBase64` | `urlSafe` (bool) | +| `isStrongPassword` | `minLength`, `minLowercase`, `minUppercase`, `minNumbers`, `minSymbols` | +| `contains` | `seed` (String), `ignoreCase` (bool), `minOccurrences` (int) | +| `equals` | `comparison` (String) | +| `isIn` | `values` (List of String) | +| `matches` | `pattern` (String) — a raw regular expression | +| `compare` | `fieldId` (String) — id of another field to compare values with | + +The `matches` rule matches the pattern *anywhere* in the value, so anchor it with `^...$` for a full-string match. The `compare` rule is useful for confirm-password fields. Remember that every rule fails on empty input, so a field carrying any of these rules is treated as required. + +```json +{ + "type": "textFormField", + "id": "password", + "obscureText": true, + "validatorRules": [ + { + "rule": "isStrongPassword", + "message": "Use a stronger password" + }, + { + "rule": "isLength", + "options": { "min": 8, "max": 32 }, + "message": "Password must be 8-32 characters" + } + ] +} +``` + ## Example diff --git a/examples/counter_example/pubspec.lock b/examples/counter_example/pubspec.lock index 5062314b0..d4b417fbc 100644 --- a/examples/counter_example/pubspec.lock +++ b/examples/counter_example/pubspec.lock @@ -299,6 +299,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_validators: + dependency: transitive + description: + name: flutter_validators + sha256: "00cc032a0eafaf5f0d4b1a3ed3347d52cd4bface4c1853ebbf1faf183bab346d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" flutter_web_plugins: dependency: transitive description: flutter diff --git a/examples/movie_app/pubspec.lock b/examples/movie_app/pubspec.lock index 7ed7c023e..ce18b3d76 100644 --- a/examples/movie_app/pubspec.lock +++ b/examples/movie_app/pubspec.lock @@ -267,6 +267,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_validators: + dependency: transitive + description: + name: flutter_validators + sha256: "00cc032a0eafaf5f0d4b1a3ed3347d52cd4bface4c1853ebbf1faf183bab346d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" flutter_web_plugins: dependency: transitive description: flutter diff --git a/examples/stac_gallery/assets/json/form_example.json b/examples/stac_gallery/assets/json/form_example.json index c786085a8..9d6f15935 100644 --- a/examples/stac_gallery/assets/json/form_example.json +++ b/examples/stac_gallery/assets/json/form_example.json @@ -34,8 +34,16 @@ }, "validatorRules": [ { - "rule": "^(?=[a-zA-Z0-9._]{8,20}$)(?!.*[_.]{2})[^_.].*[^_.]$", - "message": "Add a valid username" + "rule": "isAlphanumeric", + "message": "Letters and numbers only" + }, + { + "rule": "isLength", + "options": { + "min": 8, + "max": 20 + }, + "message": "Username must be 8-20 characters" } ] }, @@ -49,7 +57,16 @@ "decoration": { "hintText": "Password" }, - "autovalidateMode": "onUserInteraction" + "autovalidateMode": "onUserInteraction", + "validatorRules": [ + { + "rule": "isLength", + "options": { + "min": 1 + }, + "message": "Password is required" + } + ] }, { "type": "sizedBox", diff --git a/examples/stac_gallery/pubspec.lock b/examples/stac_gallery/pubspec.lock index ff6d69155..0d407ff33 100644 --- a/examples/stac_gallery/pubspec.lock +++ b/examples/stac_gallery/pubspec.lock @@ -283,6 +283,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_validators: + dependency: transitive + description: + name: flutter_validators + sha256: "00cc032a0eafaf5f0d4b1a3ed3347d52cd4bface4c1853ebbf1faf183bab346d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" flutter_web_plugins: dependency: transitive description: flutter diff --git a/packages/stac/lib/src/parsers/widgets/stac_text_form_field/stac_text_form_field_parser.dart b/packages/stac/lib/src/parsers/widgets/stac_text_form_field/stac_text_form_field_parser.dart index 361baf86e..ae59f0f23 100644 --- a/packages/stac/lib/src/parsers/widgets/stac_text_form_field/stac_text_form_field_parser.dart +++ b/packages/stac/lib/src/parsers/widgets/stac_text_form_field/stac_text_form_field_parser.dart @@ -129,26 +129,31 @@ class _TextFormFieldWidgetState extends State<_TextFormFieldWidget> { } String? _validate(String? value, StacTextFormField model) { - if (value != null && (widget.model.validatorRules?.isNotEmpty ?? false)) { - for (final validator in widget.model.validatorRules!) { - try { - final validationType = InputValidationType.values.firstWhere( - (e) => e.name == validator.rule, - orElse: () => InputValidationType.general, - ); + if (value == null || !(model.validatorRules?.isNotEmpty ?? false)) { + return null; + } - if (validationType == InputValidationType.general) { - if (!InputValidationType.general.validate(value, validator.rule)) { - return validator.message; - } - } else { - if (!validationType.validate(value, validator.rule)) { - return validator.message; - } - } - } catch (e) { - Log.e(e); + for (final validator in model.validatorRules!) { + try { + bool isValid; + if (validator.rule == 'compare') { + final targetId = validator.options?['fieldId'] as String?; + final target = targetId == null + ? null + : widget.formScope?.formData[targetId]?.toString(); + isValid = value == target; + } else { + isValid = InputValidators.validate( + validator.rule, + value, + options: validator.options, + ); } + + if (!isValid) return validator.message ?? 'Invalid input'; + } catch (e) { + Log.e(e); + return validator.message ?? 'Invalid input'; } } diff --git a/packages/stac/lib/src/utils/input_validations.dart b/packages/stac/lib/src/utils/input_validations.dart index d311ef8db..619129151 100644 --- a/packages/stac/lib/src/utils/input_validations.dart +++ b/packages/stac/lib/src/utils/input_validations.dart @@ -1,39 +1,105 @@ -enum InputValidationType { - isEmail, - isName, - isPassword, - isNotEmpty, - compare, - general; - - RegExp get _emailRegExp => RegExp( - r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$', - ); - RegExp get _nameRegExp => RegExp(r"^[A-Za-z .`'/-]{2,32}$"); - RegExp get _passwordRegExp => RegExp( - r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@#$!%*?&])[A-Za-z\d@#$%^&£*\-_+=[\]{}|\\:,?\/`~""()<>;!]*$', - ); - RegExp get _isNotEmptyRegExp => RegExp("^[0-9a-zA-Z .`'/-]{1,50}\$"); - - bool validate(String value, String rule, {String? compareValue}) { - switch (this) { - case InputValidationType.isEmail: - return _emailRegExp.hasMatch(value); - - case InputValidationType.isName: - return _nameRegExp.hasMatch(value); - - case InputValidationType.isPassword: - return _passwordRegExp.hasMatch(value); - - case InputValidationType.isNotEmpty: - return _isNotEmptyRegExp.hasMatch(value); - - case InputValidationType.compare: - return value == compareValue; - - default: - return RegExp(rule).hasMatch(value); - } +import 'package:flutter_validators/flutter_validators.dart'; + +typedef _RuleValidator = + bool Function(String value, Map? options); + +/// Maps Stac `validatorRules` rule strings to `flutter_validators` functions. +/// +/// Every rule delegates to the `flutter_validators` package — Stac keeps no +/// homegrown validation logic. Parameterized validators read their arguments +/// from the rule's `options` map. +class InputValidators { + InputValidators._(); + + static final Map _rules = { + 'isAlpha': (v, o) => isAlpha(v), + 'isAlphanumeric': (v, o) => isAlphanumeric(v), + 'isAscii': (v, o) => isAscii(v), + 'isBase32': (v, o) => isBase32(v), + 'isBase58': (v, o) => isBase58(v), + 'isBase64': (v, o) => isBase64(v, urlSafe: o?['urlSafe'] == true), + 'isBoolean': (v, o) => isBoolean(v), + 'isCreditCard': (v, o) => isCreditCard(v), + 'isDate': (v, o) => isDate(v), + 'isDecimal': (v, o) => isDecimal(v), + 'isEmail': (v, o) => isEmail(v), + 'isFQDN': (v, o) => isFQDN(v), + 'isFloat': (v, o) => + isFloat(v, min: _toDouble(o?['min']), max: _toDouble(o?['max'])), + 'isHexColor': (v, o) => isHexColor(v), + 'isHexadecimal': (v, o) => isHexadecimal(v), + 'isInt': (v, o) => isInt(v), + 'isIP': (v, o) => isIP(v, _toInt(o?['version'])), + 'isJson': (v, o) => isJson(v), + 'isJWT': (v, o) => isJWT(v), + 'isLatLong': (v, o) => isLatLong(v), + 'isLowercase': (v, o) => isLowercase(v), + 'isMACAddress': (v, o) => isMACAddress(v), + 'isMD5': (v, o) => isMD5(v), + 'isMongoId': (v, o) => isMongoId(v), + 'isNumeric': (v, o) => isNumeric(v), + 'isOctal': (v, o) => isOctal(v), + 'isPhone': (v, o) => isPhone(v), + 'isPort': (v, o) => isPort(v), + 'isSemVer': (v, o) => isSemVer(v), + 'isSlug': (v, o) => isSlug(v), + 'isUppercase': (v, o) => isUppercase(v), + 'isURL': (v, o) => isURL(v), + 'isUUID': (v, o) => isUUID(v), + 'isByteLength': (v, o) => + isByteLength(v, _toInt(o?['min']) ?? 0, _toInt(o?['max'])), + 'isLength': (v, o) => + isLength(v, _toInt(o?['min']) ?? 0, _toInt(o?['max'])), + 'isIn': (v, o) => isIn(v, _toStringList(o?['values'])), + 'contains': (v, o) => contains( + v, + o?['seed']?.toString() ?? '', + ignoreCase: o?['ignoreCase'] == true, + minOccurrences: _toInt(o?['minOccurrences']) ?? 1, + ), + 'equals': (v, o) => equals(v, o?['comparison']?.toString() ?? ''), + 'matches': (v, o) { + final pattern = o?['pattern']?.toString(); + if (pattern == null || pattern.isEmpty) return false; + try { + return matches(v, RegExp(pattern)); + } catch (_) { + return false; + } + }, + 'isStrongPassword': (v, o) => isStrongPassword( + v, + minLength: _toInt(o?['minLength']) ?? 8, + minLowercase: _toInt(o?['minLowercase']) ?? 1, + minUppercase: _toInt(o?['minUppercase']) ?? 1, + minNumbers: _toInt(o?['minNumbers']) ?? 1, + minSymbols: _toInt(o?['minSymbols']) ?? 1, + ), + }; + + /// Validates [value] against [rule], reading any arguments from [options]. + /// + /// Unknown rules pass (return `true`) — the rule set is owned upstream by + /// the `flutter_validators` package. + static bool validate( + String rule, + String value, { + Map? options, + }) { + final validator = _rules[rule]; + if (validator == null) return true; + return validator(value, options); } + + /// Whether [rule] is a known `flutter_validators` rule. + static bool hasRule(String rule) => _rules.containsKey(rule); + + static int? _toInt(Object? value) => + value is int ? value : (value is num ? value.toInt() : null); + + static double? _toDouble(Object? value) => + value is num ? value.toDouble() : null; + + static Iterable _toStringList(Object? value) => + value is Iterable ? value.map((e) => e.toString()) : const []; } diff --git a/packages/stac/pubspec.yaml b/packages/stac/pubspec.yaml index ee2b0d683..62ec56035 100644 --- a/packages/stac/pubspec.yaml +++ b/packages/stac/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: flutter: sdk: flutter json_annotation: ^4.9.0 + flutter_validators: ^1.2.0 dio: ^5.9.0 stac_framework: ^1.0.0 cached_network_image: ^3.4.1 diff --git a/packages/stac_core/lib/foundation/forms/stac_form_field_validator/stac_form_field_validator.dart b/packages/stac_core/lib/foundation/forms/stac_form_field_validator/stac_form_field_validator.dart index 701fd058a..5af4fc5a7 100644 --- a/packages/stac_core/lib/foundation/forms/stac_form_field_validator/stac_form_field_validator.dart +++ b/packages/stac_core/lib/foundation/forms/stac_form_field_validator/stac_form_field_validator.dart @@ -7,10 +7,32 @@ part 'stac_form_field_validator.g.dart'; /// /// The `rule` is a string understood by the parser; `message` is shown /// when the validation fails. +/// +/// Most rules map to a `flutter_validators` validator — e.g. `isEmail`, +/// `isURL`, `isUUID`, `isInt`, `isStrongPassword`, `isLength`, `matches`. +/// Parameterized rules read their arguments from `options` (e.g. +/// `{"min": 8, "max": 20}` for `isLength`, `{"pattern": "^[a-z]+$"}` for a +/// raw-regex `matches`). The special `compare` rule checks equality against +/// another field's value via `options.fieldId`. +/// +/// {@tool snippet} +/// JSON Example: +/// ```json +/// { +/// "rule": "isLength", +/// "options": {"min": 8, "max": 20}, +/// "message": "Must be 8-20 characters" +/// } +/// ``` +/// {@end-tool} @JsonSerializable() class StacFormFieldValidator extends StacElement { /// Creates a form field validator with the specified rule and optional message. - const StacFormFieldValidator({required this.rule, this.message}); + const StacFormFieldValidator({ + required this.rule, + this.message, + this.options, + }); /// Identifier of the validation logic to apply. final String rule; @@ -18,6 +40,10 @@ class StacFormFieldValidator extends StacElement { /// Error message to display when validation fails. final String? message; + /// Arguments for parameterized rules (e.g. `min`/`max` for `isLength`, + /// `fieldId` for `compare`). Ignored by rules that take no arguments. + final Map? options; + /// Creates a [StacFormFieldValidator] from a JSON map. factory StacFormFieldValidator.fromJson(Map json) => _$StacFormFieldValidatorFromJson(json); diff --git a/packages/stac_core/lib/foundation/forms/stac_form_field_validator/stac_form_field_validator.g.dart b/packages/stac_core/lib/foundation/forms/stac_form_field_validator/stac_form_field_validator.g.dart index 95f976eda..9cf5cfcc7 100644 --- a/packages/stac_core/lib/foundation/forms/stac_form_field_validator/stac_form_field_validator.g.dart +++ b/packages/stac_core/lib/foundation/forms/stac_form_field_validator/stac_form_field_validator.g.dart @@ -11,8 +11,13 @@ StacFormFieldValidator _$StacFormFieldValidatorFromJson( ) => StacFormFieldValidator( rule: json['rule'] as String, message: json['message'] as String?, + options: json['options'] as Map?, ); Map _$StacFormFieldValidatorToJson( StacFormFieldValidator instance, -) => {'rule': instance.rule, 'message': instance.message}; +) => { + 'rule': instance.rule, + 'message': instance.message, + 'options': instance.options, +};