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,
+};