diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml
index e796a3a..1b8751d 100644
--- a/.github/workflows/dart.yml
+++ b/.github/workflows/dart.yml
@@ -2,122 +2,63 @@ name: Dart
on:
push:
- branches: [ "main" ]
+ branches: ["main"]
pull_request:
- branches: [ "main" ]
+ branches: ["main"]
env:
- FLUTTER_VERSION: "3.35.0"
+ FLUTTER_VERSION: "3.47.5"
jobs:
- discover:
+ quality:
runs-on: ubuntu-latest
- outputs:
- packages: ${{ steps.packages.outputs.packages }}
steps:
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- - name: Install melos
- run: dart pub global activate melos
- - name: Bootstrap packages
- run: melos bootstrap
- - name: Get packages
- id: packages
- run: |
- # Determine the base ref for comparison
- if [ "${{ github.event_name }}" == "pull_request" ]; then
- BASE_REF="origin/${{ github.base_ref }}"
- else
- BASE_REF="HEAD~1"
- fi
-
-
- # Get packages that have changes using melos diff
- packages=$(melos list --diff="$BASE_REF" --json | jq -c "[.[] | select(.name != \"flutter_forge_workspace\") | .name]" 2>/dev/null || echo '[]')
- if [ "$packages" = "" ] || [ "$packages" = "null" ]; then
- packages="[]"
- fi
- echo "packages=$packages" >> $GITHUB_OUTPUT
- echo "Changed packages: $packages"
-
- build:
- needs: discover
- runs-on: ubuntu-latest
- if: needs.discover.outputs.packages != '[]'
- strategy:
- matrix:
- package: ${{ fromJson(needs.discover.outputs.packages) }}
- steps:
- - uses: actions/checkout@v4
-
- - uses: subosito/flutter-action@v2
+ - uses: actions/setup-python@v5
with:
- flutter-version: ${{ env.FLUTTER_VERSION }}
- cache: true
-
- - name: Install dependencies
- run: |
- dart pub global activate melos
- melos bootstrap --scope="${{ matrix.package }}"
-
- - name: Format package
- # Brick templates are mustache, not parseable Dart, and `dart format`
- # has no exclude of its own - so filter the paths before it sees them.
- run: |
- melos exec --scope="${{ matrix.package }}" -- \
- "find . -name '*.dart' -not -path '*/__brick__/*' -not -path '*/.dart_tool/*' \
- -exec dart format --set-exit-if-changed {} +"
-
- - name: Analyze package
- run: melos exec --scope="${{ matrix.package }}" -- dart analyze --fatal-infos
+ python-version: "3.x"
+ - name: Resolve workspace dependencies
+ run: flutter pub get --enforce-lockfile
+ - name: Verify coverage gate behavior
+ run: python3 -m unittest discover -s tools/tests
+ - name: Check formatting and analyze workspace
+ run: dart run melos run lint
+ - name: Require 100% coverage in every package
+ run: python3 tools/coverage.py
+ - name: Save test logs and coverage reports
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: workspace-coverage
+ path: |
+ coverage/
+ packages/**/coverage/
+ plugins/**/coverage/
+ tools/**/coverage/
- test:
- needs: [ discover, build ]
- runs-on: ubuntu-latest
- if: needs.discover.outputs.packages != '[]'
- strategy:
- matrix:
- package: ${{ fromJson(needs.discover.outputs.packages) }}
+ native-permissions:
+ runs-on: macos-latest
+ timeout-minutes: 30
steps:
- uses: actions/checkout@v4
-
- - uses: subosito/flutter-action@v2
+ - uses: actions/setup-java@v4
with:
- flutter-version: ${{ env.FLUTTER_VERSION }}
- cache: true
-
- - name: Install dependencies
- run: |
- dart pub global activate melos
- melos bootstrap --scope="${{ matrix.package }}"
-
- - name: Run tests
- id: test-step
- run: |
- # Try Flutter test first (will only run if package has test dir and is Flutter package)
- if melos exec --scope="${{ matrix.package }}" --dir-exists=test --flutter -- flutter test --coverage 2>/dev/null; then
- echo "has_tests=true" >> $GITHUB_OUTPUT
- # Try Dart test (will only run if package has test dir and is not Flutter package)
- elif melos exec --scope="${{ matrix.package }}" --dir-exists=test --no-flutter -- dart test --coverage 2>/dev/null; then
- echo "has_tests=true" >> $GITHUB_OUTPUT
- else
- echo "No tests to run for ${{ matrix.package }}"
- echo "has_tests=false" >> $GITHUB_OUTPUT
- fi
-
- # Get package path for coverage upload
- package_path=$(melos list --scope="${{ matrix.package }}" --json | jq -r ".[0].path")
- echo "package_path=$package_path" >> $GITHUB_OUTPUT
-
- - name: Upload coverage reports to Codecov
- if: steps.test-step.outputs.has_tests == 'true'
- uses: codecov/codecov-action@v5
+ distribution: temurin
+ java-version: "21"
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.x"
+ - name: Require 100% native permission-plugin coverage
+ run: python3 plugins/contact_permission/native_tests/run.py
+ - name: Save native coverage reports
+ if: always()
+ uses: actions/upload-artifact@v4
with:
- token: ${{ secrets.CODECOV_TOKEN }}
- flags: ${{ matrix.package }}
- files: ${{ steps.test-step.outputs.package_path }}/coverage/lcov.info
+ name: native-permission-coverage
+ path: |
+ plugins/contact_permission/native_tests/coverage/
+ plugins/contact_permission/native_tests/android/build/reports/
diff --git a/.github/workflows/nonstop-template.yml b/.github/workflows/nonstop-template.yml
index f28caf1..b1a0ff9 100644
--- a/.github/workflows/nonstop-template.yml
+++ b/.github/workflows/nonstop-template.yml
@@ -1,10 +1,10 @@
name: NonStop generated template
on:
pull_request:
- paths: ['packages/nonstop_cli/**', '.github/workflows/nonstop-template.yml']
+ paths: ['packages/nonstop_cli/**', 'packages/cli_core/**', 'pubspec.yaml', 'pubspec.lock', '.github/workflows/nonstop-template.yml']
push:
branches: [main]
- paths: ['packages/nonstop_cli/**', '.github/workflows/nonstop-template.yml']
+ paths: ['packages/nonstop_cli/**', 'packages/cli_core/**', 'pubspec.yaml', 'pubspec.lock', '.github/workflows/nonstop-template.yml']
permissions:
contents: read
jobs:
@@ -17,9 +17,9 @@ jobs:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.0'
+ flutter-version: '3.47.5'
cache: true
- - run: dart pub get
+ - run: flutter pub get --enforce-lockfile
- run: dart analyze --fatal-infos lib test tool
- run: dart test --exclude-tags version-verify
generated:
@@ -36,7 +36,7 @@ jobs:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
- flutter-version: '3.44.0'
+ flutter-version: '3.47.5'
cache: true
- - run: dart pub get
+ - run: flutter pub get --enforce-lockfile
- run: dart run tool/verify_template.dart ${{ matrix.profile }}
diff --git a/.gitignore b/.gitignore
index fb343ae..4a3f0ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,10 @@ full_coverage_test.dart
**/example/linux/
**/example/macos/
**/example/windows/
+
+# Keep a reproducible resolution for the native Dart workspace.
+!/pubspec.lock
+
+# Python coverage-gate test caches.
+__pycache__/
+*.pyc
diff --git a/README.md b/README.md
index 256e930..fedd890 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,12 @@ optional-module configurations.
---
+## Development and tests
+
+Use Flutter 3.47.5 and run `flutter pub get --enforce-lockfile` from the repository root. Melos configuration is in `pubspec.yaml`.
+
+Run `python3 tools/coverage.py` (or `dart run melos run test`) to test every workspace package and require 100% executable Dart line coverage. See [testing and dependency maintenance](docs/testing.md) for focused runs, generated-template validation, and coverage scope.
+
## 🔗 Connect with NonStop
diff --git a/analysis_options.yaml b/analysis_options.yaml
index d705176..9e85879 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -2,9 +2,9 @@ include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- - lib/generated/**
- - test/**
- - packages/nonstop_cli/bricks/**
+ - packages/nonstop_cli/bricks/**/__brick__/**
+ - "**/coverage/**"
+ - "**/build/**"
linter:
rules:
constant_identifier_names: false
diff --git a/docs/coverage-report.md b/docs/coverage-report.md
new file mode 100644
index 0000000..1177408
--- /dev/null
+++ b/docs/coverage-report.md
@@ -0,0 +1,76 @@
+# Coverage and dependency verification
+
+Verified on 2026-09-21 after updating from `origin/main` at `2d1b5ac`, using Flutter 3.47.5 and Dart 3.13.4.
+
+The final fresh `python3 tools/coverage.py` run exited successfully: **24/24 workspace packages passed, with 11,116/11,116 executable Dart lines covered (100%)**. This includes libraries, examples, command entrypoints, Mason hooks, repository tools, and the generated-template verification script.
+
+## Workspace results
+
+| Package | Covered / executable lines | Coverage |
+| --- | ---: | ---: |
+| `flutter_forge` | 92 / 92 | 100% |
+| `cli_core` | 85 / 85 | 100% |
+| `connectivity_wrapper_example` | 129 / 129 | 100% |
+| `connectivity_wrapper` | 160 / 160 | 100% |
+| `dzod_example` | 2,808 / 2,808 | 100% |
+| `dzod` | 4,507 / 4,507 | 100% |
+| `example_html_rich_text` | 46 / 46 | 100% |
+| `html_rich_text` | 73 / 73 | 100% |
+| `morse_tap_example` | 367 / 367 | 100% |
+| `morse_tap` | 415 / 415 | 100% |
+| `flutter_app_for_mono_repo_hooks` | 17 / 17 | 100% |
+| `flutter_package_for_mono_repo_hooks` | 16 / 16 | 100% |
+| `flutter_plugin_for_mono_repo_hooks` | 17 / 17 | 100% |
+| `flutter_project_with_mono_repo_hooks` | 103 / 103 | 100% |
+| `nonstop_cli` | 759 / 759 | 100% |
+| `ns_firebase_utils` | 246 / 246 | 100% |
+| `ns_intl_phone_input_example` | 53 / 53 | 100% |
+| `ns_intl_phone_input` | 282 / 282 | 100% |
+| `ns_utils` | 583 / 583 | 100% |
+| `timer_button_example` | 38 / 38 | 100% |
+| `timer_button` | 79 / 79 | 100% |
+| `contact_permission_example` | 30 / 30 | 100% |
+| `contact_permission` | 16 / 16 | 100% |
+| `readme_sync` | 195 / 195 | 100% |
+| **Total** | **11,116 / 11,116** | **100%** |
+
+## Generated templates and native sources
+
+Mustache templates were rendered before validation. All 512 module selections passed the template contract checks. Six representative generated workspaces passed analysis, tests, 100% handwritten Dart line coverage, and web builds:
+
+| Profile | Covered / executable lines | Web build |
+| --- | ---: | --- |
+| Full | 2,396 / 2,396 | Passed |
+| Default | 2,393 / 2,393 | Passed |
+| Minimal | 766 / 766 | Passed |
+| Authentication | 950 / 950 | Passed |
+| Firestore | 781 / 781 | Passed |
+| Network | 1,120 / 1,120 | Passed |
+
+The full profile's existing temporary application received standard Flutter web platform scaffolding before its build; its application code, dependencies, and tests were preserved.
+
+The contact-permission plugin's production native sources also passed their unit coverage gates: **Android Kotlin 58/58**, **Apple Swift/Objective-C 44/44**. A disposable Flutter Android host built a debug APK with the upgraded plugin in both supported Kotlin modes. Native unit tests use SDK boundary doubles; actual OS permission dialogs and a real iOS embedding build were not tested.
+
+## Dependency upgrades and remaining constraints
+
+Dart/Flutter dependencies, generated-template dependencies, Node development tooling, and the Android plugin/tooling were upgraded. The workspace now uses native Dart pub workspaces and Melos 8, with a shared checked-in Dart lockfile. The pnpm lockfile is updated, and its audit reported zero vulnerabilities.
+
+`flutter pub outdated --json` reported **no resolvable upgrades**. Eleven direct/transitive packages have newer releases blocked by the current dependency graph. The key constraints are Flutter's `test_api` pin (holding analyzer/test and related tooling), `fake_cloud_firestore`'s Equatable 2 requirement, and AndroidX Core 1.19's compile SDK 37 requirement (this plugin uses SDK 36). These dependencies remain at their latest compatible versions.
+
+## Reproduce and inspect
+
+```sh
+flutter pub get --enforce-lockfile
+dart run melos run lint
+python3 -m unittest discover -s tools/tests
+python3 tools/coverage.py
+python3 plugins/contact_permission/native_tests/run.py
+# From packages/nonstop_cli, repeat for each profile above:
+dart run tool/verify_template.dart full
+```
+
+The coverage gate's 12 regression tests passed. Formatting and strict analysis passed. The CLI's separate generated-version build check remains intentionally tagged `version-verify`; the unit coverage result includes all executable CLI/tool lines and the separate template contract/build checks above passed.
+
+Fresh workspace details are in `coverage/summary.json`; each package retains its LCOV report and test log under `coverage/`. Native details are in `plugins/contact_permission/native_tests/coverage/summary.json`. Reports and build outputs are ignored by Git and regenerated by CI. The recorded percentages are executable **line** coverage, not branch coverage.
+
+See [testing and dependency maintenance](testing.md) for gate behavior, SDK requirements, compatibility changes, and focused commands.
diff --git a/docs/testing.md b/docs/testing.md
new file mode 100644
index 0000000..7b9480f
--- /dev/null
+++ b/docs/testing.md
@@ -0,0 +1,58 @@
+# Tests and dependency maintenance
+
+The [verified coverage report](coverage-report.md) records the package totals, generated-template builds, native coverage, and dependency compatibility limits.
+
+Use Flutter 3.47.5 (Dart 3.13.4), Python 3.10 or newer, and Node.js 24 or newer. The repository uses a native Dart pub workspace and Melos 8; configuration lives in the root `pubspec.yaml`.
+
+```sh
+flutter pub get --enforce-lockfile
+dart run melos run lint
+python3 -m unittest discover -s tools/tests
+python3 tools/coverage.py
+# Equivalent full coverage command:
+dart run melos run test
+```
+
+The coverage command discovers workspace packages rather than selecting only folders that already have tests. It includes libraries, executable entrypoints, example applications, Mason hook packages, README synchronization, repository version/bundle tools, and the generated-template verification script. Every package must have real unit or widget tests and a nonempty report. The gate requires every executable Dart line to be hit; it compares integer counts rather than a rounded percentage. Compile-time constants and export-only libraries do not contain executable lines.
+
+Production libraries are loaded alongside an existing test suite so a never-imported library cannot silently disappear. Missing source records fail unless the file contains only recognized declarations without executable code. Temporary coverage entrypoints are restored or removed after the run. Each Flutter run clears its generated test kernel cache to avoid stale source metadata. Flutter coverage is collected without VM package filters, then restricted to the package’s owned files; this avoids a Dart 3.13.4 source-report crash and includes entrypoints outside `lib/`. The generated runner lives under `coverage/` and uses the installed Flutter tool without modifying the SDK. Raw logs and LCOV reports are written to each package's `coverage/` directory; the complete result is `coverage/summary.json`. These generated reports are ignored by Git.
+
+Run a focused package while developing:
+
+```sh
+python3 tools/coverage.py --package ns_utils
+python3 tools/coverage.py --package packages/timer_button/example
+```
+
+`--report-only` inspects existing reports and writes `summary-existing.json`; it does not establish a fresh passing result.
+
+Mustache brick source is not runnable Dart until rendered. The separate generated-template workflow renders representative module combinations, analyzes them, and requires 100% handwritten Dart coverage inside the generated workspace. The template contract suite additionally validates every module selection. Hook bundles exclude local tests/build artifacts and workspace-only `resolution` metadata.
+
+## Dependencies
+
+The shared root `pubspec.lock` and `pnpm-lock.yaml` are checked in. CI enforces the Dart lockfile so dependency resolution cannot drift between package jobs. When intentionally upgrading dependencies, run:
+
+```sh
+flutter pub upgrade --major-versions
+flutter pub outdated
+pnpm install --frozen-lockfile
+pnpm audit
+```
+
+After an upgrade, rerun the full coverage gate, strict analysis, and generated-template checks. Some newest releases cannot coexist with Flutter SDK pins or upstream package constraints: the current graph uses Equatable 2.1 (required by fake_cloud_firestore) and analyzer 13.3/test 1.31.1 (compatible with Flutter's test_api pin). Do not force incompatible versions with dependency overrides.
+
+The development-only `tmp` override selects a patched release because all-contributors-cli's transitive external-editor dependency requests an obsolete version. Its temporary-file creation and cleanup API is smoke-tested during the upgrade.
+
+The Melos upgrade moves workspace configuration from `melos.yaml` into the root `pubspec.yaml`; run bootstrap and scripts from the repository root. DZod string refinements now correctly return `Schema
` instead of throwing an invalid cast. Place string-specific checks before those refinements, for example `z.string().min(3).startsWith('abc')`.
+
+## Native permission-plugin tests
+
+Run the Android Kotlin and Apple Swift/Objective-C unit suites on macOS with Xcode command-line tools and Java 21:
+
+```sh
+python3 plugins/contact_permission/native_tests/run.py
+```
+
+Use `--platform android` for the JVM suite alone or `--platform apple` for the Apple suite. The checked-in Gradle wrapper resolves the JVM test tooling. Both suites compile the production plugin files and require 100% measured native line coverage; reports are under `plugins/contact_permission/native_tests/coverage/` and the Android test build directory.
+
+Platform test doubles make permission states, callbacks, registration, and lifecycle behavior deterministic. These unit tests do not open OS permission dialogs or establish device-level integration coverage. The separate `native-permissions` CI job runs both suites on macOS.
diff --git a/melos.yaml b/melos.yaml
deleted file mode 100644
index 0e84cb6..0000000
--- a/melos.yaml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: flutter_forge_workspace
-
-packages:
- - plugins/*
- - plugins/*/*
- - packages/*
- - packages/*/*
- - packages/nonstop_cli/bricks/flutter_app_for_mono_repo/hooks
- - packages/nonstop_cli/bricks/flutter_package_for_mono_repo/hooks
- - packages/nonstop_cli/bricks/flutter_project_with_mono_repo/hooks
- - packages/nonstop_cli/bricks/flutter_plugin_for_mono_repo/hooks
- - packages/dzod/example
-
-command:
- version:
- workspaceChangelog: false
- fetchTags: true
- updateGitTagRefs: false
- linkToCommits: false
- hooks:
- preCommit: melos run update_nonstop_cli_version && melos run update_nonstop_cli_bundles
-
-scripts:
- lint:
- description: "Run format and analyze all packages"
- # Brick sources are mustache templates, not valid Dart, so the formatter
- # has to skip them - `dart format` has no exclude of its own.
- run: |
- find . -name '*.dart' -not -path '*/__brick__/*' -not -path '*/.dart_tool/*' \
- -exec dart format --set-exit-if-changed {} + &&
- dart analyze --fatal-infos .
- exec:
- concurrency: 5
- failFast: true
- fix:
- description: "Apply Dart automated fixes to all packages"
- run: dart fix --apply
- exec:
- concurrency: 5
- packageFilters:
- dirExists: lib
- test:
- description: "Run tests in all packages"
- run: |
- if [ -f "pubspec.yaml" ] && grep -q "flutter:" pubspec.yaml; then
- flutter test
- else
- dart test
- fi
- exec:
- concurrency: 1
- failFast: false
- packageFilters:
- dirExists: ["test"]
- update_nonstop_cli_version:
- run: dart run tools/update_nonstop_cli_version.dart
- update_nonstop_cli_bundles:
- run: dart run tools/update_nonstop_cli_bundles.dart
- sync:readme:
- description: "Sync README sections across all packages"
- run: dart run tools/readme_sync/readme_sync.dart
diff --git a/package.json b/package.json
index 1da712a..1b9505e 100644
--- a/package.json
+++ b/package.json
@@ -15,14 +15,17 @@
"commitlint": "commitlint --from=HEAD~1"
},
"devDependencies": {
- "@commitlint/cli": "^19.6.0",
- "@commitlint/config-conventional": "^19.6.0",
+ "@commitlint/cli": "^21.2.3",
+ "@commitlint/config-conventional": "^21.2.3",
"all-contributors-cli": "^6.26.1",
"husky": "^9.1.7",
- "prettier": "^3.4.2",
- "typescript": "^5.7.3"
+ "prettier": "^3.9.8",
+ "typescript": "^7.0.2"
},
"dependencies": {
- "@changesets/cli": "^2.27.12"
+ "@changesets/cli": "^3.0.3"
+ },
+ "overrides": {
+ "tmp": "^0.2.7"
}
}
diff --git a/packages/cli_core/lib/src/file_utils.dart b/packages/cli_core/lib/src/file_utils.dart
index f4b8f07..4dd440f 100644
--- a/packages/cli_core/lib/src/file_utils.dart
+++ b/packages/cli_core/lib/src/file_utils.dart
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:path/path.dart' as p;
+import 'package:yaml/yaml.dart';
/// Utility methods for common file system operations.
///
@@ -81,35 +82,30 @@ class FileUtils {
await file.writeAsString(content);
}
- /// Check if the current directory is part of a mono-repo.
+ /// Check the current directory and its ancestors for a mono-repo root.
///
- /// First tries to detect using the melos command.
- /// Falls back to checking for melos.yaml in current and parent directories.
- ///
- /// [path] - Optional path to check. Defaults to current directory.
+ /// Recognizes legacy `melos.yaml` files and native Dart pub workspaces.
+ /// [path] defaults to the current directory.
static Future isMonoRepo([String? path]) async {
- final workingDir = path ?? Directory.current.path;
+ var directory = p.normalize(p.absolute(path ?? Directory.current.path));
+ while (true) {
+ if (await File(p.join(directory, 'melos.yaml')).exists()) return true;
- // Try with melos command first
- try {
- final melosResult = await Process.run(
- 'melos',
- ['list', '--json'],
- workingDirectory: workingDir,
- runInShell: true,
- );
- if (melosResult.exitCode == 0 &&
- melosResult.stdout.toString().isNotEmpty) {
- return true;
+ final pubspec = File(p.join(directory, 'pubspec.yaml'));
+ if (await pubspec.exists()) {
+ try {
+ final configuration = loadYaml(await pubspec.readAsString());
+ if (configuration is Map && configuration['workspace'] is List) {
+ return true;
+ }
+ } on YamlException {
+ // An unrelated or unfinished pubspec does not identify a workspace.
+ }
}
- } catch (_) {
- // Ignore melos command failures and fall back to file check
- }
- // Check for melos.yaml in current and parent directories
- final currentMelosFile = File(p.join(workingDir, 'melos.yaml'));
- final parentMelosFile = File(p.join(p.dirname(workingDir), 'melos.yaml'));
-
- return currentMelosFile.existsSync() || parentMelosFile.existsSync();
+ final parent = p.dirname(directory);
+ if (parent == directory) return false;
+ directory = parent;
+ }
}
}
diff --git a/packages/cli_core/lib/src/logger_extension.dart b/packages/cli_core/lib/src/logger_extension.dart
index 06592b6..87ea6df 100644
--- a/packages/cli_core/lib/src/logger_extension.dart
+++ b/packages/cli_core/lib/src/logger_extension.dart
@@ -11,7 +11,7 @@ int Function() stdoutTerminalColumnsResolver = defaultTerminalColumnsForTest;
@visibleForTesting
int defaultTerminalColumnsForTest() {
if (stdout.hasTerminal) {
- return stdout.terminalColumns; // coverage:ignore-line
+ return stdout.terminalColumns;
}
return fallbackStdoutTerminalColumns;
}
diff --git a/packages/cli_core/pubspec.yaml b/packages/cli_core/pubspec.yaml
index 656f810..0647958 100644
--- a/packages/cli_core/pubspec.yaml
+++ b/packages/cli_core/pubspec.yaml
@@ -12,7 +12,7 @@ topics:
- flutter
environment:
- sdk: '>=3.0.0 <4.0.0'
+ sdk: '>=3.6.0 <4.0.0'
dependencies:
mason: ^0.1.2
@@ -20,9 +20,12 @@ dependencies:
meta: ^1.15.0
path: ^1.8.0
universal_io: ^2.2.2
+ yaml: ^3.1.4
dev_dependencies:
test: ^1.24.9
- flutter_lints: ^5.0.0
+ flutter_lints: ^6.0.0
mocktail: ^1.0.4
build_runner: ^2.4.12
+
+resolution: workspace
diff --git a/packages/cli_core/test/file_utils_test.dart b/packages/cli_core/test/file_utils_test.dart
index dc5762e..b9c79a6 100644
--- a/packages/cli_core/test/file_utils_test.dart
+++ b/packages/cli_core/test/file_utils_test.dart
@@ -94,10 +94,46 @@ void main() {
});
test('defaults to current directory when no path is provided', () async {
- // No argument — exercises the Directory.current fallback branch.
- final result = await FileUtils.isMonoRepo();
- expect(result, isA());
+ final original = Directory.current;
+ try {
+ Directory.current = tempDir;
+ await File('melos.yaml').writeAsString('name: test_workspace');
+ expect(await FileUtils.isMonoRepo(), isTrue);
+ } finally {
+ Directory.current = original;
+ }
});
+
+ test('recognizes a pub workspace from a deeply nested member', () async {
+ await File(p.join(tempDir.path, 'pubspec.yaml')).writeAsString(
+ 'name: root\nworkspace:\n - packages/example\n',
+ );
+ final nested =
+ await Directory(p.join(tempDir.path, 'packages', 'example', 'lib'))
+ .create(recursive: true);
+ expect(await FileUtils.isMonoRepo(nested.path), isTrue);
+ });
+
+ test('recognizes an empty native workspace at its root', () async {
+ await File(p.join(tempDir.path, 'pubspec.yaml'))
+ .writeAsString('workspace: []\n');
+ expect(await FileUtils.isMonoRepo(tempDir.path), isTrue);
+ });
+
+ for (final contents in [
+ 'name: regular_package\n# workspace: []\n',
+ 'workspace: not-a-list\n',
+ 'just a scalar',
+ 'workspace: [unfinished',
+ ]) {
+ test(
+ 'does not mistake unrelated or invalid YAML for a workspace: $contents',
+ () async {
+ await File(p.join(tempDir.path, 'pubspec.yaml'))
+ .writeAsString(contents);
+ expect(await FileUtils.isMonoRepo(tempDir.path), isFalse);
+ });
+ }
});
test('readYamlFile throws when file is missing', () {
diff --git a/packages/cli_core/test/logger_extension_test.dart b/packages/cli_core/test/logger_extension_test.dart
index 433997c..5b65fb9 100644
--- a/packages/cli_core/test/logger_extension_test.dart
+++ b/packages/cli_core/test/logger_extension_test.dart
@@ -1,3 +1,5 @@
+import 'dart:io';
+
import 'package:cli_core/cli_core.dart';
import 'package:cli_core/src/logger_extension.dart' as ext;
import 'package:mason_logger/mason_logger.dart';
@@ -6,6 +8,8 @@ import 'package:test/test.dart';
class _FakeLogger extends Mock implements Logger {}
+class _MockStdout extends Mock implements Stdout {}
+
void main() {
group('LoggerX.created', () {
test('forwards message to info', () {
@@ -70,5 +74,14 @@ void main() {
expect(ext.defaultTerminalColumnsForTest(),
ext.fallbackStdoutTerminalColumns);
});
+
+ test('default resolver reads the attached terminal width', () {
+ final output = _MockStdout();
+ when(() => output.hasTerminal).thenReturn(true);
+ when(() => output.terminalColumns).thenReturn(132);
+ IOOverrides.runZoned(() {
+ expect(ext.defaultTerminalColumnsForTest(), 132);
+ }, stdout: () => output);
+ });
});
}
diff --git a/packages/connectivity_wrapper/example/lib/screens/network_aware_widget_screen.dart b/packages/connectivity_wrapper/example/lib/screens/network_aware_widget_screen.dart
index b2a1036..4754826 100644
--- a/packages/connectivity_wrapper/example/lib/screens/network_aware_widget_screen.dart
+++ b/packages/connectivity_wrapper/example/lib/screens/network_aware_widget_screen.dart
@@ -18,11 +18,11 @@ class NetworkAwareWidgetScreen extends StatelessWidget {
const TextField(
decoration: InputDecoration(labelText: 'Email'),
),
- const Spacer(),
+ const SizedBox(height: 10),
const TextField(
decoration: InputDecoration(labelText: 'Password'),
),
- const Spacer(),
+ const SizedBox(height: 10),
ConnectivityWidgetWrapper(
stacked: false,
offlineWidget: ElevatedButton(
diff --git a/packages/connectivity_wrapper/example/lib/screens/scaffold_example_screen.dart b/packages/connectivity_wrapper/example/lib/screens/scaffold_example_screen.dart
index 8fa8e65..bbccdef 100644
--- a/packages/connectivity_wrapper/example/lib/screens/scaffold_example_screen.dart
+++ b/packages/connectivity_wrapper/example/lib/screens/scaffold_example_screen.dart
@@ -68,7 +68,7 @@ class _ScaffoldExampleScreenState extends State {
},
),
const Divider(),
- const Spacer(),
+ const SizedBox(height: 10),
CheckboxListTile(
title: const Text(Strings.customHeight),
value: _customHeight,
@@ -87,7 +87,7 @@ class _ScaffoldExampleScreenState extends State {
},
),
const Divider(),
- const Spacer(),
+ const SizedBox(height: 10),
CheckboxListTile(
title: const Text(Strings.customMessage),
value: _customMessage,
@@ -106,11 +106,11 @@ class _ScaffoldExampleScreenState extends State {
},
),
const Divider(),
- const Spacer(),
+ const SizedBox(height: 10),
const ListTile(
title: Text(Strings.customAlignment),
),
- const Spacer(),
+ const SizedBox(height: 10),
IconButton(
icon: const Icon(Icons.arrow_upward),
onPressed: () {
@@ -120,7 +120,7 @@ class _ScaffoldExampleScreenState extends State {
},
),
const Divider(),
- const Spacer(),
+ const SizedBox(height: 10),
IconButton(
icon: const Icon(Icons.center_focus_strong),
onPressed: () {
@@ -130,7 +130,7 @@ class _ScaffoldExampleScreenState extends State {
},
),
const Divider(),
- const Spacer(),
+ const SizedBox(height: 10),
IconButton(
icon: const Icon(Icons.arrow_downward),
onPressed: () {
@@ -140,7 +140,7 @@ class _ScaffoldExampleScreenState extends State {
},
),
const Divider(),
- const Spacer(),
+ const SizedBox(height: 10),
CheckboxListTile(
title: const Text(Strings.userInteraction),
value: _disableInteraction,
@@ -153,7 +153,7 @@ class _ScaffoldExampleScreenState extends State {
});
},
),
- const Spacer(),
+ const SizedBox(height: 10),
],
),
),
diff --git a/packages/connectivity_wrapper/example/lib/utils/utils.dart b/packages/connectivity_wrapper/example/lib/utils/utils.dart
index 4bc4987..25bfe72 100644
--- a/packages/connectivity_wrapper/example/lib/utils/utils.dart
+++ b/packages/connectivity_wrapper/example/lib/utils/utils.dart
@@ -8,7 +8,7 @@ class AppRoutes {
}
}
-showSnackBar(
+void showSnackBar(
BuildContext context, {
required String title,
Color? color,
diff --git a/packages/connectivity_wrapper/example/pubspec.yaml b/packages/connectivity_wrapper/example/pubspec.yaml
index 63ea078..32ec936 100644
--- a/packages/connectivity_wrapper/example/pubspec.yaml
+++ b/packages/connectivity_wrapper/example/pubspec.yaml
@@ -4,7 +4,7 @@ publish_to: none
version: 0.0.2+1
environment:
- sdk: '>=2.18.4 <3.0.0'
+ sdk: '>=3.6.0 <4.0.0'
dependencies:
flutter:
@@ -16,42 +16,11 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
- flutter_lints: ^5.0.0
+ flutter_lints: ^6.0.0
flutter:
uses-material-design: true
assets:
- assets/
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+resolution: workspace
diff --git a/packages/connectivity_wrapper/example/test/example_test.dart b/packages/connectivity_wrapper/example/test/example_test.dart
new file mode 100644
index 0000000..c94e7ca
--- /dev/null
+++ b/packages/connectivity_wrapper/example/test/example_test.dart
@@ -0,0 +1,193 @@
+import 'package:connectivity_wrapper/connectivity_wrapper.dart';
+import 'package:connectivity_wrapper_example/main.dart' as app;
+import 'package:connectivity_wrapper_example/screens/custom_offline_widget_screen.dart';
+import 'package:connectivity_wrapper_example/screens/network_aware_widget_screen.dart';
+import 'package:connectivity_wrapper_example/screens/scaffold_example_screen.dart';
+import 'package:connectivity_wrapper_example/utils/strings.dart';
+import 'package:connectivity_wrapper_example/utils/ui_helper.dart' as ui;
+import 'package:connectivity_wrapper_example/utils/utils.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ var connected = true;
+ setUp(() {
+ connected = true;
+ ConnectivityWrapper.instance.networkChecker = () async => connected;
+ ConnectivityWrapper.instance.checkInterval =
+ const Duration(milliseconds: 20);
+ });
+ tearDown(() {
+ ConnectivityWrapper.instance.networkChecker = null;
+ ConnectivityWrapper.instance.checkInterval = const Duration(seconds: 2);
+ });
+
+ Future close(WidgetTester tester) async {
+ await tester.pumpWidget(const SizedBox());
+ await tester.pump();
+ expect(ConnectivityWrapper.instance.hasListeners, isFalse);
+ }
+
+ Future pumpScreen(WidgetTester tester, Widget screen) async {
+ await tester.binding.setSurfaceSize(const Size(900, 1200));
+ addTearDown(() => tester.binding.setSurfaceSize(null));
+ await tester
+ .pumpWidget(ConnectivityAppWrapper(app: MaterialApp(home: screen)));
+ await tester.pumpAndSettle();
+ }
+
+ testWidgets('entry point navigates to each demonstration', (tester) async {
+ await tester.binding.setSurfaceSize(const Size(900, 1200));
+ addTearDown(() => tester.binding.setSurfaceSize(null));
+ app.main();
+ await tester.pumpAndSettle();
+ expect(find.text('Connectivity Wrapper Example'), findsOneWidget);
+ for (final title in [
+ Strings.example1,
+ Strings.example2,
+ Strings.example3
+ ]) {
+ await tester.tap(find.text(title));
+ await tester.pumpAndSettle();
+ expect(find.text(title), findsOneWidget);
+ await tester.pageBack();
+ await tester.pumpAndSettle();
+ }
+ expect(tester.takeException(), isNull);
+ await close(tester);
+ });
+
+ testWidgets('menu checks current connection and shows both status messages',
+ (tester) async {
+ app.main();
+ await tester.pumpAndSettle();
+ await tester.tap(find.text(Strings.example4));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 300));
+ expect(find.text('You Are Connected'), findsOneWidget);
+ expect(tester.widget(find.byType(SnackBar)).backgroundColor,
+ Colors.green);
+ await tester.pump(const Duration(seconds: 2));
+ await tester.pumpAndSettle();
+ connected = false;
+ await tester.tap(find.text(Strings.example4));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 300));
+ expect(find.text('You Are Not Connected'), findsOneWidget);
+ expect(tester.widget(find.byType(SnackBar)).backgroundColor,
+ Colors.red);
+ await close(tester);
+ });
+
+ testWidgets(
+ 'scaffold controls customize and reset each offline presentation option',
+ (tester) async {
+ await pumpScreen(tester, const ScaffoldExampleScreen());
+ ConnectivityWidgetWrapper wrapper() =>
+ tester.widget(find.byType(ConnectivityWidgetWrapper));
+ expect(wrapper().alignment, Alignment.bottomCenter);
+ final controls = {
+ Strings.customDecoration: (enabled) =>
+ expect(wrapper().decoration, enabled ? isA() : isNull),
+ Strings.customHeight: (enabled) =>
+ expect(wrapper().height, enabled ? 150 : isNull),
+ Strings.customMessage: (enabled) {
+ expect(wrapper().message, enabled ? Strings.offlineMessage : isNull);
+ expect(wrapper().messageStyle?.fontSize, enabled ? 40 : isNull);
+ },
+ Strings.userInteraction: (enabled) =>
+ expect(wrapper().disableInteraction, enabled),
+ };
+ for (final entry in controls.entries) {
+ final finder = find.widgetWithText(CheckboxListTile, entry.key);
+ tester.widget(finder).onChanged!(null);
+ await tester.pump();
+ entry.value(false);
+ await tester.ensureVisible(finder);
+ await tester.tap(finder);
+ await tester.pumpAndSettle();
+ entry.value(true);
+ await tester.tap(finder);
+ await tester.pumpAndSettle();
+ entry.value(false);
+ }
+ for (final entry in {
+ Icons.arrow_upward: Alignment.topCenter,
+ Icons.center_focus_strong: Alignment.center,
+ Icons.arrow_downward: Alignment.bottomCenter
+ }.entries) {
+ await tester.ensureVisible(find.byIcon(entry.key));
+ await tester.tap(find.byIcon(entry.key));
+ await tester.pumpAndSettle();
+ expect(wrapper().alignment, entry.value);
+ }
+ await close(tester);
+ });
+
+ testWidgets('custom offline screen renders its illustration and reconnects',
+ (tester) async {
+ await pumpScreen(tester, const CustomOfflineWidgetScreen());
+ expect(find.text(' Item 0'), findsOneWidget);
+ connected = false;
+ await tester.pump(const Duration(milliseconds: 40));
+ await tester.pump(const Duration(milliseconds: 600));
+ expect(find.byType(OfflineWidget), findsOneWidget);
+ expect(find.text(Strings.offlineMessage), findsOneWidget);
+ expect(tester.widget(find.byType(Image)).image,
+ const AssetImage('assets/dog.gif'));
+ connected = true;
+ await tester.pump(const Duration(milliseconds: 40));
+ await tester.pump(const Duration(milliseconds: 600));
+ expect(find.byType(OfflineWidget), findsNothing);
+ await close(tester);
+ });
+
+ testWidgets('network-aware form disables sign in while offline',
+ (tester) async {
+ await pumpScreen(tester, const NetworkAwareWidgetScreen());
+ await tester.enterText(find.byType(TextField).first, 'a@example.com');
+ await tester.enterText(find.byType(TextField).last, 'secret');
+ await tester.tap(find.text('Sign In'));
+ expect(
+ tester
+ .widget(
+ find.widgetWithText(ElevatedButton, 'Sign In'))
+ .onPressed,
+ isNotNull);
+ connected = false;
+ await tester.pump(const Duration(milliseconds: 40));
+ await tester.pump(const Duration(milliseconds: 600));
+ expect(find.text('Sign In'), findsNothing);
+ expect(
+ tester
+ .widget(
+ find.widgetWithText(ElevatedButton, 'Connecting'))
+ .onPressed,
+ isNull);
+ await close(tester);
+ });
+
+ testWidgets('snackbar and spacer helpers respect custom and default options',
+ (tester) async {
+ late BuildContext context;
+ await tester
+ .pumpWidget(MaterialApp(home: Scaffold(body: Builder(builder: (value) {
+ context = value;
+ return const Column(children: [ui.Spacer(), ui.Spacer(size: 12)]);
+ }))));
+ final paddings = tester
+ .widgetList(find.descendant(
+ of: find.byType(ui.Spacer), matching: find.byType(Padding)))
+ .map((widget) => widget.padding);
+ expect(paddings, [const EdgeInsets.all(5), const EdgeInsets.all(12)]);
+ const style = TextStyle(fontSize: 22, color: Colors.pink);
+ showSnackBar(context,
+ title: 'custom', color: Colors.blue, milliseconds: 500, style: style);
+ await tester.pumpAndSettle();
+ final bar = tester.widget(find.byType(SnackBar));
+ expect(bar.duration, const Duration(milliseconds: 500));
+ expect(bar.backgroundColor, Colors.blue);
+ expect(tester.widget(find.text('custom')).style, style);
+ await tester.pumpWidget(const SizedBox());
+ });
+}
diff --git a/packages/connectivity_wrapper/lib/connectivity_wrapper.dart b/packages/connectivity_wrapper/lib/connectivity_wrapper.dart
index bb6ab99..5e77f3a 100644
--- a/packages/connectivity_wrapper/lib/connectivity_wrapper.dart
+++ b/packages/connectivity_wrapper/lib/connectivity_wrapper.dart
@@ -8,7 +8,7 @@
/// (usually on timeout, default 10 sec)
/// a false boolean is pushed to the same list.
///
-library connectivity_wrapper;
+library;
import 'dart:async';
import 'dart:io';
@@ -38,8 +38,8 @@ enum ConnectivityStatus { CONNECTED, DISCONNECTED }
/// connectivity status of the device.
///
class ConnectivityWrapper {
- static List get _defaultAddresses => (kIsWeb)
- ? [] // coverage:ignore-line
+ static List _defaultAddresses(bool isWeb) => isWeb
+ ? []
: List.unmodifiable(
[
AddressCheckOptions(
@@ -81,9 +81,12 @@ class ConnectivityWrapper {
],
);
- List addresses = _defaultAddresses;
+ List addresses;
+ final bool _isWeb;
- ConnectivityWrapper._() {
+ ConnectivityWrapper._({bool isWeb = kIsWeb})
+ : _isWeb = isWeb,
+ addresses = _defaultAddresses(isWeb) {
_statusController.onListen = () {
_maybeEmitStatusUpdate();
};
@@ -97,6 +100,15 @@ class ConnectivityWrapper {
///
static final ConnectivityWrapper instance = ConnectivityWrapper._();
+ /// Creates an isolated checker with a selected platform for unit tests.
+ @visibleForTesting
+ factory ConnectivityWrapper.forTesting({bool isWeb = false}) =>
+ ConnectivityWrapper._(isWeb: isWeb);
+
+ /// Optional deterministic network check for tests. Reset after use.
+ @visibleForTesting
+ Future Function()? networkChecker;
+
/// [isHostReachable] is a function that checks if a host is reachable.
///
/// [options] is the options to use for the address check.
@@ -113,18 +125,18 @@ class ConnectivityWrapper {
options.address ?? options.hostname,
options.port,
timeout: options.timeout,
- )
- ..destroy();
+ );
return AddressCheckResult(
options,
isSuccess: true,
);
} catch (e) {
- sock?.destroy(); // coverage:ignore-line
return AddressCheckResult(
options,
isSuccess: false,
);
+ } finally {
+ sock?.destroy();
}
}
@@ -138,8 +150,10 @@ class ConnectivityWrapper {
/// to the network.
///
Future get isConnected async {
+ final checker = networkChecker;
+ if (checker != null) return checker();
bool connected = await _checkWebConnection();
- if (kIsWeb) return connected;
+ if (_isWeb) return connected;
if (!connected) return connected;
List> requests = [];
@@ -206,9 +220,8 @@ class ConnectivityWrapper {
ConnectivityStatus? get lastStatus => _lastStatus;
- _maybeEmitStatusUpdate([Timer? timer]) async {
+ Future _maybeEmitStatusUpdate() async {
_timerHandle?.cancel();
- timer?.cancel(); // coverage:ignore-line
var currentStatus = await connectionStatus;
diff --git a/packages/connectivity_wrapper/lib/src/providers/connectivity_provider.dart b/packages/connectivity_wrapper/lib/src/providers/connectivity_provider.dart
index bbb439b..45e33ce 100644
--- a/packages/connectivity_wrapper/lib/src/providers/connectivity_provider.dart
+++ b/packages/connectivity_wrapper/lib/src/providers/connectivity_provider.dart
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
/// which extends [ChangeNotifier].
///
class ConnectivityProvider extends ChangeNotifier {
+ late final StreamSubscription _subscription;
StreamController connectivityController =
StreamController();
@@ -24,10 +25,17 @@ class ConnectivityProvider extends ChangeNotifier {
_updateConnectivityStatus();
}
- _updateConnectivityStatus() async {
- ConnectivityWrapper.instance.onStatusChange
+ void _updateConnectivityStatus() {
+ _subscription = ConnectivityWrapper.instance.onStatusChange
.listen((ConnectivityStatus connectivityStatus) {
connectivityController.add(connectivityStatus);
});
}
+
+ @override
+ void dispose() {
+ _subscription.cancel();
+ connectivityController.close();
+ super.dispose();
+ }
}
diff --git a/packages/connectivity_wrapper/lib/src/widgets/connectivity_app_wrapper_widget.dart b/packages/connectivity_wrapper/lib/src/widgets/connectivity_app_wrapper_widget.dart
index 5caa9ef..b5e77cd 100644
--- a/packages/connectivity_wrapper/lib/src/widgets/connectivity_app_wrapper_widget.dart
+++ b/packages/connectivity_wrapper/lib/src/widgets/connectivity_app_wrapper_widget.dart
@@ -19,7 +19,11 @@ class ConnectivityAppWrapper extends StatelessWidget {
Widget build(BuildContext context) {
return StreamProvider(
initialData: ConnectivityStatus.CONNECTED,
- create: (context) => ConnectivityProvider().connectivityStream,
+ create: (context) {
+ final provider = ConnectivityProvider();
+ provider.connectivityController.onCancel = provider.dispose;
+ return provider.connectivityStream;
+ },
child: app,
);
}
diff --git a/packages/connectivity_wrapper/pubspec.yaml b/packages/connectivity_wrapper/pubspec.yaml
index e252136..1887c39 100644
--- a/packages/connectivity_wrapper/pubspec.yaml
+++ b/packages/connectivity_wrapper/pubspec.yaml
@@ -10,16 +10,18 @@ screenshots:
path: screenshots/icon.png
environment:
- sdk: ">=2.18.4 <4.0.0"
+ sdk: '>=3.6.0 <4.0.0'
flutter: ">=1.17.0"
dependencies:
flutter:
sdk: flutter
provider: ^6.1.2
- connectivity_plus: ^6.1.2 # To check connectivity for web only
+ connectivity_plus: ^7.3.1 # To check connectivity for web only
dev_dependencies:
flutter_test:
sdk: flutter
- flutter_lints: ^5.0.0
\ No newline at end of file
+ flutter_lints: ^6.0.0
+
+resolution: workspace
diff --git a/packages/connectivity_wrapper/test/connectivity_provider_test.dart b/packages/connectivity_wrapper/test/connectivity_provider_test.dart
index b8a189e..fa6e0a7 100644
--- a/packages/connectivity_wrapper/test/connectivity_provider_test.dart
+++ b/packages/connectivity_wrapper/test/connectivity_provider_test.dart
@@ -29,10 +29,6 @@ void main() {
.setMockMethodCallHandler(_connectivityChannel, null);
});
- // Both tests are combined so we make a single provider, exercise initial
- // emission + forwarded emission, and tear down after both are observed.
- // Closing the controller early conflicts with the provider's leaked
- // subscription to the singleton wrapper.
test('emits initial CONNECTED then forwards wrapper status', () async {
final provider = ConnectivityProvider();
@@ -55,10 +51,8 @@ void main() {
} finally {
poller.cancel();
await sub.cancel();
- // Intentionally do not close provider.connectivityController here —
- // the provider's internal subscription to the singleton wrapper is not
- // exposed and may still push events; the controller will be GC'd once
- // the test ends.
+ provider.dispose();
+ expect(ConnectivityWrapper.instance.hasListeners, isFalse);
}
});
}
diff --git a/packages/connectivity_wrapper/test/connectivity_wrapper_test.dart b/packages/connectivity_wrapper/test/connectivity_wrapper_test.dart
index 0e0163b..99cfd0d 100644
--- a/packages/connectivity_wrapper/test/connectivity_wrapper_test.dart
+++ b/packages/connectivity_wrapper/test/connectivity_wrapper_test.dart
@@ -30,6 +30,52 @@ void main() {
tearDown(_resetConnectivityMock);
+ test('isolated web checker uses transport status without socket addresses',
+ () async {
+ final checker = ConnectivityWrapper.forTesting(isWeb: true);
+ expect(checker.addresses, isEmpty);
+ _mockConnectivity(['wifi']);
+ expect(await checker.isConnected, isTrue);
+ _mockConnectivity(['none']);
+ expect(await checker.isConnected, isFalse);
+ expect(checker.lastTryResults, isEmpty);
+ });
+
+ test('injected checks determine status without accessing the network',
+ () async {
+ final checker = ConnectivityWrapper.forTesting();
+ expect(checker.addresses, isNotEmpty);
+ checker.networkChecker = () async => true;
+ expect(await checker.connectionStatus, ConnectivityStatus.CONNECTED);
+ checker.networkChecker = () async => false;
+ expect(await checker.connectionStatus, ConnectivityStatus.DISCONNECTED);
+ expect(checker.lastTryResults, isEmpty);
+ });
+
+ test('polling publishes status changes and suppresses repeated values',
+ () async {
+ final checker = ConnectivityWrapper.forTesting();
+ checker.checkInterval = const Duration(milliseconds: 5);
+ var checks = 0;
+ checker.networkChecker = () async => ++checks >= 3;
+ final statuses = [];
+ final reconnected = Completer();
+ final subscription = checker.onStatusChange.listen((status) {
+ statuses.add(status);
+ if (status == ConnectivityStatus.CONNECTED) reconnected.complete();
+ });
+ try {
+ await reconnected.future.timeout(const Duration(seconds: 2));
+ expect(statuses,
+ [ConnectivityStatus.DISCONNECTED, ConnectivityStatus.CONNECTED]);
+ expect(checks, greaterThanOrEqualTo(3));
+ } finally {
+ await subscription.cancel();
+ }
+ expect(checker.hasListeners, isFalse);
+ expect(checker.lastStatus, isNull);
+ });
+
group('ConnectivityWrapper singleton', () {
test('instance returns the same object', () {
expect(ConnectivityWrapper.instance, same(ConnectivityWrapper.instance));
diff --git a/packages/dzod/CHANGELOG.md b/packages/dzod/CHANGELOG.md
index 9129da7..877dccc 100644
--- a/packages/dzod/CHANGELOG.md
+++ b/packages/dzod/CHANGELOG.md
@@ -1,3 +1,9 @@
+## Unreleased
+
+- Fix `startsWith`, `endsWith`, `contains`, `date`, and `datetime` throwing invalid casts when constructing a schema. They now return `Schema`, consistent with other refinements. Apply string-specific checks first, for example `z.string().min(3).startsWith('abc')`.
+- Await inner asynchronous validation before applying an asynchronous refinement.
+- Upgrade dependencies and add regression tests for all executable library lines.
+
## 0.2.1
- **FIX**: replace deprecated Flutter 3.35 APIs in dzod example app.
@@ -46,4 +52,3 @@
- **DOCS**(dzod): fix README examples to match actual API implementation.
- **DOCS**(dzod): update test coverage badge to 99.3%.
- **BREAKING** **REFACTOR**(dzod): rename zod_bhai package to dzod.
-
diff --git a/packages/dzod/example/analysis_options.yaml b/packages/dzod/example/analysis_options.yaml
index 0d29021..55b180c 100644
--- a/packages/dzod/example/analysis_options.yaml
+++ b/packages/dzod/example/analysis_options.yaml
@@ -7,6 +7,9 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
+analyzer:
+ exclude:
+ - build/**
include: package:flutter_lints/flutter.yaml
linter:
diff --git a/packages/dzod/example/lib/examples/advanced/coercion_example.dart b/packages/dzod/example/lib/examples/advanced/coercion_example.dart
index 1ff8f0f..65058e1 100644
--- a/packages/dzod/example/lib/examples/advanced/coercion_example.dart
+++ b/packages/dzod/example/lib/examples/advanced/coercion_example.dart
@@ -5,6 +5,8 @@ import '../../widgets/result_display.dart';
import '../../widgets/schema_display.dart';
import '../../widgets/validation_card.dart';
+enum _CoercionType { number, boolean, date, list, advanced }
+
class CoercionExample extends StatefulWidget {
const CoercionExample({super.key});
@@ -16,31 +18,29 @@ class _CoercionExampleState extends State {
final _formKey = GlobalKey();
final _inputController = TextEditingController();
- String _selectedType = 'number';
+ _CoercionType _selectedType = _CoercionType.number;
bool _strictMode = false;
// Example 13: Automatic Type Coercion
Schema get _currentSchema {
switch (_selectedType) {
- case 'number':
+ case _CoercionType.number:
return _strictMode ? z.coerce.number(strict: true) : z.coerce.number();
- case 'boolean':
+ case _CoercionType.boolean:
return _strictMode
? z.coerce.boolean(strict: true)
: z.coerce.boolean();
- case 'date':
+ case _CoercionType.date:
return _strictMode ? z.coerce.date(strict: true) : z.coerce.date();
- case 'list':
+ case _CoercionType.list:
return _strictMode ? z.coerce.list(strict: true) : z.coerce.list();
- case 'advanced':
+ case _CoercionType.advanced:
// Advanced number coercion with additional validation
return z.coerce
.number(strict: false)
.transform((value) => double.parse(value.toStringAsFixed(2)))
.refine((value) => value >= 0 && value <= 100,
message: 'Must be between 0 and 100');
- default:
- return z.string();
}
}
@@ -76,7 +76,7 @@ class _CoercionExampleState extends State {
}
Widget _buildExampleButtons() {
- final examples = _examples[_selectedType] ?? [];
+ final examples = _examples[_selectedType.name] ?? [];
return Wrap(
spacing: 8,
@@ -95,11 +95,11 @@ class _CoercionExampleState extends State {
}
IconData _getExampleIcon(String example) {
- if (_selectedType == 'number') {
+ if (_selectedType == _CoercionType.number) {
if (RegExp(r'^\d+\.?\d*$').hasMatch(example)) {
return Icons.check_circle;
}
- } else if (_selectedType == 'boolean') {
+ } else if (_selectedType == _CoercionType.boolean) {
if (['true', '1', 'yes', 'on'].contains(example.toLowerCase())) {
return Icons.toggle_on;
} else if (['false', '0', 'no', 'off', '']
@@ -115,11 +115,11 @@ class _CoercionExampleState extends State {
// For demonstration, we'll parse the input based on type
switch (_selectedType) {
- case 'number':
+ case _CoercionType.number:
// Try to parse as number
final num = double.tryParse(text);
return num ?? text;
- case 'boolean':
+ case _CoercionType.boolean:
// Various boolean representations
if (text.toLowerCase() == 'true' ||
text == '1' ||
@@ -134,23 +134,17 @@ class _CoercionExampleState extends State {
return false;
}
return text;
- case 'date':
+ case _CoercionType.date:
// Try to parse as date
try {
return DateTime.parse(text);
} catch (_) {
return text;
}
- case 'list':
- // Try JSON parse first
- try {
- final parsed = text.contains('[') ? text : '[$text]';
- return parsed;
- } catch (_) {
- // Fall back to comma-separated
- return text.split(',').map((e) => e.trim()).toList();
- }
- default:
+ case _CoercionType.list:
+ // Keep the input in the bracketed form expected by the demo.
+ return text.contains('[') ? text : '[$text]';
+ case _CoercionType.advanced:
return text;
}
}
@@ -169,7 +163,7 @@ class _CoercionExampleState extends State {
Row(
children: [
Expanded(
- child: DropdownButtonFormField(
+ child: DropdownButtonFormField<_CoercionType>(
initialValue: _selectedType,
onChanged: (value) {
setState(() {
@@ -184,23 +178,23 @@ class _CoercionExampleState extends State {
),
items: const [
DropdownMenuItem(
- value: 'number',
+ value: _CoercionType.number,
child: Text('Number Coercion'),
),
DropdownMenuItem(
- value: 'boolean',
+ value: _CoercionType.boolean,
child: Text('Boolean Coercion'),
),
DropdownMenuItem(
- value: 'date',
+ value: _CoercionType.date,
child: Text('Date Coercion'),
),
DropdownMenuItem(
- value: 'list',
+ value: _CoercionType.list,
child: Text('List Coercion'),
),
DropdownMenuItem(
- value: 'advanced',
+ value: _CoercionType.advanced,
child: Text('Advanced Number'),
),
],
@@ -213,7 +207,7 @@ class _CoercionExampleState extends State {
const Text('Strict Mode'),
Switch(
value: _strictMode,
- onChanged: _selectedType != 'advanced'
+ onChanged: _selectedType != _CoercionType.advanced
? (value) {
setState(() {
_strictMode = value;
@@ -250,7 +244,7 @@ class _CoercionExampleState extends State {
),
const SizedBox(height: 8),
Text(
- _descriptions[_selectedType] ?? '',
+ _descriptions[_selectedType.name] ?? '',
style: Theme.of(context).textTheme.bodySmall,
),
if (_strictMode) ...[
@@ -275,7 +269,7 @@ class _CoercionExampleState extends State {
controller: _inputController,
decoration: InputDecoration(
labelText: 'Input Value',
- hintText: 'Enter value to coerce to $_selectedType',
+ hintText: 'Enter value to coerce to ${_selectedType.name}',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.input),
),
diff --git a/packages/dzod/example/lib/examples/advanced/discriminated_union_example.dart b/packages/dzod/example/lib/examples/advanced/discriminated_union_example.dart
index ba75845..51bb471 100644
--- a/packages/dzod/example/lib/examples/advanced/discriminated_union_example.dart
+++ b/packages/dzod/example/lib/examples/advanced/discriminated_union_example.dart
@@ -5,6 +5,8 @@ import '../../widgets/result_display.dart';
import '../../widgets/schema_display.dart';
import '../../widgets/validation_card.dart';
+enum _MessageType { text, image, video, audio }
+
class DiscriminatedUnionExample extends StatefulWidget {
const DiscriminatedUnionExample({super.key});
@@ -15,7 +17,7 @@ class DiscriminatedUnionExample extends StatefulWidget {
class _DiscriminatedUnionExampleState extends State {
final _formKey = GlobalKey();
- String _selectedType = 'text';
+ _MessageType _selectedType = _MessageType.text;
// Controllers for different message types
final _textController = TextEditingController();
@@ -55,12 +57,12 @@ class _DiscriminatedUnionExampleState extends State {
Map _buildMessageData() {
switch (_selectedType) {
- case 'text':
+ case _MessageType.text:
return {
'type': 'text',
'content': _textController.text,
};
- case 'image':
+ case _MessageType.image:
final data = {
'type': 'image',
'url': _imageUrlController.text,
@@ -69,38 +71,36 @@ class _DiscriminatedUnionExampleState extends State {
data['alt'] = _imageAltController.text;
}
return data;
- case 'video':
+ case _MessageType.video:
return {
'type': 'video',
'url': _videoUrlController.text,
'duration': double.tryParse(_videoDurationController.text) ?? 0,
};
- case 'audio':
+ case _MessageType.audio:
return {
'type': 'audio',
'url': _audioUrlController.text,
'duration': double.tryParse(_audioDurationController.text) ?? 0,
};
- default:
- return {};
}
}
void _fillExample() {
setState(() {
switch (_selectedType) {
- case 'text':
+ case _MessageType.text:
_textController.text = 'Hello, this is a text message!';
break;
- case 'image':
+ case _MessageType.image:
_imageUrlController.text = 'https://example.com/image.jpg';
_imageAltController.text = 'A beautiful sunset';
break;
- case 'video':
+ case _MessageType.video:
_videoUrlController.text = 'https://example.com/video.mp4';
_videoDurationController.text = '120.5';
break;
- case 'audio':
+ case _MessageType.audio:
_audioUrlController.text = 'https://example.com/audio.mp3';
_audioDurationController.text = '180';
break;
@@ -122,7 +122,7 @@ class _DiscriminatedUnionExampleState extends State {
Widget _buildMessageTypeForm() {
switch (_selectedType) {
- case 'text':
+ case _MessageType.text:
return TextFormField(
controller: _textController,
maxLines: 3,
@@ -134,7 +134,7 @@ class _DiscriminatedUnionExampleState extends State {
),
);
- case 'image':
+ case _MessageType.image:
return Column(
children: [
TextFormField(
@@ -159,7 +159,7 @@ class _DiscriminatedUnionExampleState extends State {
],
);
- case 'video':
+ case _MessageType.video:
return Column(
children: [
TextFormField(
@@ -185,7 +185,7 @@ class _DiscriminatedUnionExampleState extends State {
],
);
- case 'audio':
+ case _MessageType.audio:
return Column(
children: [
TextFormField(
@@ -210,17 +210,15 @@ class _DiscriminatedUnionExampleState extends State {
),
],
);
-
- default:
- return const SizedBox.shrink();
}
}
@override
Widget build(BuildContext context) {
// Use extended schema if audio is selected
- final currentSchema =
- _selectedType == 'audio' ? extendedMessageSchema : messageSchema;
+ final currentSchema = _selectedType == _MessageType.audio
+ ? extendedMessageSchema
+ : messageSchema;
return ValidationCard(
title: 'Example 10: Discriminated Unions',
@@ -232,7 +230,7 @@ class _DiscriminatedUnionExampleState extends State {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Message type selector
- DropdownButtonFormField(
+ DropdownButtonFormField<_MessageType>(
initialValue: _selectedType,
onChanged: (value) {
setState(() {
@@ -247,7 +245,7 @@ class _DiscriminatedUnionExampleState extends State {
),
items: const [
DropdownMenuItem(
- value: 'text',
+ value: _MessageType.text,
child: Row(
children: [
Icon(Icons.text_fields, size: 20),
@@ -257,7 +255,7 @@ class _DiscriminatedUnionExampleState extends State {
),
),
DropdownMenuItem(
- value: 'image',
+ value: _MessageType.image,
child: Row(
children: [
Icon(Icons.image, size: 20),
@@ -267,7 +265,7 @@ class _DiscriminatedUnionExampleState extends State {
),
),
DropdownMenuItem(
- value: 'video',
+ value: _MessageType.video,
child: Row(
children: [
Icon(Icons.videocam, size: 20),
@@ -277,7 +275,7 @@ class _DiscriminatedUnionExampleState extends State {
),
),
DropdownMenuItem(
- value: 'audio',
+ value: _MessageType.audio,
child: Row(
children: [
Icon(Icons.audiotrack, size: 20),
diff --git a/packages/dzod/example/lib/examples/advanced/recursive_schema_example.dart b/packages/dzod/example/lib/examples/advanced/recursive_schema_example.dart
index 82cb724..7fa836f 100644
--- a/packages/dzod/example/lib/examples/advanced/recursive_schema_example.dart
+++ b/packages/dzod/example/lib/examples/advanced/recursive_schema_example.dart
@@ -224,8 +224,8 @@ class _RecursiveSchemaExampleState extends State {
return currentDepth;
}
- final children = node['children'] as List?;
- if (children == null || children.isEmpty) {
+ final children = node['children'];
+ if (children is! List || children.isEmpty) {
return currentDepth;
}
diff --git a/packages/dzod/example/lib/examples/async/api_validation_example.dart b/packages/dzod/example/lib/examples/async/api_validation_example.dart
index e010a32..62147f9 100644
--- a/packages/dzod/example/lib/examples/async/api_validation_example.dart
+++ b/packages/dzod/example/lib/examples/async/api_validation_example.dart
@@ -50,17 +50,12 @@ class _ApiValidationExampleState extends State {
_result = null;
});
- try {
- final result = await apiSchema.validateAsync(_urlController.text);
- setState(() {
- _result = result;
- _isValidating = false;
- });
- } catch (e) {
- setState(() {
- _isValidating = false;
- });
- }
+ final result = await apiSchema.validateAsync(_urlController.text);
+ if (!mounted) return;
+ setState(() {
+ _result = result;
+ _isValidating = false;
+ });
}
void _fillValidExample() {
diff --git a/packages/dzod/example/lib/examples/async/database_validation_example.dart b/packages/dzod/example/lib/examples/async/database_validation_example.dart
index e4444ce..364ba52 100644
--- a/packages/dzod/example/lib/examples/async/database_validation_example.dart
+++ b/packages/dzod/example/lib/examples/async/database_validation_example.dart
@@ -76,17 +76,12 @@ class _DatabaseValidationExampleState extends State {
'username': _usernameController.text,
};
- try {
- final result = await userSchema.validateAsync(data);
- setState(() {
- _result = result;
- _isValidating = false;
- });
- } catch (e) {
- setState(() {
- _isValidating = false;
- });
- }
+ final result = await userSchema.validateAsync(data);
+ if (!mounted) return;
+ setState(() {
+ _result = result;
+ _isValidating = false;
+ });
}
void _fillAvailableExample() {
diff --git a/packages/dzod/example/lib/examples/complex/array_advanced_example.dart b/packages/dzod/example/lib/examples/complex/array_advanced_example.dart
index 7869191..5acd7e7 100644
--- a/packages/dzod/example/lib/examples/complex/array_advanced_example.dart
+++ b/packages/dzod/example/lib/examples/complex/array_advanced_example.dart
@@ -68,8 +68,6 @@ class _ArrayAdvancedExampleState extends State {
Schema _getCurrentSchema() {
switch (_selectedArrayType) {
- case 'base':
- return _baseArraySchema;
case 'range':
return _baseArraySchema.min(1).max(10);
case 'exactLength':
diff --git a/packages/dzod/example/lib/examples/complex/enum_example.dart b/packages/dzod/example/lib/examples/complex/enum_example.dart
index 3efdf10..e0fba17 100644
--- a/packages/dzod/example/lib/examples/complex/enum_example.dart
+++ b/packages/dzod/example/lib/examples/complex/enum_example.dart
@@ -59,8 +59,6 @@ class _EnumExampleState extends State {
Schema _getCurrentSchema() {
switch (_selectedEnumType) {
- case 'basic':
- return _basicRoleSchema;
case 'excluded':
return _basicRoleSchema.exclude(['guest']);
case 'extended':
diff --git a/packages/dzod/example/lib/examples/complex/object_manipulation_example.dart b/packages/dzod/example/lib/examples/complex/object_manipulation_example.dart
index 06ae0f6..14b6813 100644
--- a/packages/dzod/example/lib/examples/complex/object_manipulation_example.dart
+++ b/packages/dzod/example/lib/examples/complex/object_manipulation_example.dart
@@ -82,8 +82,6 @@ class _ObjectManipulationExampleState extends State {
Schema _getCurrentSchema() {
switch (_selectedManipulation) {
- case 'base':
- return _baseUserSchema;
case 'picked':
return _baseUserSchema.pick(['name', 'email']);
case 'omitted':
diff --git a/packages/dzod/example/lib/examples/complex/tuple_example.dart b/packages/dzod/example/lib/examples/complex/tuple_example.dart
index 93e5d0f..39db095 100644
--- a/packages/dzod/example/lib/examples/complex/tuple_example.dart
+++ b/packages/dzod/example/lib/examples/complex/tuple_example.dart
@@ -52,7 +52,6 @@ class _TupleExampleState extends State {
}
void _clearInput() {
- _tupleFormKey.currentState?.reset();
_element1Controller.clear();
_element2Controller.clear();
_element3Controller.clear();
@@ -76,8 +75,6 @@ class _TupleExampleState extends State {
Schema _getCurrentSchema() {
switch (_selectedTupleType) {
- case 'basic':
- return _basicTupleSchema;
case 'withRest':
return _basicTupleSchema.rest(z.string());
case 'exactLength':
diff --git a/packages/dzod/example/lib/examples/real_world/authentication_schema_example.dart b/packages/dzod/example/lib/examples/real_world/authentication_schema_example.dart
index 5626094..b1ca060 100644
--- a/packages/dzod/example/lib/examples/real_world/authentication_schema_example.dart
+++ b/packages/dzod/example/lib/examples/real_world/authentication_schema_example.dart
@@ -5,6 +5,8 @@ import '../../widgets/result_display.dart';
import '../../widgets/schema_display.dart';
import '../../widgets/validation_card.dart';
+enum _AuthMethod { email, oauth, apikey }
+
class AuthenticationSchemaExample extends StatefulWidget {
const AuthenticationSchemaExample({super.key});
@@ -16,7 +18,7 @@ class AuthenticationSchemaExample extends StatefulWidget {
class _AuthenticationSchemaExampleState
extends State {
final _formKey = GlobalKey();
- String _selectedMethod = 'email';
+ _AuthMethod _selectedMethod = _AuthMethod.email;
// Controllers for different auth methods
final _emailController = TextEditingController();
@@ -56,42 +58,40 @@ class _AuthenticationSchemaExampleState
Map _buildAuthData() {
switch (_selectedMethod) {
- case 'email':
+ case _AuthMethod.email:
return {
'method': 'email',
'email': _emailController.text,
'password': _passwordController.text,
};
- case 'oauth':
+ case _AuthMethod.oauth:
return {
'method': 'oauth',
'provider': _providerController.text,
'token': _tokenController.text,
};
- case 'apikey':
+ case _AuthMethod.apikey:
return {
'method': 'apikey',
'key': _apiKeyController.text,
'secret': _apiSecretController.text,
};
- default:
- return {};
}
}
void _fillValidExample() {
setState(() {
switch (_selectedMethod) {
- case 'email':
+ case _AuthMethod.email:
_emailController.text = 'user@example.com';
_passwordController.text = 'SecurePass123!';
break;
- case 'oauth':
+ case _AuthMethod.oauth:
_providerController.text = 'google';
_tokenController.text =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
break;
- case 'apikey':
+ case _AuthMethod.apikey:
_apiKeyController.text = '12345678901234567890123456789012';
_apiSecretController.text =
'1234567890123456789012345678901234567890123456789012345678901234';
@@ -103,15 +103,15 @@ class _AuthenticationSchemaExampleState
void _fillInvalidExample() {
setState(() {
switch (_selectedMethod) {
- case 'email':
+ case _AuthMethod.email:
_emailController.text = 'invalid-email';
_passwordController.text = 'weak';
break;
- case 'oauth':
+ case _AuthMethod.oauth:
_providerController.text = 'google';
_tokenController.text = 'invalid-jwt-token';
break;
- case 'apikey':
+ case _AuthMethod.apikey:
_apiKeyController.text = 'too-short';
_apiSecretController.text = 'not-hex';
break;
@@ -131,7 +131,7 @@ class _AuthenticationSchemaExampleState
Widget _buildAuthMethodForm() {
switch (_selectedMethod) {
- case 'email':
+ case _AuthMethod.email:
return Column(
children: [
TextFormField(
@@ -183,7 +183,7 @@ class _AuthenticationSchemaExampleState
],
);
- case 'oauth':
+ case _AuthMethod.oauth:
return Column(
children: [
DropdownButtonFormField(
@@ -220,7 +220,7 @@ class _AuthenticationSchemaExampleState
],
);
- case 'apikey':
+ case _AuthMethod.apikey:
return Column(
children: [
TextFormField(
@@ -265,32 +265,26 @@ class _AuthenticationSchemaExampleState
),
],
);
-
- default:
- return const SizedBox.shrink();
}
}
- Widget _buildMethodIcon(String method) {
+ Widget _buildMethodIcon(_AuthMethod method) {
IconData icon;
Color? color;
switch (method) {
- case 'email':
+ case _AuthMethod.email:
icon = Icons.email;
color = Colors.blue;
break;
- case 'oauth':
+ case _AuthMethod.oauth:
icon = Icons.account_circle;
color = Colors.orange;
break;
- case 'apikey':
+ case _AuthMethod.apikey:
icon = Icons.vpn_key;
color = Colors.green;
break;
- default:
- icon = Icons.lock;
- color = null;
}
return Icon(icon, color: color);
@@ -308,7 +302,7 @@ class _AuthenticationSchemaExampleState
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Auth method selector
- DropdownButtonFormField(
+ DropdownButtonFormField<_AuthMethod>(
initialValue: _selectedMethod,
onChanged: (value) {
setState(() {
@@ -321,16 +315,16 @@ class _AuthenticationSchemaExampleState
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.security),
),
- items: ['email', 'oauth', 'apikey'].map((method) {
+ items: _AuthMethod.values.map((method) {
return DropdownMenuItem(
value: method,
child: Row(
children: [
_buildMethodIcon(method),
const SizedBox(width: 8),
- Text(method == 'email'
+ Text(method == _AuthMethod.email
? 'Email/Password'
- : method == 'oauth'
+ : method == _AuthMethod.oauth
? 'OAuth Provider'
: 'API Key'),
],
diff --git a/packages/dzod/example/lib/examples/schema_composition/json_schema_generation_example.dart b/packages/dzod/example/lib/examples/schema_composition/json_schema_generation_example.dart
index 3c285e8..c3c2565 100644
--- a/packages/dzod/example/lib/examples/schema_composition/json_schema_generation_example.dart
+++ b/packages/dzod/example/lib/examples/schema_composition/json_schema_generation_example.dart
@@ -91,12 +91,8 @@ class _JsonSchemaGenerationExampleState
}
String get _jsonSchema {
- try {
- final schema = _currentSchema.toJsonSchema();
- return const JsonEncoder.withIndent(' ').convert(schema);
- } catch (e) {
- return 'Error generating JSON Schema: $e';
- }
+ final schema = _currentSchema.toJsonSchema();
+ return const JsonEncoder.withIndent(' ').convert(schema);
}
void _copyToClipboard() {
diff --git a/packages/dzod/example/lib/main.dart b/packages/dzod/example/lib/main.dart
index e00b7ca..852051c 100644
--- a/packages/dzod/example/lib/main.dart
+++ b/packages/dzod/example/lib/main.dart
@@ -177,6 +177,7 @@ class _ValidationShowcasePageState extends State {
Container(
padding: const EdgeInsets.all(16),
child: DropdownButtonFormField(
+ isExpanded: true,
initialValue: _selectedCategory,
onChanged: (value) {
setState(() {
diff --git a/packages/dzod/example/pubspec.yaml b/packages/dzod/example/pubspec.yaml
index 11bfa9e..188f4b3 100644
--- a/packages/dzod/example/pubspec.yaml
+++ b/packages/dzod/example/pubspec.yaml
@@ -5,7 +5,7 @@ publish_to: 'none'
version: 2.0.0
environment:
- sdk: '>=2.18.4 <4.0.0'
+ sdk: '>=3.6.0 <4.0.0'
flutter: ">=1.17.0"
dependencies:
@@ -17,7 +17,9 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
- flutter_lints: ^2.0.3
+ flutter_lints: ^6.0.0
flutter:
- uses-material-design: true
\ No newline at end of file
+ uses-material-design: true
+
+resolution: workspace
diff --git a/packages/dzod/example/test/showcase_test.dart b/packages/dzod/example/test/showcase_test.dart
new file mode 100644
index 0000000..0f61677
--- /dev/null
+++ b/packages/dzod/example/test/showcase_test.dart
@@ -0,0 +1,235 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:dzod_example/main.dart' as app;
+import 'package:dzod_example/widgets/schema_display.dart';
+import 'package:dzod_example/widgets/validation_card.dart';
+import 'package:dzod_example/examples/advanced/coercion_example.dart';
+import 'package:dzod_example/examples/advanced/discriminated_union_example.dart';
+import 'package:dzod_example/examples/advanced/pipeline_example.dart';
+import 'package:dzod_example/examples/advanced/recursive_schema_example.dart';
+import 'package:dzod_example/examples/async/api_validation_example.dart';
+import 'package:dzod_example/examples/async/database_validation_example.dart';
+import 'package:dzod_example/examples/basic/boolean_null_example.dart';
+import 'package:dzod_example/examples/basic/number_validations_example.dart';
+import 'package:dzod_example/examples/basic/string_validation_example.dart';
+import 'package:dzod_example/examples/basic/user_schema_example.dart';
+import 'package:dzod_example/examples/complex/array_advanced_example.dart';
+import 'package:dzod_example/examples/complex/enum_example.dart';
+import 'package:dzod_example/examples/complex/object_manipulation_example.dart';
+import 'package:dzod_example/examples/complex/record_example.dart';
+import 'package:dzod_example/examples/complex/tuple_example.dart';
+import 'package:dzod_example/examples/error_handling/error_code_system_example.dart';
+import 'package:dzod_example/examples/error_handling/error_formatting_example.dart';
+import 'package:dzod_example/examples/real_world/authentication_schema_example.dart';
+import 'package:dzod_example/examples/schema_composition/introspection_example.dart';
+import 'package:dzod_example/examples/schema_composition/json_schema_generation_example.dart';
+import 'package:dzod_example/examples/security/security_best_practices_example.dart';
+
+Future settle(WidgetTester tester) async {
+ for (var i = 0; i < 5; i++) {
+ await tester.pump(const Duration(seconds: 1));
+ }
+ expect(tester.takeException(), isNull);
+}
+
+Future pressButtons(WidgetTester tester) async {
+ final buttons = tester
+ .widgetList(
+ find.byWidgetPredicate((w) => w is ButtonStyleButton))
+ .toList();
+ for (final button in buttons) {
+ button.onPressed?.call();
+ await settle(tester);
+ tester
+ .widget(find.byType(ValidationCard))
+ .onValidate
+ ?.call();
+ await settle(tester);
+ for (final label in ['Check URL', 'Validate Async', 'Validate Securely']) {
+ final control = find.ancestor(
+ of: find.text(label),
+ matching: find.byWidgetPredicate((w) => w is ButtonStyleButton));
+ if (control.evaluate().isNotEmpty) {
+ tester.widget(control).onPressed?.call();
+ await settle(tester);
+ }
+ }
+ for (final icon
+ in tester.widgetList(find.byType(IconButton)).toList()) {
+ icon.onPressed?.call();
+ await settle(tester);
+ }
+ }
+ for (final chip
+ in tester.widgetList(find.byType(ActionChip)).toList()) {
+ chip.onPressed?.call();
+ await settle(tester);
+ }
+ for (final toggle
+ in tester.widgetList(find.byType(Switch)).toList()) {
+ toggle.onChanged?.call(!toggle.value);
+ await settle(tester);
+ }
+ for (final radio in tester
+ .widgetList>(find.byType(RadioGroup))
+ .toList()) {
+ radio.onChanged(true);
+ await settle(tester);
+ radio.onChanged(false);
+ await settle(tester);
+ }
+ for (final checkbox in tester
+ .widgetList(find.byType(CheckboxListTile))
+ .toList()) {
+ checkbox.onChanged?.call(true);
+ await settle(tester);
+ checkbox.onChanged?.call(false);
+ await settle(tester);
+ }
+ for (final slider
+ in tester.widgetList(find.byType(Slider)).toList()) {
+ slider.onChanged?.call(slider.min);
+ await settle(tester);
+ }
+}
+
+void main() {
+ final examples = [
+ const CoercionExample(),
+ const DiscriminatedUnionExample(),
+ const PipelineExample(),
+ const RecursiveSchemaExample(),
+ const ApiValidationExample(),
+ const DatabaseValidationExample(),
+ const BooleanNullExample(),
+ const NumberValidationsExample(),
+ const StringValidationExample(),
+ const UserSchemaExample(),
+ const ArrayAdvancedExample(),
+ const EnumExample(),
+ const ObjectManipulationExample(),
+ const RecordExample(),
+ const TupleExample(),
+ const ErrorCodeSystemExample(),
+ const ErrorFormattingExample(),
+ const AuthenticationSchemaExample(),
+ const IntrospectionExample(),
+ const JsonSchemaGenerationExample(),
+ const SecurityBestPracticesExample(),
+ ];
+
+ for (final example in examples) {
+ testWidgets(
+ '${example.runtimeType} renders and validates every selectable example',
+ (tester) async {
+ tester.view.physicalSize = const Size(1600, 1600);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ await tester.pumpWidget(MaterialApp(
+ home: Scaffold(body: SingleChildScrollView(child: example))));
+ await settle(tester);
+ expect(find.byType(ValidationCard), findsOneWidget);
+ expect(find.byType(SchemaDisplay), findsWidgets);
+ await pressButtons(tester);
+ final dropdownCount =
+ find.byWidgetPredicate((w) => w is DropdownButton).evaluate().length;
+ for (var index = 0; index < dropdownCount; index++) {
+ dynamic dropdown = tester.widget(
+ find.byWidgetPredicate((w) => w is DropdownButton).at(index));
+ final values =
+ (dropdown.items as List).map((dynamic item) => item.value).toList();
+ for (final value in values) {
+ dropdown = tester.widget(
+ find.byWidgetPredicate((w) => w is DropdownButton).at(index));
+ dropdown.onChanged(value);
+ await settle(tester);
+ expect(find.byType(ValidationCard), findsOneWidget);
+ await pressButtons(tester);
+ final nestedDropdowns = tester
+ .widgetList(find.byWidgetPredicate((w) => w is DropdownButton))
+ .toList();
+ for (final nested in nestedDropdowns.skip(1)) {
+ final dynamic control = nested;
+ for (final dynamic item in control.items) {
+ control.onChanged(item.value);
+ await settle(tester);
+ }
+ }
+ final fields = tester
+ .widgetList(find.byType(TextFormField))
+ .toList();
+ for (final field in fields) {
+ field.controller?.text = 'invalid input';
+ field.onChanged?.call('invalid input');
+ field.validator?.call('invalid input');
+ }
+ await settle(tester);
+ await pressButtons(tester);
+ }
+ }
+ for (final toggle in tester
+ .widgetList(find.byType(SwitchListTile))
+ .toList()) {
+ toggle.onChanged?.call(!toggle.value);
+ await settle(tester);
+ }
+ for (final field in tester
+ .widgetList(find.byType(TextFormField))
+ .toList()) {
+ field.controller?.text = 'invalid input';
+ field.onChanged?.call('invalid input');
+ field.validator?.call('invalid input');
+ }
+ await settle(tester);
+ await pressButtons(tester);
+ expect(find.byType(ValidationCard), findsOneWidget);
+ await tester.pumpWidget(const SizedBox.shrink());
+ await settle(tester);
+ });
+ }
+
+ testWidgets('record editor adds numeric values and rejects invalid values',
+ (tester) async {
+ tester.view.physicalSize = const Size(1600, 1600);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ await tester.pumpWidget(const MaterialApp(
+ home: Scaffold(body: SingleChildScrollView(child: RecordExample()))));
+ final fields =
+ tester.widgetList(find.byType(TextFormField)).toList();
+ fields[0].controller!.text = 'count';
+ fields[1].controller!.text = '42';
+ await tester.tap(find.byTooltip('Add entry'));
+ await settle(tester);
+ expect(find.text('count: 42'), findsOneWidget);
+ expect(fields[0].controller!.text, isEmpty);
+ expect(fields[1].controller!.text, isEmpty);
+ });
+
+ testWidgets('app entry point builds the showcase', (tester) async {
+ tester.view.physicalSize = const Size(1600, 1600);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(tester.view.resetPhysicalSize);
+ addTearDown(tester.view.resetDevicePixelRatio);
+ app.main();
+ await tester.pumpAndSettle();
+ expect(find.byType(app.ValidationShowcasePage), findsOneWidget);
+ dynamic categories =
+ tester.widget(find.byWidgetPredicate((w) => w is DropdownButton).first);
+ final values =
+ (categories.items as List).map((dynamic item) => item.value).toList();
+ for (final value in values) {
+ categories = tester
+ .widget(find.byWidgetPredicate((w) => w is DropdownButton).first);
+ categories.onChanged(value);
+ await settle(tester);
+ for (final tile
+ in tester.widgetList(find.byType(ListTile)).toList()) {
+ tile.onTap?.call();
+ await settle(tester);
+ }
+ }
+ });
+}
diff --git a/packages/dzod/lib/dzod.dart b/packages/dzod/lib/dzod.dart
index 7593eb6..7aa3f6e 100644
--- a/packages/dzod/lib/dzod.dart
+++ b/packages/dzod/lib/dzod.dart
@@ -1,4 +1,3 @@
-// coverage:ignore-file
// ignore_for_file: unused_import
library;
diff --git a/packages/dzod/lib/src/core/error_utils.dart b/packages/dzod/lib/src/core/error_utils.dart
index 6ac8768..ad6b2be 100644
--- a/packages/dzod/lib/src/core/error_utils.dart
+++ b/packages/dzod/lib/src/core/error_utils.dart
@@ -12,9 +12,7 @@ typedef ErrorGroupingFunction = T Function(ValidationError error);
typedef ErrorComparator = int Function(ValidationError a, ValidationError b);
/// Comprehensive error filtering and grouping utilities
-class ErrorUtils {
- const ErrorUtils._();
-
+abstract final class ErrorUtils {
// ==================== FILTERING UTILITIES ====================
/// Filters errors by error code
diff --git a/packages/dzod/lib/src/core/json_schema.dart b/packages/dzod/lib/src/core/json_schema.dart
index 5e34121..8f46e88 100644
--- a/packages/dzod/lib/src/core/json_schema.dart
+++ b/packages/dzod/lib/src/core/json_schema.dart
@@ -143,11 +143,6 @@ class JsonSchemaGenerator {
...schemaMap,
};
- // Add definitions if any were collected
- if (context.definitions.isNotEmpty) {
- result['definitions'] = context.definitions;
- }
-
return result;
}
@@ -331,9 +326,6 @@ class JsonSchemaGenerator {
if (constraints['exclusiveMaximum'] != null) {
result['exclusiveMaximum'] = constraints['exclusiveMaximum'];
}
- if (constraints['multipleOf'] != null) {
- result['multipleOf'] = constraints['multipleOf'];
- }
} catch (e) {
// Ignore constraint extraction errors
}
@@ -563,46 +555,22 @@ class JsonSchemaGenerator {
/// Generate JSON Schema for UnionSchema
Map _generateUnionSchema(
UnionSchema schema, JsonSchemaContext context) {
- try {
- // Union schemas use anyOf in JSON Schema
- // Since we can't access private _schemas field directly, we'll use a fallback
- // In a real implementation, you'd need access to the schemas list
- return {
- 'anyOf': [
- // This would need actual access to schema._schemas
- // schema._schemas.map((s) => _generateSchema(s, context)).toList()
- ],
- 'description': 'Union schema - requires access to constituent schemas',
- };
- } catch (e) {
- // Fallback
- return {
- 'description': 'Union schema (schemas not accessible)',
- };
- }
+ // Constituent schemas are not exposed yet.
+ return {
+ 'anyOf':