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
58 changes: 58 additions & 0 deletions docs/widgets/text_form_field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Note>
`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.
</Note>

**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

<Tabs sync={false}>
Expand Down
8 changes: 8 additions & 0 deletions examples/counter_example/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions examples/movie_app/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 20 additions & 3 deletions examples/stac_gallery/assets/json/form_example.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
},
Expand All @@ -49,7 +57,16 @@
"decoration": {
"hintText": "Password"
},
"autovalidateMode": "onUserInteraction"
"autovalidateMode": "onUserInteraction",
"validatorRules": [
{
"rule": "isLength",
"options": {
"min": 1
},
"message": "Password is required"
}
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
{
"type": "sizedBox",
Expand Down
8 changes: 8 additions & 0 deletions examples/stac_gallery/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
divyanshub024 marked this conversation as resolved.

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';
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand Down
140 changes: 103 additions & 37 deletions packages/stac/lib/src/utils/input_validations.dart
Original file line number Diff line number Diff line change
@@ -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<String, dynamic>? 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<String, _RuleValidator> _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<String, dynamic>? 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<String> _toStringList(Object? value) =>
value is Iterable ? value.map((e) => e.toString()) : const <String>[];
}
1 change: 1 addition & 0 deletions packages/stac/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,43 @@ 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;

/// 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<String, dynamic>? options;

/// Creates a [StacFormFieldValidator] from a JSON map.
factory StacFormFieldValidator.fromJson(Map<String, dynamic> json) =>
_$StacFormFieldValidatorFromJson(json);
Expand Down
Loading
Loading