Compare commits

...

2 Commits

Author SHA1 Message Date
mansi.kansara 54c66efe9b chore: update README and CLI usage for cursor_gen, version bump to 1.0.1
- Changed CLI usage instructions from `dart run cursor_gen` to `cursor_gen` for global activation.
- Updated project-brief.yaml example and README to reflect new command usage.
- Added app_context section in project-brief.yaml for theme variants and RBAC roles.
- Fixed bundled template resolution for local and global installs to prevent 'Template not found' errors.
- Version bump to 1.0.1 with corresponding updates in CHANGELOG and pubspec.yaml.
2026-05-13 12:08:52 +05:30
mansi.kansara b05cdb7fbe fix(cursor_gen): satisfy pub publish validation
Add LICENSE (MIT), README, and CHANGELOG for the package root.
Import models.dart in the CLI for OverrideSnapshot.
Wrap tests in main() and add containsNot() helper for analyzer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 22:54:12 +05:30
158 changed files with 8706 additions and 559 deletions
+34 -17
View File
@@ -54,21 +54,37 @@ flutter-cursor-gen/
## Quick Start ## Quick Start
### 1. Add to your Flutter project ### 1. Install the CLI globally
```yaml From pub.dev:
# pubspec.yaml (dev dependency)
dev_dependencies: ```bash
cursor_gen: dart pub global activate cursor_gen
git: ```
url: https://github.com/company/flutter-cursor-templates
path: generator From this repository:
```bash
cd flutter-cursor-templates/generator
dart pub global activate --source path .
```
From Git:
```bash
dart pub global activate --source git https://github.com/company/flutter-cursor-templates --git-path generator
```
If `cursor_gen` is not found after activation, add Dart's global bin directory to your shell:
```bash
export PATH="$PATH":"$HOME/.pub-cache/bin"
``` ```
### 2. Create your brief (interactive wizard) ### 2. Create your brief (interactive wizard)
```bash ```bash
dart run cursor_gen --wizard cursor_gen --wizard
``` ```
Or copy the single reference brief (every option is explained in comments): Or copy the single reference brief (every option is explained in comments):
@@ -80,7 +96,8 @@ cp path/to/flutter-cursor-gen/example-project/project-brief.yaml .
### 3. Generate your .cursor/ directory ### 3. Generate your .cursor/ directory
```bash ```bash
dart run cursor_gen cursor_gen --validate
cursor_gen
``` ```
### 4. Commit and share with your team ### 4. Commit and share with your team
@@ -95,13 +112,13 @@ git commit -m "chore: add cursor AI config for this project"
## All CLI commands ## All CLI commands
```bash ```bash
dart run cursor_gen # First-time setup cursor_gen # First-time setup
dart run cursor_gen --wizard # Interactive brief creator cursor_gen --wizard # Interactive brief creator
dart run cursor_gen --validate # Validate brief without generating cursor_gen --validate # Validate brief without generating
dart run cursor_gen --refresh # Re-generate (preserves custom/ + CURSOR:CUSTOM blocks) cursor_gen --refresh # Re-generate (preserves custom/ + CURSOR:CUSTOM blocks)
dart run cursor_gen --diff # Preview changes before refresh cursor_gen --diff # Preview changes before refresh
dart run cursor_gen --check-updates # Check for template version updates cursor_gen --check-updates # Check for template version updates
dart run cursor_gen --telemetry # Show usage analytics report cursor_gen --telemetry # Show usage analytics report
``` ```
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
# .cursor/custom/ — project-specific overrides # .cursor/custom/ — project-specific overrides
This directory is never modified by `dart run cursor_gen` or `--refresh`. This directory is never modified by `cursor_gen` or `--refresh`.
Use it for team-only rules, vendor SDK notes, or policies that should not live in the shared template repo. Use it for team-only rules, vendor SDK notes, or policies that should not live in the shared template repo.
+12 -2
View File
@@ -1,6 +1,6 @@
# ============================================================================= # =============================================================================
# project-brief.yaml — SINGLE REFERENCE EXAMPLE for cursor_gen # project-brief.yaml — SINGLE REFERENCE EXAMPLE for cursor_gen
# Copy to your Flutter repo root, edit values, then: dart run cursor_gen # Copy to your Flutter repo root, edit values, then: cursor_gen
# Schema / IDE hints: flutter-cursor-templates/generator/brief-schema.json # Schema / IDE hints: flutter-cursor-templates/generator/brief-schema.json
# ============================================================================= # =============================================================================
# #
@@ -13,7 +13,7 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Template pin (Pillar 1) — bump when you intentionally upgrade template output # Template pin (Pillar 1) — bump when you intentionally upgrade template output
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
cursor_templates_version: "1.0.0" cursor_templates_version: "1.0.1"
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# project — identity and rough size (affects rule tone / scaffolding hints) # project — identity and rough size (affects rule tone / scaffolding hints)
@@ -92,6 +92,16 @@ references:
- "https://github.com/example/acme-design-tokens" - "https://github.com/example/acme-design-tokens"
local_paths: [] local_paths: []
# -----------------------------------------------------------------------------
# app_context — themes & RBAC (mirrored to .cursor/cursor-gen-metadata.json)
# -----------------------------------------------------------------------------
# theme_variants: subset of [ light, dark, high_contrast ]; omit section or use [] → loader defaults to [light, dark]
# roles_enabled / role_names: use named roles when the app has RBAC
# app_context:
# theme_variants: ["light", "dark", "high_contrast"]
# roles_enabled: true
# role_names: ["customer", "merchant", "admin"]
features: features:
# modules: high-level feature areas in YOUR lib/ (names are project-specific) # modules: high-level feature areas in YOUR lib/ (names are project-specific)
modules: ["auth", "home", "catalog", "cart", "checkout", "profile", "orders"] modules: ["auth", "home", "catalog", "cart", "checkout", "profile", "orders"]
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## [1.0.1] - 2026-05-13
### Fixed
- Resolve bundled templates for local, Git, and hosted/global installs so generated files contain real content instead of `Template not found` placeholders.
### Changed
- Document global `cursor_gen` installation and usage across the README and generated guidance.
- Include a package-local template copy for pub.dev publishing.
## [1.0.0] - 2025-01-01 ## [1.0.0] - 2025-01-01
### Added ### Added
- Initial release of flutter-cursor-templates - Initial release of flutter-cursor-templates
+1 -1
View File
@@ -1 +1 @@
1.0.0 1.0.1
@@ -0,0 +1,316 @@
{
"configVersion": 2,
"packages": [
{
"name": "_fe_analyzer_shared",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/_fe_analyzer_shared-88.0.0",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "analyzer",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/analyzer-8.1.1",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "ansi_styles",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/ansi_styles-0.3.2+1",
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "args",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/args-2.7.0",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "async",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/async-2.13.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "boolean_selector",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/boolean_selector-2.1.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "cli_config",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/cli_config-0.2.0",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "collection",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/collection-1.19.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "convert",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/convert-3.1.2",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "coverage",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/coverage-1.15.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "crypto",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/crypto-3.0.7",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "file",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/file-7.0.1",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "frontend_server_client",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/frontend_server_client-4.0.0",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "glob",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/glob-2.1.3",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "http",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/http-1.6.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "http_multi_server",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/http_multi_server-3.2.2",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "http_parser",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/http_parser-4.1.2",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "io",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/io-1.0.5",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "lints",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/lints-3.0.0",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "logging",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/logging-1.3.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "matcher",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/matcher-0.12.19",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "meta",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/meta-1.18.2",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "mime",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/mime-2.0.0",
"packageUri": "lib/",
"languageVersion": "3.2"
},
{
"name": "node_preamble",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/node_preamble-2.0.2",
"packageUri": "lib/",
"languageVersion": "2.12"
},
{
"name": "package_config",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/package_config-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "path",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/path-1.9.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "pool",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/pool-1.5.2",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "pub_semver",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/pub_semver-2.2.0",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "shelf",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/shelf-1.4.2",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "shelf_packages_handler",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/shelf_packages_handler-3.0.2",
"packageUri": "lib/",
"languageVersion": "2.17"
},
{
"name": "shelf_static",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/shelf_static-1.1.3",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "shelf_web_socket",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/shelf_web_socket-3.0.0",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "source_map_stack_trace",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/source_map_stack_trace-2.1.2",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "source_maps",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/source_maps-0.10.13",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "source_span",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/source_span-1.10.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "stack_trace",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/stack_trace-1.12.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "stream_channel",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/stream_channel-2.1.4",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "string_scanner",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/string_scanner-1.4.1",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "term_glyph",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/term_glyph-1.2.2",
"packageUri": "lib/",
"languageVersion": "3.1"
},
{
"name": "test",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/test-1.31.0",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "test_api",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/test_api-0.7.11",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "test_core",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/test_core-0.6.17",
"packageUri": "lib/",
"languageVersion": "3.7"
},
{
"name": "typed_data",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/typed_data-1.4.0",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "vm_service",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/vm_service-15.2.0",
"packageUri": "lib/",
"languageVersion": "3.5"
},
{
"name": "watcher",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/watcher-1.2.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "web",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/web-1.1.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "web_socket",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/web_socket-1.0.1",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "web_socket_channel",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/web_socket_channel-3.0.3",
"packageUri": "lib/",
"languageVersion": "3.3"
},
{
"name": "webkit_inspection_protocol",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/webkit_inspection_protocol-1.2.1",
"packageUri": "lib/",
"languageVersion": "3.0"
},
{
"name": "yaml",
"rootUri": "file:///Users/maheshlalwani.devgmail.com/.pub-cache/hosted/pub.dev/yaml-3.1.3",
"packageUri": "lib/",
"languageVersion": "3.4"
},
{
"name": "cursor_gen",
"rootUri": "../",
"packageUri": "lib/",
"languageVersion": "3.3"
}
],
"generator": "pub",
"generatorVersion": "3.8.1",
"flutterRoot": "file:///Users/maheshlalwani.devgmail.com/Documents/flutter",
"flutterVersion": "3.32.5",
"pubCache": "file:///Users/maheshlalwani.devgmail.com/.pub-cache"
}
@@ -0,0 +1,470 @@
{
"roots": [
"cursor_gen"
],
"packages": [
{
"name": "cursor_gen",
"version": "1.0.1",
"dependencies": [
"ansi_styles",
"args",
"collection",
"crypto",
"http",
"path",
"yaml"
],
"devDependencies": [
"lints",
"test"
]
},
{
"name": "lints",
"version": "3.0.0",
"dependencies": []
},
{
"name": "ansi_styles",
"version": "0.3.2+1",
"dependencies": []
},
{
"name": "collection",
"version": "1.19.1",
"dependencies": []
},
{
"name": "crypto",
"version": "3.0.7",
"dependencies": [
"typed_data"
]
},
{
"name": "http",
"version": "1.6.0",
"dependencies": [
"async",
"http_parser",
"meta",
"web"
]
},
{
"name": "path",
"version": "1.9.1",
"dependencies": []
},
{
"name": "yaml",
"version": "3.1.3",
"dependencies": [
"collection",
"source_span",
"string_scanner"
]
},
{
"name": "args",
"version": "2.7.0",
"dependencies": []
},
{
"name": "typed_data",
"version": "1.4.0",
"dependencies": [
"collection"
]
},
{
"name": "web",
"version": "1.1.1",
"dependencies": []
},
{
"name": "meta",
"version": "1.18.2",
"dependencies": []
},
{
"name": "http_parser",
"version": "4.1.2",
"dependencies": [
"collection",
"source_span",
"string_scanner",
"typed_data"
]
},
{
"name": "async",
"version": "2.13.1",
"dependencies": [
"collection",
"meta"
]
},
{
"name": "string_scanner",
"version": "1.4.1",
"dependencies": [
"source_span"
]
},
{
"name": "source_span",
"version": "1.10.2",
"dependencies": [
"collection",
"path",
"term_glyph"
]
},
{
"name": "term_glyph",
"version": "1.2.2",
"dependencies": []
},
{
"name": "test",
"version": "1.31.0",
"dependencies": [
"analyzer",
"async",
"boolean_selector",
"collection",
"coverage",
"http_multi_server",
"io",
"matcher",
"node_preamble",
"package_config",
"path",
"pool",
"shelf",
"shelf_packages_handler",
"shelf_static",
"shelf_web_socket",
"source_span",
"stack_trace",
"stream_channel",
"test_api",
"test_core",
"typed_data",
"web_socket_channel",
"webkit_inspection_protocol",
"yaml"
]
},
{
"name": "webkit_inspection_protocol",
"version": "1.2.1",
"dependencies": [
"logging"
]
},
{
"name": "web_socket_channel",
"version": "3.0.3",
"dependencies": [
"async",
"crypto",
"stream_channel",
"web",
"web_socket"
]
},
{
"name": "test_core",
"version": "0.6.17",
"dependencies": [
"analyzer",
"args",
"async",
"boolean_selector",
"collection",
"coverage",
"frontend_server_client",
"glob",
"io",
"meta",
"package_config",
"path",
"pool",
"source_map_stack_trace",
"source_maps",
"source_span",
"stack_trace",
"stream_channel",
"test_api",
"vm_service",
"yaml"
]
},
{
"name": "test_api",
"version": "0.7.11",
"dependencies": [
"async",
"boolean_selector",
"collection",
"meta",
"source_span",
"stack_trace",
"stream_channel",
"string_scanner",
"term_glyph"
]
},
{
"name": "stream_channel",
"version": "2.1.4",
"dependencies": [
"async"
]
},
{
"name": "stack_trace",
"version": "1.12.1",
"dependencies": [
"path"
]
},
{
"name": "shelf_web_socket",
"version": "3.0.0",
"dependencies": [
"shelf",
"stream_channel",
"web_socket_channel"
]
},
{
"name": "shelf_static",
"version": "1.1.3",
"dependencies": [
"convert",
"http_parser",
"mime",
"path",
"shelf"
]
},
{
"name": "shelf_packages_handler",
"version": "3.0.2",
"dependencies": [
"path",
"shelf",
"shelf_static"
]
},
{
"name": "shelf",
"version": "1.4.2",
"dependencies": [
"async",
"collection",
"http_parser",
"path",
"stack_trace",
"stream_channel"
]
},
{
"name": "pool",
"version": "1.5.2",
"dependencies": [
"async",
"stack_trace"
]
},
{
"name": "package_config",
"version": "2.2.0",
"dependencies": [
"path"
]
},
{
"name": "node_preamble",
"version": "2.0.2",
"dependencies": []
},
{
"name": "io",
"version": "1.0.5",
"dependencies": [
"meta",
"path",
"string_scanner"
]
},
{
"name": "http_multi_server",
"version": "3.2.2",
"dependencies": [
"async"
]
},
{
"name": "coverage",
"version": "1.15.0",
"dependencies": [
"args",
"cli_config",
"glob",
"logging",
"meta",
"package_config",
"path",
"source_maps",
"stack_trace",
"vm_service",
"yaml"
]
},
{
"name": "boolean_selector",
"version": "2.1.2",
"dependencies": [
"source_span",
"string_scanner"
]
},
{
"name": "logging",
"version": "1.3.0",
"dependencies": []
},
{
"name": "web_socket",
"version": "1.0.1",
"dependencies": [
"web"
]
},
{
"name": "vm_service",
"version": "15.2.0",
"dependencies": []
},
{
"name": "source_maps",
"version": "0.10.13",
"dependencies": [
"source_span"
]
},
{
"name": "source_map_stack_trace",
"version": "2.1.2",
"dependencies": [
"path",
"source_maps",
"stack_trace"
]
},
{
"name": "glob",
"version": "2.1.3",
"dependencies": [
"async",
"collection",
"file",
"path",
"string_scanner"
]
},
{
"name": "frontend_server_client",
"version": "4.0.0",
"dependencies": [
"async",
"path"
]
},
{
"name": "mime",
"version": "2.0.0",
"dependencies": []
},
{
"name": "convert",
"version": "3.1.2",
"dependencies": [
"typed_data"
]
},
{
"name": "cli_config",
"version": "0.2.0",
"dependencies": [
"args",
"yaml"
]
},
{
"name": "file",
"version": "7.0.1",
"dependencies": [
"meta",
"path"
]
},
{
"name": "matcher",
"version": "0.12.19",
"dependencies": [
"async",
"meta",
"stack_trace",
"term_glyph",
"test_api"
]
},
{
"name": "analyzer",
"version": "8.1.1",
"dependencies": [
"_fe_analyzer_shared",
"collection",
"convert",
"crypto",
"glob",
"meta",
"package_config",
"path",
"pub_semver",
"source_span",
"watcher",
"yaml"
]
},
{
"name": "watcher",
"version": "1.2.1",
"dependencies": [
"async",
"path"
]
},
{
"name": "pub_semver",
"version": "2.2.0",
"dependencies": [
"collection"
]
},
{
"name": "_fe_analyzer_shared",
"version": "88.0.0",
"dependencies": [
"meta"
]
}
],
"configVersion": 1
}
@@ -0,0 +1,14 @@
# Changelog
## 1.0.1
- Fix default template resolution so generated projects use bundled templates instead of writing `Template not found` placeholders.
- Include templates in the published package so hosted/global installs can generate real content.
- Add a regression test for the generated `hooks/arch-guard.ts` content.
- Improve installation docs for global `cursor_gen` usage.
## 1.0.0
- Initial published release of the `cursor_gen` CLI.
- Generates `.cursor/` rules and related config from `project-brief.yaml`.
- Supports validate, generate, refresh (preserve customizations), diff preview, wizard, and update checks.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cursor_gen contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,98 @@
# cursor_gen
CLI that generates project-specific [Cursor](https://cursor.com) AI configuration for Flutter projects from a `project-brief.yaml` file (stack, architecture, routing, backends, testing, and more).
## Requirements
- Dart SDK `>=3.3.0 <4.0.0`
## Install
Install the CLI globally so you can run `cursor_gen` from any Flutter project.
**From pub.dev:**
```bash
dart pub global activate cursor_gen
```
**From Git:**
```bash
dart pub global activate --source git https://github.com/company/flutter-cursor-templates --git-path generator
```
**From a local checkout:**
```bash
cd flutter-cursor-templates/generator
dart pub global activate --source path .
```
If your shell cannot find `cursor_gen`, add Dart's global bin directory to your `PATH`:
```bash
export PATH="$PATH":"$HOME/.pub-cache/bin"
```
For zsh, add that line to `~/.zshrc` and restart your terminal.
**From a private Pub registry:** configure access first, then activate from that registry:
```bash
dart pub token add https://your-registry.example.com
dart pub global activate cursor_gen --hosted-url=https://your-registry.example.com
```
## Setup In Your Flutter Project
Create a `project-brief.yaml`:
```bash
cursor_gen --wizard
```
Or copy the example brief and edit it:
```bash
cp path/to/flutter-cursor-templates/example-project/project-brief.yaml .
```
Validate the brief, then generate `.cursor/`:
```bash
cursor_gen --validate
cursor_gen
```
## Usage
```bash
cursor_gen --help
cursor_gen --validate --brief project-brief.yaml
cursor_gen --brief project-brief.yaml --output .cursor
cursor_gen --refresh # regenerate while preserving custom/ and CURSOR:CUSTOM blocks
cursor_gen --wizard # interactive project-brief.yaml creator
```
## Minimal `project-brief.yaml`
```yaml
project:
name: MyApp
package: com.example.myapp
scale: medium
stack:
state_management: bloc
architecture: clean
routing: gorouter
platforms: [ios, android]
backend: firebase
auth: firebase_auth
```
Use `brief-schema.json` in this package for IDE validation and autocomplete of the full schema.
## Repository
For full monorepo layout, template library, and architecture notes, see the parent repository README.
@@ -1,9 +1,13 @@
#!/usr/bin/env dart #!/usr/bin/env dart
// cursor_gen — Flutter Cursor AI config generator // cursor_gen — Flutter Cursor AI config generator
// Usage: dart run cursor_gen [options] // Usage: cursor_gen [options]
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:isolate';
import 'package:args/args.dart'; import 'package:args/args.dart';
import 'package:path/path.dart' as p;
import '../src/models.dart';
import '../src/brief_loader.dart'; import '../src/brief_loader.dart';
import '../src/resolver.dart'; import '../src/resolver.dart';
import '../src/renderer.dart'; import '../src/renderer.dart';
@@ -17,15 +21,33 @@ import '../src/logger.dart';
Future<void> main(List<String> arguments) async { Future<void> main(List<String> arguments) async {
final parser = ArgParser() final parser = ArgParser()
..addFlag('help', abbr: 'h', negatable: false, help: 'Show usage') ..addFlag('help', abbr: 'h', negatable: false, help: 'Show usage')
..addFlag('validate', abbr: 'v', negatable: false, help: 'Validate project-brief.yaml without writing files') ..addFlag('validate',
..addFlag('refresh', abbr: 'r', negatable: false, help: 'Re-generate .cursor/ preserving custom/ and CURSOR:CUSTOM markers') abbr: 'v',
..addFlag('check-updates', negatable: false, help: 'Check if newer template version is available') negatable: false,
..addFlag('diff', negatable: false, help: 'Preview what would change before refreshing') help: 'Validate project-brief.yaml without writing files')
..addFlag('wizard', abbr: 'w', negatable: false, help: 'Interactive wizard to create project-brief.yaml') ..addFlag('refresh',
..addFlag('telemetry', negatable: false, help: 'Show telemetry / rule-trigger report') abbr: 'r',
..addOption('brief', abbr: 'b', defaultsTo: 'project-brief.yaml', help: 'Path to project-brief.yaml') negatable: false,
..addOption('output', abbr: 'o', defaultsTo: '.cursor', help: 'Output directory') help:
..addOption('templates', defaultsTo: '', help: 'Override template library path'); 'Re-generate .cursor/ preserving custom/ and CURSOR:CUSTOM markers')
..addFlag('check-updates',
negatable: false, help: 'Check if newer template version is available')
..addFlag('diff',
negatable: false, help: 'Preview what would change before refreshing')
..addFlag('wizard',
abbr: 'w',
negatable: false,
help: 'Interactive wizard to create project-brief.yaml')
..addFlag('telemetry',
negatable: false, help: 'Show telemetry / rule-trigger report')
..addOption('brief',
abbr: 'b',
defaultsTo: 'project-brief.yaml',
help: 'Path to project-brief.yaml')
..addOption('output',
abbr: 'o', defaultsTo: '.cursor', help: 'Output directory')
..addOption('templates',
defaultsTo: '', help: 'Override template library path');
final ArgResults args; final ArgResults args;
try { try {
@@ -84,8 +106,9 @@ Future<void> main(List<String> arguments) async {
final versionStatus = await VersionManager.check(brief: brief); final versionStatus = await VersionManager.check(brief: brief);
if (versionStatus.hasUpdate) { if (versionStatus.hasUpdate) {
Logger.warn('\n⚠ Template update available: ${versionStatus.currentVersion}${versionStatus.latestVersion}'); Logger.warn(
Logger.warn(' Run: dart run cursor_gen --check-updates for full diff\n'); '\n⚠ Template update available: ${versionStatus.currentVersion}${versionStatus.latestVersion}');
Logger.warn(' Run: cursor_gen --check-updates for full diff\n');
} }
if (args['diff'] as bool) { if (args['diff'] as bool) {
@@ -95,9 +118,11 @@ Future<void> main(List<String> arguments) async {
_printBanner(); _printBanner();
Logger.info('Generating .cursor/ for ${brief.projectName}'); Logger.info('Generating .cursor/ for ${brief.projectName}');
Logger.dim(' Stack: ${brief.stateManagement} + ${brief.architecture} + ${brief.backends.join("+")}'); Logger.dim(
' Stack: ${brief.stateManagement} + ${brief.architecture} + ${brief.backends.join("+")}');
Logger.dim(' Platform: ${brief.platforms.join(", ")}'); Logger.dim(' Platform: ${brief.platforms.join(", ")}');
Logger.dim(' Codegen: ${brief.codegenTools.isEmpty ? "none" : brief.codegenTools.join(", ")}\n'); Logger.dim(
' Codegen: ${brief.codegenTools.isEmpty ? "none" : brief.codegenTools.join(", ")}\n');
final isRefresh = args['refresh'] as bool; final isRefresh = args['refresh'] as bool;
final overrideSnapshot = await OverrideManager.snapshot(outputDir); final overrideSnapshot = await OverrideManager.snapshot(outputDir);
@@ -105,27 +130,40 @@ Future<void> main(List<String> arguments) async {
final templateFiles = Resolver.resolve(brief); final templateFiles = Resolver.resolve(brief);
Logger.info('Resolved ${templateFiles.length} template files'); Logger.info('Resolved ${templateFiles.length} template files');
final templateDir = templateSrc.isEmpty ? _defaultTemplateDir() : templateSrc; final templateDir =
templateSrc.isEmpty ? await _defaultTemplateDir() : templateSrc;
final rendered = await Renderer.render( final rendered = await Renderer.render(
brief: brief, templateFiles: templateFiles, templateSrc: templateDir, brief: brief,
templateFiles: templateFiles,
templateSrc: templateDir,
); );
await _writeOutput(outputDir, rendered, overrideSnapshot, isRefresh); await _writeOutput(outputDir, rendered, overrideSnapshot, isRefresh);
await VersionManager.writeLock(outputDir: outputDir, briefPath: briefPath, brief: brief); await _writeMetadataJson(outputDir, brief);
await Telemetry.record(projectName: brief.projectName, outputDir: outputDir, templateFiles: templateFiles); await VersionManager.writeLock(
outputDir: outputDir, briefPath: briefPath, brief: brief);
await Telemetry.record(
projectName: brief.projectName,
outputDir: outputDir,
templateFiles: templateFiles);
Logger.success('\n✔ Done! ${rendered.length} files written to $outputDir/'); Logger.success('\n✔ Done! ${rendered.length} files written to $outputDir/');
if (overrideSnapshot.customFiles.isNotEmpty) { if (overrideSnapshot.customFiles.isNotEmpty) {
Logger.success('${overrideSnapshot.customFiles.length} custom override(s) preserved untouched'); Logger.success(
'${overrideSnapshot.customFiles.length} custom override(s) preserved untouched');
} }
Logger.dim('\n Next: commit .cursor/ to git so all teammates get the same config.'); Logger.dim(
'\n Next: commit .cursor/ to git so all teammates get the same config.');
} }
Future<void> _runDiff(dynamic brief, String outputDir, String templateSrc) async { Future<void> _runDiff(
dynamic brief, String outputDir, String templateSrc) async {
final templateFiles = Resolver.resolve(brief); final templateFiles = Resolver.resolve(brief);
final rendered = await Renderer.render( final rendered = await Renderer.render(
brief: brief, templateFiles: templateFiles, brief: brief,
templateSrc: templateSrc.isEmpty ? _defaultTemplateDir() : templateSrc, templateFiles: templateFiles,
templateSrc:
templateSrc.isEmpty ? await _defaultTemplateDir() : templateSrc,
); );
Logger.info('Diff preview (no files written):\n'); Logger.info('Diff preview (no files written):\n');
for (final entry in rendered.entries) { for (final entry in rendered.entries) {
@@ -134,22 +172,27 @@ Future<void> _runDiff(dynamic brief, String outputDir, String templateSrc) async
Logger.success(' + ${entry.key}'); Logger.success(' + ${entry.key}');
} else { } else {
final current = await existing.readAsString(); final current = await existing.readAsString();
if (current != entry.value) Logger.warn(' ~ ${entry.key}'); if (current != entry.value)
else Logger.dim(' = ${entry.key}'); Logger.warn(' ~ ${entry.key}');
else
Logger.dim(' = ${entry.key}');
} }
} }
} }
Future<void> _writeOutput( Future<void> _writeOutput(
String outputDir, Map<String, String> rendered, String outputDir,
OverrideSnapshot snapshot, bool isRefresh, Map<String, String> rendered,
OverrideSnapshot snapshot,
bool isRefresh,
) async { ) async {
for (final entry in rendered.entries) { for (final entry in rendered.entries) {
final file = File('$outputDir/${entry.key}'); final file = File('$outputDir/${entry.key}');
await file.parent.create(recursive: true); await file.parent.create(recursive: true);
if (isRefresh && file.existsSync()) { if (isRefresh && file.existsSync()) {
final merged = OverrideManager.mergeCustomSections( final merged = OverrideManager.mergeCustomSections(
newContent: entry.value, existingContent: await file.readAsString(), newContent: entry.value,
existingContent: await file.readAsString(),
); );
await file.writeAsString(merged); await file.writeAsString(merged);
} else { } else {
@@ -159,9 +202,35 @@ Future<void> _writeOutput(
await OverrideManager.restoreCustomFolder(snapshot, outputDir); await OverrideManager.restoreCustomFolder(snapshot, outputDir);
} }
String _defaultTemplateDir() { Future<void> _writeMetadataJson(String outputDir, ProjectBrief brief) async {
final scriptDir = Platform.script.toFilePath(); final path = p.join(outputDir, 'cursor-gen-metadata.json');
return scriptDir.replaceAll(RegExp(r'bin[/\\]cursor_gen\.dart$'), 'templates'); final file = File(path);
await file.parent.create(recursive: true);
final encoder = JsonEncoder.withIndent(' ');
await file.writeAsString(encoder.convert(brief.toMetadataMap()));
}
Future<String> _defaultTemplateDir() async {
final binDir = p.dirname(Platform.script.toFilePath());
final candidates = <String>[
// Monorepo layout: flutter-cursor-templates/generator/bin -> ../templates.
p.normalize(p.join(binDir, '..', '..', 'templates')),
// Published package layout: cursor_gen/templates.
p.normalize(p.join(binDir, '..', 'templates')),
];
final packageUri = await Isolate.resolvePackageUri(
Uri.parse('package:cursor_gen/src/renderer.dart'),
);
if (packageUri != null && packageUri.isScheme('file')) {
final packageRoot = p.dirname(p.dirname(packageUri.toFilePath()));
candidates.add(p.normalize(p.join(packageRoot, 'templates')));
}
return candidates.firstWhere(
(path) => Directory(path).existsSync(),
orElse: () => candidates.last,
);
} }
void _printBanner() { void _printBanner() {
@@ -8,7 +8,7 @@
"cursor_templates_version": { "cursor_templates_version": {
"type": "string", "type": "string",
"description": "Pillar 1: Pin to template version for reproducibility", "description": "Pillar 1: Pin to template version for reproducibility",
"examples": ["1.0.0"] "examples": ["1.0.1"]
}, },
"project": { "project": {
"type": "object", "type": "object",
@@ -91,6 +91,73 @@
"locales": { "type": "array", "items": { "type": "string" } } "locales": { "type": "array", "items": { "type": "string" } }
} }
}, },
"design": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["figma_mcp", "figma_manual", "native_ref", "html_ref", "none"]
},
"figma_url": { "type": "string" }
}
},
"api_docs": {
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": ["openapi", "postman", "markdown", "none"]
},
"path": { "type": "string" }
}
},
"references": {
"type": "object",
"description": "Other repos or local paths agents should treat as product context",
"properties": {
"repos": {
"type": "array",
"items": { "type": "string" },
"description": "Git remote URLs (https or ssh)"
},
"local_paths": {
"type": "array",
"items": { "type": "string" },
"description": "Repo-relative paths (e.g. monorepo packages) or other local references"
}
}
},
"features": {
"type": "object",
"properties": {
"modules": { "type": "array", "items": { "type": "string" } },
"special": { "type": "array", "items": { "type": "string" } }
}
},
"app_context": {
"type": "object",
"description": "Theme targets and optional RBAC labels — mirrored to cursor-gen-metadata.json on generate",
"properties": {
"theme_variants": {
"type": "array",
"items": {
"type": "string",
"enum": ["light", "dark", "high_contrast"]
},
"description": "Supported theme tokens; omit or leave empty to default to light + dark in the loader"
},
"roles_enabled": {
"type": "boolean",
"default": false,
"description": "When true, list concrete roles in role_names"
},
"role_names": {
"type": "array",
"items": { "type": "string" },
"description": "Product role identifiers when roles_enabled is true"
}
}
},
"telemetry_opt_in": { "telemetry_opt_in": {
"type": "boolean", "type": "boolean",
"description": "Pillar 6: Opt-in local telemetry for rule trigger analytics", "description": "Pillar 6: Opt-in local telemetry for rule trigger analytics",
@@ -0,0 +1,405 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: f0bb5d1648339c8308cc0b9838d8456b3cfe5c91f9dc1a735b4d003269e5da9a
url: "https://pub.dev"
source: hosted
version: "88.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "0b7b9c329d2879f8f05d6c05b32ee9ec025f39b077864bdb5ac9a7b63418a98f"
url: "https://pub.dev"
source: hosted
version: "8.1.1"
ansi_styles:
dependency: "direct main"
description:
name: ansi_styles
sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a"
url: "https://pub.dev"
source: hosted
version: "0.3.2+1"
args:
dependency: "direct main"
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
collection:
dependency: "direct main"
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
lints:
dependency: "direct dev"
description:
name: lints
sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290
url: "https://pub.dev"
source: hosted
version: "3.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
meta:
dependency: transitive
description:
name: meta
sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739
url: "https://pub.dev"
source: hosted
version: "1.18.2"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.17"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
yaml:
dependency: "direct main"
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.7.0 <4.0.0"
@@ -1,6 +1,6 @@
name: cursor_gen name: cursor_gen
description: A CLI tool that generates project-specific Cursor AI configurations for Flutter projects. description: A CLI tool that generates project-specific Cursor AI configurations for Flutter projects.
version: 1.0.0 version: 1.0.1
homepage: https://github.com/company/flutter-cursor-templates homepage: https://github.com/company/flutter-cursor-templates
environment: environment:
@@ -9,7 +9,6 @@ environment:
dependencies: dependencies:
args: ^2.4.2 args: ^2.4.2
yaml: ^3.1.2 yaml: ^3.1.2
json_schema: ^6.0.0
path: ^1.9.0 path: ^1.9.0
http: ^1.2.0 http: ^1.2.0
crypto: ^3.0.3 crypto: ^3.0.3
@@ -7,7 +7,7 @@ class BriefLoader {
final file = File(path); final file = File(path);
if (!file.existsSync()) { if (!file.existsSync()) {
throw FileSystemException('project-brief.yaml not found at $path. ' throw FileSystemException('project-brief.yaml not found at $path. '
'Run: dart run cursor_gen --wizard to create one.'); 'Run: cursor_gen --wizard to create one.');
} }
final content = await file.readAsString(); final content = await file.readAsString();
final yaml = loadYaml(content) as YamlMap; final yaml = loadYaml(content) as YamlMap;
@@ -19,14 +19,14 @@ class BriefLoader {
final design = yaml['design'] as YamlMap? ?? YamlMap(); final design = yaml['design'] as YamlMap? ?? YamlMap();
final apiDocs = yaml['api_docs'] as YamlMap? ?? YamlMap(); final apiDocs = yaml['api_docs'] as YamlMap? ?? YamlMap();
final refs = yaml['references'] as YamlMap? ?? YamlMap(); final refs = yaml['references'] as YamlMap? ?? YamlMap();
final appCtx = yaml['app_context'] as YamlMap? ?? YamlMap();
final features = yaml['features'] as YamlMap? ?? YamlMap(); final features = yaml['features'] as YamlMap? ?? YamlMap();
final l10n = yaml['localization'] as YamlMap? ?? YamlMap(); final l10n = yaml['localization'] as YamlMap? ?? YamlMap();
// Parse backends (can be "firebase+rest" shorthand or list) // Parse backends (can be "firebase+rest" shorthand or list)
final backendRaw = stack['backend']?.toString() ?? 'rest'; final backendRaw = stack['backend']?.toString() ?? 'rest';
final backends = backendRaw.contains('+') final backends =
? backendRaw.split('+') backendRaw.contains('+') ? backendRaw.split('+') : [backendRaw];
: [backendRaw];
return ProjectBrief( return ProjectBrief(
projectName: project['name']?.toString() ?? 'MyApp', projectName: project['name']?.toString() ?? 'MyApp',
@@ -67,6 +67,14 @@ class BriefLoader {
cursorTemplatesVersion: yaml['cursor_templates_version']?.toString(), cursorTemplatesVersion: yaml['cursor_templates_version']?.toString(),
telemetryOptIn: yaml['telemetry_opt_in'] as bool? ?? false, telemetryOptIn: yaml['telemetry_opt_in'] as bool? ?? false,
themeVariants: () {
final t = _toStringList(appCtx['theme_variants']);
if (t == null || t.isEmpty) return ['light', 'dark'];
return t;
}(),
rolesEnabled: appCtx['roles_enabled'] as bool? ?? false,
roleNames: _toStringList(appCtx['role_names']) ?? [],
); );
} }
@@ -52,6 +52,13 @@ class ProjectBrief {
// Telemetry opt-in (Pillar 6) // Telemetry opt-in (Pillar 6)
final bool telemetryOptIn; final bool telemetryOptIn;
/// Theme variants to support (subset of light, dark, high_contrast).
final List<String> themeVariants;
/// App-level RBAC: when true, [roleNames] should list concrete roles.
final bool rolesEnabled;
final List<String> roleNames;
const ProjectBrief({ const ProjectBrief({
required this.projectName, required this.projectName,
required this.packageId, required this.packageId,
@@ -80,7 +87,34 @@ class ProjectBrief {
required this.locales, required this.locales,
this.cursorTemplatesVersion, this.cursorTemplatesVersion,
this.telemetryOptIn = false, this.telemetryOptIn = false,
this.themeVariants = const ['light', 'dark'],
this.rolesEnabled = false,
this.roleNames = const [],
}); });
/// Local snapshot for tooling (written as `cursor-gen-metadata.json` under the output dir).
Map<String, dynamic> toMetadataMap({DateTime? generatedAt}) {
final at = (generatedAt ?? DateTime.now()).toUtc();
return {
'schema_version': 1,
'generated_at': at.toIso8601String(),
'project': {
'name': projectName,
'package': packageId,
'description': description,
'scale': scale,
},
'references': {
'repos': List<String>.from(referenceRepos),
'local_paths': List<String>.from(localPaths),
},
'app_context': {
'theme_variants': List<String>.from(themeVariants),
'roles_enabled': rolesEnabled,
'role_names': List<String>.from(roleNames),
},
};
}
} }
class ValidationResult { class ValidationResult {
@@ -23,7 +23,8 @@ class Renderer {
} }
var content = await file.readAsString(); var content = await file.readAsString();
content = _substituteAll(content, context); content = _substituteAll(content, context);
_checkUnreplacedPlaceholders(content, key); // Pillar 3: validate no broken {{VAR}} _checkUnreplacedPlaceholders(
content, key); // Pillar 3: validate no broken {{VAR}}
output[_outputPath(key)] = content; output[_outputPath(key)] = content;
} }
return output; return output;
@@ -47,7 +48,8 @@ class Renderer {
'AUTH': _displayName(brief.auth), 'AUTH': _displayName(brief.auth),
'AUTH_RAW': brief.auth, 'AUTH_RAW': brief.auth,
'PLATFORMS_LIST': brief.platforms.join(', '), 'PLATFORMS_LIST': brief.platforms.join(', '),
'CODEGEN_LIST': brief.codegenTools.isEmpty ? 'none' : brief.codegenTools.join(', '), 'CODEGEN_LIST':
brief.codegenTools.isEmpty ? 'none' : brief.codegenTools.join(', '),
'FLAVORS_LIST': brief.flavors.join(', '), 'FLAVORS_LIST': brief.flavors.join(', '),
'CICD_TOOL': _displayName(brief.cicd), 'CICD_TOOL': _displayName(brief.cicd),
'CICD_RAW': brief.cicd, 'CICD_RAW': brief.cicd,
@@ -59,11 +61,16 @@ class Renderer {
'FIGMA_URL': brief.figmaUrl, 'FIGMA_URL': brief.figmaUrl,
'API_DOCS_FORMAT': brief.apiDocsFormat, 'API_DOCS_FORMAT': brief.apiDocsFormat,
'API_DOCS_PATH': brief.apiDocsPath, 'API_DOCS_PATH': brief.apiDocsPath,
'REFERENCE_REPOS': brief.referenceRepos.join('\n- '), 'GIT_REFS_BLOCK': _gitRefsBlock(brief),
'LOCAL_PATHS_BLOCK': _localPathsBlock(brief),
'THEME_SUMMARY': _themeSummary(brief),
'ROLES_SUMMARY': _rolesSummary(brief),
'HIGH_CONTRAST_NOTE': _highContrastNote(brief),
'HIGH_CONTRAST_UX_LINE': _highContrastUxLine(brief),
'ARCH_IMPORT_RULES': _archImportRules(brief.architecture), 'ARCH_IMPORT_RULES': _archImportRules(brief.architecture),
'TEST_PATTERN': _testPattern(brief.stateManagement), 'TEST_PATTERN': _testPattern(brief.stateManagement),
'LOCALES_LIST': brief.locales.join(', '), 'LOCALES_LIST': brief.locales.join(', '),
'TEMPLATE_VERSION': '1.0.0', 'TEMPLATE_VERSION': '1.0.1',
}; };
} }
@@ -80,21 +87,17 @@ class Renderer {
if (matches.isNotEmpty) { if (matches.isNotEmpty) {
final vars = matches.map((m) => m.group(0)).toSet(); final vars = matches.map((m) => m.group(0)).toSet();
// Print warning but don't fail — let golden tests catch it // Print warning but don't fail — let golden tests catch it
stderr.writeln('⚠ Unreplaced placeholder(s) in $key: ${vars.join(", ")}'); stderr
.writeln('⚠ Unreplaced placeholder(s) in $key: ${vars.join(", ")}');
} }
} }
static String _templatePath(String templateSrc, String key) { static String _templatePath(String templateSrc, String key) {
// skills use SKILL.md.tmpl, everything else uses .mdc.tmpl
final ext = key.startsWith('skills/') ? 'SKILL.md.tmpl' : '.mdc.tmpl';
final dir = key.startsWith('skills/') ? p.dirname(key) : '';
if (key.startsWith('skills/')) { if (key.startsWith('skills/')) {
final skillName = p.basename(key); final skillName = p.basename(key);
return p.join(templateSrc, 'skills', skillName, 'SKILL.md.tmpl'); return p.join(templateSrc, 'skills', skillName, 'SKILL.md.tmpl');
} }
if (key.startsWith('hooks/')) { if (key.startsWith('hooks/')) {
final hookFile = p.basename(key).replaceAll('-', '.');
// special: hooks-json → hooks.json.tmpl // special: hooks-json → hooks.json.tmpl
if (key.endsWith('hooks-json')) { if (key.endsWith('hooks-json')) {
return p.join(templateSrc, 'hooks', 'hooks.json.tmpl'); return p.join(templateSrc, 'hooks', 'hooks.json.tmpl');
@@ -149,6 +152,56 @@ class Renderer {
return names[raw] ?? raw; return names[raw] ?? raw;
} }
static String _gitRefsBlock(ProjectBrief brief) {
if (brief.referenceRepos.isEmpty) {
return '_No Git repository URLs listed._ Add entries under `references.repos` in project-brief.yaml when other repos are part of the product context.';
}
return brief.referenceRepos.map((u) => '- $u').join('\n');
}
static String _localPathsBlock(ProjectBrief brief) {
if (brief.localPaths.isEmpty) {
return '_No local paths listed._ Add monorepo packages or sibling folders under `references.local_paths` in project-brief.yaml when relevant.';
}
return brief.localPaths.map((path) => '- `$path`').join('\n');
}
static String _themeSummary(ProjectBrief brief) {
if (brief.themeVariants.isEmpty) {
return '*(none specified — defaults not applied in brief; check YAML)*';
}
return brief.themeVariants.map(_themeLabel).join(', ');
}
static String _themeLabel(String token) {
switch (token) {
case 'high_contrast':
return 'high contrast';
default:
return token;
}
}
static String _rolesSummary(ProjectBrief brief) {
if (!brief.rolesEnabled) {
return 'Not enabled (`app_context.roles_enabled: false`).';
}
if (brief.roleNames.isEmpty) {
return 'Enabled but **no role names** — add `app_context.role_names` in project-brief.yaml.';
}
return brief.roleNames.join(', ');
}
static String _highContrastNote(ProjectBrief brief) {
if (!brief.themeVariants.contains('high_contrast')) return '';
return '\n- **High contrast:** validate contrast, borders, and focus in the high-contrast theme alongside light/dark (WCAG).\n';
}
static String _highContrastUxLine(ProjectBrief brief) {
if (!brief.themeVariants.contains('high_contrast')) return '';
return '\n- **High contrast theme:** validate loading, empty, and error states; never rely on color alone for meaning (use icons/text/semantics).';
}
static String _archImportRules(String arch) { static String _archImportRules(String arch) {
switch (arch) { switch (arch) {
case 'clean': case 'clean':
@@ -52,6 +52,16 @@ class Resolver {
files.add('rules/codegen/codegen-$tool'); files.add('rules/codegen/codegen-$tool');
} }
// ── Hooks (Pillar 4) — tied to codegen, not state_management ─────
if (brief.codegenTools.isNotEmpty) {
files.addAll([
'hooks/hooks-json',
'hooks/flutter-analyze',
'hooks/grind-tests',
'hooks/arch-guard',
]);
}
// ── Localization ────────────────────────────────────────────────── // ── Localization ──────────────────────────────────────────────────
if (brief.i18nEnabled) { if (brief.i18nEnabled) {
files.add('rules/i18n/localization'); files.add('rules/i18n/localization');
@@ -83,14 +93,6 @@ class Resolver {
files.add('agents/migration-agent'); files.add('agents/migration-agent');
} }
// ── Hooks ─────────────────────────────────────────────────────────
files.addAll([
'hooks/hooks-json',
'hooks/flutter-analyze',
'hooks/grind-tests',
'hooks/arch-guard',
]);
return files; return files;
} }
@@ -114,6 +116,9 @@ class Resolver {
if (key.contains('testing-e2e')) return 'testing.depth includes e2e'; if (key.contains('testing-e2e')) return 'testing.depth includes e2e';
if (key.contains('testing')) return 'Matches state_management testing patterns'; if (key.contains('testing')) return 'Matches state_management testing patterns';
if (key.contains('platform')) return 'Matches stack.platforms'; if (key.contains('platform')) return 'Matches stack.platforms';
if (key.startsWith('hooks/')) {
return 'stack.codegen non-empty — Cursor hooks for analyze, boundaries, and tests';
}
if (key.contains('codegen')) return 'Matches stack.codegen'; if (key.contains('codegen')) return 'Matches stack.codegen';
if (key.contains('i18n')) return 'localization.enabled: true'; if (key.contains('i18n')) return 'localization.enabled: true';
if (key.contains('migration')) return 'state_management is GetX — migration guidance included'; if (key.contains('migration')) return 'state_management is GetX — migration guidance included';
@@ -5,8 +5,6 @@ import 'dart:io';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'logger.dart'; import 'logger.dart';
const _telemetryFile = '.cursor/telemetry.json';
class Telemetry { class Telemetry {
/// Record a generation event (local-only, opt-in) /// Record a generation event (local-only, opt-in)
static Future<void> record({ static Future<void> record({
@@ -48,7 +46,8 @@ class Telemetry {
static Future<void> printReport({required String outputDir}) async { static Future<void> printReport({required String outputDir}) async {
final file = File(p.join(outputDir, 'telemetry.json')); final file = File(p.join(outputDir, 'telemetry.json'));
if (!file.existsSync()) { if (!file.existsSync()) {
Logger.warn('No telemetry data found. Ensure telemetry_opt_in: true in project-brief.yaml'); Logger.warn(
'No telemetry data found. Ensure telemetry_opt_in: true in project-brief.yaml');
return; return;
} }
final data = jsonDecode(await file.readAsString()) as Map<String, dynamic>; final data = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
@@ -65,15 +64,21 @@ class Telemetry {
final ruleCount = templates.where((t) => t.startsWith('rules/')).length; final ruleCount = templates.where((t) => t.startsWith('rules/')).length;
final agentCount = templates.where((t) => t.startsWith('agents/')).length; final agentCount = templates.where((t) => t.startsWith('agents/')).length;
final skillCount = templates.where((t) => t.startsWith('skills/')).length; final skillCount = templates.where((t) => t.startsWith('skills/')).length;
Logger.dim(' Rules: $ruleCount | Agents: $agentCount | Skills: $skillCount'); Logger.dim(
' Rules: $ruleCount | Agents: $agentCount | Skills: $skillCount');
} }
Logger.info('\n💡 Quarterly template quality review checklist:'); Logger.info('\n💡 Quarterly template quality review checklist:');
Logger.dim(' □ Review AI code review comments — which rules are most violated?'); Logger.dim(
Logger.dim('Ask teams: which agents are actually consulted vs. ignored?'); 'Review AI code review comments — which rules are most violated?');
Logger.dim(' □ Check if generated rules reduced hallucinations vs. last quarter'); Logger.dim(
Logger.dim('Identify brief combinations that produce the most AI errors'); 'Ask teams: which agents are actually consulted vs. ignored?');
Logger.dim(' □ Update templates based on feedback, bump version in CHANGELOG.md'); Logger.dim(
' □ Check if generated rules reduced hallucinations vs. last quarter');
Logger.dim(
' □ Identify brief combinations that produce the most AI errors');
Logger.dim(
' □ Update templates based on feedback, bump version in CHANGELOG.md');
} }
static Map<String, dynamic> _emptyData(String projectName) => { static Map<String, dynamic> _emptyData(String projectName) => {
@@ -81,6 +86,7 @@ class Telemetry {
'totalGenerations': 0, 'totalGenerations': 0,
'lastGeneratedAt': '', 'lastGeneratedAt': '',
'generations': [], 'generations': [],
'note': 'Local-only telemetry. Never sent anywhere. opt-in via telemetry_opt_in: true.', 'note':
'Local-only telemetry. Never sent anywhere. opt-in via telemetry_opt_in: true.',
}; };
} }
@@ -5,24 +5,59 @@ import 'package:yaml/yaml.dart';
import 'models.dart'; import 'models.dart';
class Validator { class Validator {
static const _validStateManagement = {'bloc', 'riverpod', 'getx', 'hooks_riverpod'}; static const _validStateManagement = {
static const _validArchitectures = {'clean', 'feature_first', 'mvvm', 'mvc', 'layered'}; 'bloc',
'riverpod',
'getx',
'hooks_riverpod'
};
static const _validArchitectures = {
'clean',
'feature_first',
'mvvm',
'mvc',
'layered'
};
static const _validRouting = {'gorouter', 'getx_nav', 'auto_route'}; static const _validRouting = {'gorouter', 'getx_nav', 'auto_route'};
static const _validBackends = {'firebase', 'supabase', 'rest'}; static const _validBackends = {'firebase', 'supabase', 'rest'};
static const _validAuth = {'firebase_auth', 'supabase_auth', 'jwt_rest', 'oauth2', 'none'}; static const _validAuth = {
'firebase_auth',
'supabase_auth',
'jwt_rest',
'oauth2',
'none'
};
static const _validPlatforms = {'ios', 'android', 'web', 'desktop'}; static const _validPlatforms = {'ios', 'android', 'web', 'desktop'};
static const _validCodegen = {'freezed', 'json_serializable', 'injectable', 'retrofit'}; static const _validCodegen = {
'freezed',
'json_serializable',
'injectable',
'retrofit'
};
static const _validScale = {'small', 'medium', 'large'}; static const _validScale = {'small', 'medium', 'large'};
static const _validTestingDepth = {'unit_widget', 'integration', 'e2e', 'full'}; static const _validTestingDepth = {
'unit_widget',
'integration',
'e2e',
'full'
};
static const _validCicd = {'codemagic', 'github_actions', 'fastlane', 'none'}; static const _validCicd = {'codemagic', 'github_actions', 'fastlane', 'none'};
static const _validE2eTools = {'patrol', 'maestro'}; static const _validE2eTools = {'patrol', 'maestro'};
static const _validDesignSource = {'figma_mcp', 'figma_manual', 'native_ref', 'html_ref', 'none'}; static const _validDesignSource = {
'figma_mcp',
'figma_manual',
'native_ref',
'html_ref',
'none'
};
static const _validApiFormats = {'openapi', 'postman', 'markdown', 'none'}; static const _validApiFormats = {'openapi', 'postman', 'markdown', 'none'};
static const _validThemeVariants = {'light', 'dark', 'high_contrast'};
static Future<ValidationResult> validateFile(String path) async { static Future<ValidationResult> validateFile(String path) async {
final file = File(path); final file = File(path);
if (!file.existsSync()) { if (!file.existsSync()) {
return ValidationResult(isValid: false, errors: ['File not found: $path']); return ValidationResult(
isValid: false, errors: ['File not found: $path']);
} }
final content = await file.readAsString(); final content = await file.readAsString();
final yaml = loadYaml(content) as YamlMap; final yaml = loadYaml(content) as YamlMap;
@@ -39,9 +74,15 @@ class Validator {
final project = yaml['project'] as YamlMap?; final project = yaml['project'] as YamlMap?;
final stack = yaml['stack'] as YamlMap?; final stack = yaml['stack'] as YamlMap?;
final envs = yaml['environments'] as YamlMap?;
final testing = yaml['testing'] as YamlMap?;
final design = yaml['design'] as YamlMap?;
final apiDocs = yaml['api_docs'] as YamlMap?;
final appCtx = yaml['app_context'] as YamlMap?;
if (project == null) { errors.add('Missing required section: project'); } if (project == null) {
else { errors.add('Missing required section: project');
} else {
if (project['name'] == null) errors.add('project.name is required'); if (project['name'] == null) errors.add('project.name is required');
if (project['package'] == null) errors.add('project.package is required'); if (project['package'] == null) errors.add('project.package is required');
if (project['scale'] != null && !_validScale.contains(project['scale'])) { if (project['scale'] != null && !_validScale.contains(project['scale'])) {
@@ -49,8 +90,9 @@ class Validator {
} }
} }
if (stack == null) { errors.add('Missing required section: stack'); } if (stack == null) {
else { errors.add('Missing required section: stack');
} else {
_validateField(stack, 'state_management', _validStateManagement, errors); _validateField(stack, 'state_management', _validStateManagement, errors);
_validateField(stack, 'architecture', _validArchitectures, errors); _validateField(stack, 'architecture', _validArchitectures, errors);
_validateField(stack, 'routing', _validRouting, errors); _validateField(stack, 'routing', _validRouting, errors);
@@ -61,7 +103,8 @@ class Validator {
if (platforms != null && platforms is YamlList) { if (platforms != null && platforms is YamlList) {
for (final p in platforms) { for (final p in platforms) {
if (!_validPlatforms.contains(p.toString())) { if (!_validPlatforms.contains(p.toString())) {
errors.add('stack.platforms contains unknown value: $p. Valid: ${_validPlatforms.join(", ")}'); errors.add(
'stack.platforms contains unknown value: $p. Valid: ${_validPlatforms.join(", ")}');
} }
} }
} }
@@ -71,7 +114,8 @@ class Validator {
if (codegen != null && codegen is YamlList) { if (codegen != null && codegen is YamlList) {
for (final c in codegen) { for (final c in codegen) {
if (!_validCodegen.contains(c.toString())) { if (!_validCodegen.contains(c.toString())) {
warnings.add('stack.codegen contains unknown value: $c. Known: ${_validCodegen.join(", ")}'); warnings.add(
'stack.codegen contains unknown value: $c. Known: ${_validCodegen.join(", ")}');
} }
} }
} }
@@ -79,13 +123,58 @@ class Validator {
// Cross-validation: GetX nav requires GetX SM // Cross-validation: GetX nav requires GetX SM
if (stack['routing']?.toString() == 'getx_nav' && if (stack['routing']?.toString() == 'getx_nav' &&
stack['state_management']?.toString() != 'getx') { stack['state_management']?.toString() != 'getx') {
warnings.add('stack.routing is getx_nav but state_management is not getx. This is unusual.'); warnings.add(
'stack.routing is getx_nav but state_management is not getx. This is unusual.');
} }
// Cross-validation: web platform warnings // Cross-validation: web platform warnings
final platformsList = (stack['platforms'] as YamlList?)?.map((e) => e.toString()).toList() ?? []; final platformsList = (stack['platforms'] as YamlList?)
?.map((e) => e.toString())
.toList() ??
[];
if (platformsList.contains('web')) { if (platformsList.contains('web')) {
warnings.add('Web platform detected: ensure you avoid dart:io imports (use dart:html or flutter_web_plugins)'); warnings.add(
'Web platform detected: ensure you avoid dart:io imports (use dart:html or flutter_web_plugins)');
}
}
if (envs != null) {
_validateField(envs, 'cicd', _validCicd, warnings,
prefix: 'environments');
}
if (testing != null) {
_validateField(testing, 'depth', _validTestingDepth, errors,
prefix: 'testing');
_validateField(testing, 'e2e_tool', _validE2eTools, warnings,
prefix: 'testing');
}
if (design != null) {
_validateField(design, 'source', _validDesignSource, warnings,
prefix: 'design');
}
if (apiDocs != null) {
_validateField(apiDocs, 'format', _validApiFormats, warnings,
prefix: 'api_docs');
}
if (appCtx != null) {
final themes = appCtx['theme_variants'];
for (final t in _yamlStrings(themes)) {
if (!_validThemeVariants.contains(t)) {
warnings.add(
'app_context.theme_variants contains unknown value: $t (expected: ${_validThemeVariants.join(", ")})');
}
}
if (appCtx['roles_enabled'] == true) {
final names = appCtx['role_names'];
final list = names is YamlList
? names
: names is List
? names
: const [];
if (list.isEmpty) {
warnings.add(
'app_context.roles_enabled is true but role_names is empty');
}
} }
} }
@@ -103,26 +192,51 @@ class Validator {
if (brief.projectName.isEmpty) errors.add('project.name is required'); if (brief.projectName.isEmpty) errors.add('project.name is required');
if (brief.packageId.isEmpty) errors.add('project.package is required'); if (brief.packageId.isEmpty) errors.add('project.package is required');
if (!_validStateManagement.contains(brief.stateManagement)) { if (!_validStateManagement.contains(brief.stateManagement)) {
errors.add('stack.state_management "${brief.stateManagement}" is not valid. Use: ${_validStateManagement.join(", ")}'); errors.add(
'stack.state_management "${brief.stateManagement}" is not valid. Use: ${_validStateManagement.join(", ")}');
} }
if (!_validArchitectures.contains(brief.architecture)) { if (!_validArchitectures.contains(brief.architecture)) {
errors.add('stack.architecture "${brief.architecture}" is not valid. Use: ${_validArchitectures.join(", ")}'); errors.add(
'stack.architecture "${brief.architecture}" is not valid. Use: ${_validArchitectures.join(", ")}');
} }
for (final b in brief.backends) { for (final b in brief.backends) {
if (!_validBackends.contains(b)) { if (!_validBackends.contains(b)) {
errors.add('stack.backend contains "$b". Valid values: ${_validBackends.join(", ")}'); errors.add(
'stack.backend contains "$b". Valid values: ${_validBackends.join(", ")}');
} }
} }
for (final t in brief.themeVariants) {
if (!_validThemeVariants.contains(t)) {
warnings.add(
'app_context.theme_variants contains unknown value: $t (expected: ${_validThemeVariants.join(", ")})');
}
}
if (brief.rolesEnabled && brief.roleNames.isEmpty) {
warnings.add('roles_enabled is true but role_names is empty');
}
return ValidationResult(isValid: errors.isEmpty, errors: errors, warnings: warnings); return ValidationResult(
isValid: errors.isEmpty, errors: errors, warnings: warnings);
}
static Iterable<String> _yamlStrings(dynamic value) sync* {
if (value == null) return;
if (value is YamlList) {
for (final e in value) yield e.toString();
} else if (value is List) {
for (final e in value) yield e.toString();
} else {
yield value.toString();
}
} }
static void _validateField( static void _validateField(
YamlMap map, String field, Set<String> valid, List<String> output, YamlMap map, String field, Set<String> valid, List<String> output,
) { {String prefix = 'stack'}) {
final val = map[field]?.toString(); final val = map[field]?.toString();
if (val != null && !valid.contains(val)) { if (val != null && !valid.contains(val)) {
output.add('stack.$field "$val" is not valid. Use: ${valid.join(", ")}'); output
.add('$prefix.$field "$val" is not valid. Use: ${valid.join(", ")}');
} }
} }
} }
@@ -8,7 +8,7 @@ import 'models.dart';
import 'logger.dart'; import 'logger.dart';
const _lockFileName = '.cursor-gen-lock.json'; const _lockFileName = '.cursor-gen-lock.json';
const _currentVersion = '1.0.0'; const _currentVersion = '1.0.1';
class VersionManager { class VersionManager {
/// Check if the project's locked version differs from the current template version /// Check if the project's locked version differs from the current template version
@@ -43,7 +43,7 @@ class VersionManager {
'codegenTools': brief.codegenTools, 'codegenTools': brief.codegenTools,
}, },
'note': 'Auto-generated by cursor_gen. Do not edit manually. ' 'note': 'Auto-generated by cursor_gen. Do not edit manually. '
'Run: dart run cursor_gen --check-updates to see available updates.', 'Run: cursor_gen --check-updates to see available updates.',
}; };
final file = File(p.join(outputDir, _lockFileName)); final file = File(p.join(outputDir, _lockFileName));
await file.parent.create(recursive: true); await file.parent.create(recursive: true);
@@ -66,7 +66,7 @@ class VersionManager {
} }
final lock = await readLock('.cursor'); final lock = await readLock('.cursor');
if (lock == null) { if (lock == null) {
Logger.warn('No lock file found. Run: dart run cursor_gen first.'); Logger.warn('No lock file found. Run: cursor_gen first.');
return; return;
} }
final lockedVersion = lock['templateVersion'] as String? ?? 'unknown'; final lockedVersion = lock['templateVersion'] as String? ?? 'unknown';
@@ -79,10 +79,12 @@ class VersionManager {
} else { } else {
Logger.warn(' ⚠ Update available!'); Logger.warn(' ⚠ Update available!');
Logger.info('\nTo update:'); Logger.info('\nTo update:');
Logger.info(' 1. Update cursor_templates_version in project-brief.yaml to "$_currentVersion"'); Logger.info(
Logger.info(' 2. Run: dart run cursor_gen --diff (preview changes)'); ' 1. Update cursor_templates_version in project-brief.yaml to "$_currentVersion"');
Logger.info(' 3. Run: dart run cursor_gen --refresh (apply updates)'); Logger.info(' 2. Run: cursor_gen --diff (preview changes)');
Logger.info('\nChangelog: see CHANGELOG.md in flutter-cursor-templates repo'); Logger.info(' 3. Run: cursor_gen --refresh (apply updates)');
Logger.info(
'\nChangelog: see CHANGELOG.md in flutter-cursor-templates repo');
} }
} }
@@ -7,57 +7,100 @@ class Wizard {
static Future<void> run({required String outputPath}) async { static Future<void> run({required String outputPath}) async {
Logger.info('\n🧙 cursor_gen Interactive Wizard'); Logger.info('\n🧙 cursor_gen Interactive Wizard');
Logger.info('' * 42); Logger.info('' * 42);
Logger.info('Answer the questions below to generate your project-brief.yaml.\n'); Logger.info(
'Answer the questions below to generate your project-brief.yaml.\n');
final answers = <String, dynamic>{}; final answers = <String, dynamic>{};
// Project basics // Project basics
answers['name'] = _ask('Project name', hint: 'ShopEasy'); answers['name'] = _ask('Project name', hint: 'ShopEasy');
answers['package'] = _ask('Package ID', hint: 'com.company.shopeasy'); answers['package'] = _ask('Package ID', hint: 'com.company.shopeasy');
answers['description'] = _ask('Short description', hint: 'E-commerce app with real-time inventory'); answers['description'] = _ask('Short description',
answers['scale'] = _askChoice('Project scale', ['small', 'medium', 'large'], defaultIdx: 1); hint: 'E-commerce app with real-time inventory');
answers['scale'] = _askChoice('Project scale', ['small', 'medium', 'large'],
defaultIdx: 1);
Logger.info(''); Logger.info('');
// Stack // Stack
answers['state_management'] = _askChoice('State management', answers['state_management'] = _askChoice(
['bloc', 'riverpod', 'getx', 'hooks_riverpod'], defaultIdx: 1); 'State management', ['bloc', 'riverpod', 'getx', 'hooks_riverpod'],
answers['architecture'] = _askChoice('Architecture', defaultIdx: 1);
['clean', 'feature_first', 'mvvm', 'mvc', 'layered'], defaultIdx: 0); answers['architecture'] = _askChoice(
answers['routing'] = _askChoice('Routing', 'Architecture', ['clean', 'feature_first', 'mvvm', 'mvc', 'layered'],
['gorouter', 'getx_nav', 'auto_route'], defaultIdx: 0); defaultIdx: 0);
answers['routing'] = _askChoice(
'Routing', ['gorouter', 'getx_nav', 'auto_route'],
defaultIdx: 0);
answers['backend'] = _askChoice('Backend(s)', answers['backend'] = _askChoice('Backend(s)',
['firebase', 'supabase', 'rest', 'firebase+rest', 'supabase+rest'], defaultIdx: 2); ['firebase', 'supabase', 'rest', 'firebase+rest', 'supabase+rest'],
defaultIdx: 2);
answers['auth'] = _askChoice('Auth method', answers['auth'] = _askChoice('Auth method',
['firebase_auth', 'supabase_auth', 'jwt_rest', 'oauth2', 'none'], defaultIdx: 4); ['firebase_auth', 'supabase_auth', 'jwt_rest', 'oauth2', 'none'],
defaultIdx: 4);
Logger.info(''); Logger.info('');
// Platforms (Pillar 4) // Platforms (Pillar 4)
answers['platforms'] = _askMultiChoice('Target platforms', answers['platforms'] = _askMultiChoice(
['ios', 'android', 'web', 'desktop'], defaults: [0, 1]); 'Target platforms', ['ios', 'android', 'web', 'desktop'],
defaults: [0, 1]);
// Codegen (Pillar 4) // Codegen (Pillar 4)
answers['codegen'] = _askMultiChoice('Code generation tools (optional)', answers['codegen'] = _askMultiChoice('Code generation tools (optional)', [
['freezed', 'json_serializable', 'injectable', 'retrofit'], defaults: []); 'freezed',
'json_serializable',
'injectable',
'retrofit'
], defaults: []);
Logger.info('');
// References & app UX
final reposRaw =
_ask('Git reference repo URLs (comma-separated, optional)', hint: '');
answers['reference_repos'] = _splitComma(reposRaw);
final localRaw =
_ask('Local reference paths (comma-separated, optional)', hint: '');
answers['local_paths'] = _splitComma(localRaw);
answers['theme_variants'] = _askMultiChoice(
'Theme variants to support',
['light', 'dark', 'high_contrast'],
defaults: [0, 1]);
final rolesOn =
_askBool('Does the app support user roles?', defaultYes: false);
answers['roles_enabled'] = rolesOn;
if (rolesOn) {
final raw =
_ask('Role names (comma-separated)', hint: 'admin,member,guest');
answers['role_names'] = _splitComma(raw);
} else {
answers['role_names'] = <String>[];
}
Logger.info(''); Logger.info('');
// Environments // Environments
final flavorsInput = _ask('Build flavors (comma-separated)', hint: 'dev,staging,prod'); final flavorsInput =
_ask('Build flavors (comma-separated)', hint: 'dev,staging,prod');
answers['flavors'] = flavorsInput.split(',').map((e) => e.trim()).toList(); answers['flavors'] = flavorsInput.split(',').map((e) => e.trim()).toList();
answers['cicd'] = _askChoice('CI/CD', ['github_actions', 'codemagic', 'fastlane', 'none'], defaultIdx: 0); answers['cicd'] = _askChoice(
'CI/CD', ['github_actions', 'codemagic', 'fastlane', 'none'],
defaultIdx: 0);
// Testing // Testing
answers['testing_depth'] = _askChoice('Testing depth', answers['testing_depth'] = _askChoice(
['unit_widget', 'integration', 'e2e', 'full'], defaultIdx: 0); 'Testing depth', ['unit_widget', 'integration', 'e2e', 'full'],
defaultIdx: 0);
// Localization // Localization
final l10n = _askBool('Enable localization / i18n?', defaultYes: false); final l10n = _askBool('Enable localization / i18n?', defaultYes: false);
answers['i18n'] = l10n; answers['i18n'] = l10n;
// Telemetry opt-in (Pillar 6) // Telemetry opt-in (Pillar 6)
final telemetry = _askBool('\nOpt in to local telemetry (rule trigger logging, stored locally)?', defaultYes: false); final telemetry = _askBool(
'\nOpt in to local telemetry (rule trigger logging, stored locally)?',
defaultYes: false);
answers['telemetry'] = telemetry; answers['telemetry'] = telemetry;
Logger.info(''); Logger.info('');
@@ -65,7 +108,7 @@ class Wizard {
final file = File(outputPath); final file = File(outputPath);
await file.writeAsString(yaml); await file.writeAsString(yaml);
Logger.success('✔ project-brief.yaml written to $outputPath'); Logger.success('✔ project-brief.yaml written to $outputPath');
Logger.info('\nNext: dart run cursor_gen --validate → dart run cursor_gen'); Logger.info('\nNext: cursor_gen --validate → cursor_gen');
} }
static String _ask(String label, {String hint = ''}) { static String _ask(String label, {String hint = ''}) {
@@ -75,7 +118,8 @@ class Wizard {
return input.isEmpty ? hint : input; return input.isEmpty ? hint : input;
} }
static String _askChoice(String label, List<String> options, {int defaultIdx = 0}) { static String _askChoice(String label, List<String> options,
{int defaultIdx = 0}) {
Logger.info(' $label:'); Logger.info(' $label:');
for (var i = 0; i < options.length; i++) { for (var i = 0; i < options.length; i++) {
final marker = i == defaultIdx ? '' : ''; final marker = i == defaultIdx ? '' : '';
@@ -85,11 +129,13 @@ class Wizard {
final input = stdin.readLineSync()?.trim() ?? ''; final input = stdin.readLineSync()?.trim() ?? '';
if (input.isEmpty) return options[defaultIdx]; if (input.isEmpty) return options[defaultIdx];
final idx = int.tryParse(input); final idx = int.tryParse(input);
if (idx != null && idx >= 1 && idx <= options.length) return options[idx - 1]; if (idx != null && idx >= 1 && idx <= options.length)
return options[idx - 1];
return options[defaultIdx]; return options[defaultIdx];
} }
static List<String> _askMultiChoice(String label, List<String> options, {List<int> defaults = const []}) { static List<String> _askMultiChoice(String label, List<String> options,
{List<int> defaults = const []}) {
Logger.info(' $label (comma-separated numbers, or leave blank):'); Logger.info(' $label (comma-separated numbers, or leave blank):');
for (var i = 0; i < options.length; i++) { for (var i = 0; i < options.length; i++) {
final marker = defaults.contains(i) ? '' : ''; final marker = defaults.contains(i) ? '' : '';
@@ -99,7 +145,8 @@ class Wizard {
stdout.write(' Choices [$defaultStr]: '); stdout.write(' Choices [$defaultStr]: ');
final input = stdin.readLineSync()?.trim() ?? ''; final input = stdin.readLineSync()?.trim() ?? '';
if (input.isEmpty) return defaults.map((d) => options[d]).toList(); if (input.isEmpty) return defaults.map((d) => options[d]).toList();
return input.split(',') return input
.split(',')
.map((s) => int.tryParse(s.trim())) .map((s) => int.tryParse(s.trim()))
.where((i) => i != null && i >= 1 && i <= options.length) .where((i) => i != null && i >= 1 && i <= options.length)
.map((i) => options[i! - 1]) .map((i) => options[i! - 1])
@@ -114,17 +161,35 @@ class Wizard {
return input == 'y' || input == 'yes'; return input == 'y' || input == 'yes';
} }
static List<String> _splitComma(String s) => s
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
static String _yamlQStringList(List<String> xs) {
if (xs.isEmpty) return '';
return xs.map((e) => '"${_yamlEsc(e)}"').join(', ');
}
static String _yamlEsc(String s) =>
s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
static String _buildYaml(Map<String, dynamic> a) { static String _buildYaml(Map<String, dynamic> a) {
final platforms = (a['platforms'] as List).map((p) => '"$p"').join(', '); final platforms = (a['platforms'] as List).map((p) => '"$p"').join(', ');
final codegen = (a['codegen'] as List).map((c) => '"$c"').join(', '); final codegen = (a['codegen'] as List).map((c) => '"$c"').join(', ');
final flavors = (a['flavors'] as List).map((f) => '"$f"').join(', '); final flavors = (a['flavors'] as List).map((f) => '"$f"').join(', ');
final refRepos = List<String>.from(a['reference_repos'] as List);
final refLocals = List<String>.from(a['local_paths'] as List);
final themes = List<String>.from(a['theme_variants'] as List);
final roles = List<String>.from(a['role_names'] as List);
return '''# project-brief.yaml — cursor_gen configuration return '''# project-brief.yaml — cursor_gen configuration
# Generated by cursor_gen --wizard # Generated by cursor_gen --wizard
# Run: dart run cursor_gen to generate .cursor/ # Run: cursor_gen to generate .cursor/
# Run: dart run cursor_gen --refresh to update after changes # Run: cursor_gen --refresh to update after changes
# Pillar 1: Pin to template version for reproducibility # Pillar 1: Pin to template version for reproducibility
cursor_templates_version: "1.0.0" cursor_templates_version: "1.0.1"
project: project:
name: "${a['name']}" name: "${a['name']}"
@@ -162,8 +227,13 @@ api_docs:
path: "" path: ""
references: references:
repos: [] repos: [${_yamlQStringList(refRepos)}]
local_paths: [] local_paths: [${_yamlQStringList(refLocals)}]
app_context:
theme_variants: [${_yamlQStringList(themes)}]
roles_enabled: ${a['roles_enabled']}
role_names: [${_yamlQStringList(roles)}]
features: features:
modules: [] modules: []
@@ -0,0 +1,34 @@
---
name: api-client-gen
description: "Generates type-safe API clients for {{PROJECT_NAME}} from {{API_DOCS_FORMAT}} spec. Ask: '@api-client-gen generate client for /products endpoint'"
model: claude-sonnet-4-20250514
context: auto
allowed-tools: [read_file, write_file, list_files]
---
You are an API client generator for **{{PROJECT_NAME}}**.
API docs: `{{API_DOCS_PATH}}` (format: {{API_DOCS_FORMAT}})
## Generation steps
1. Read the API spec at `{{API_DOCS_PATH}}`
2. For the requested endpoint(s), generate:
- Request DTO (`@JsonSerializable` or Freezed)
- Response DTO (`@JsonSerializable` or Freezed)
- Repository method with error handling
- Dio/Retrofit client method (if Retrofit in codegen)
## Output structure
```dart
// data/models/product_dto.dart
@freezed
class ProductDto with _$ProductDto {
factory ProductDto({...}) = _ProductDto;
factory ProductDto.fromJson(Map<String, dynamic> json) => _$ProductDtoFromJson(json);
}
// data/datasources/product_remote_datasource.dart
class ProductRemoteDataSource {
final Dio _dio;
Future<List<ProductDto>> getProducts() async { ... }
}
```
@@ -0,0 +1,51 @@
---
name: code-reviewer
description: "Reviews {{PROJECT_NAME}} code for {{STATE_MANAGEMENT}} patterns, {{ARCHITECTURE}} boundaries, and {{BACKEND}} usage. Ask: 'Review this code' or '@code-reviewer check PR'"
model: claude-opus-4-5
context: auto
allowed-tools: [read_file, list_files]
---
You are a senior Flutter engineer reviewing code for **{{PROJECT_NAME}}**.
## Your review checklist
### {{STATE_MANAGEMENT}} patterns
- Are state classes immutable and sealed?
- Is state management correctly separated from UI logic?
- Are streams/subscriptions properly cancelled in dispose()?
- Check for anti-patterns specific to {{STATE_MANAGEMENT}}
### {{ARCHITECTURE}} boundaries
{{ARCH_IMPORT_RULES}}
- Flag any violation of these import rules immediately
### {{BACKEND}} usage
- Are all exceptions caught and mapped to domain errors?
- Are streams vs futures used appropriately?
- Are connections/subscriptions disposed correctly?
### Security (always check)
- No hardcoded API keys or secrets
- No PII logged to console or crash reporters
- Sensitive data using flutter_secure_storage, not SharedPreferences
- All user inputs validated before sending to backend
### General Flutter
- `const` used where possible
- `dispose()` overridden for all controllers/subscriptions
- No `print()` in production paths
- Loading/empty/error states all handled
## Output format
For each issue found:
```
[SEVERITY: critical/major/minor] File:line — Issue description
WHY: Why this matters
FIX: Specific fix recommendation
```
Severity guide:
- **critical**: Security issue, data loss risk, crash potential
- **major**: Incorrect pattern, boundary violation, missing error handling
- **minor**: Style, naming, optimization opportunity
@@ -0,0 +1,42 @@
---
name: migration-agent
description: "Migrates GetX controllers to Riverpod Notifiers for {{PROJECT_NAME}}. Consult before adding any new GetX code — suggest Riverpod equivalent. Ask: '@migration-agent migrate [feature]'"
model: claude-opus-4-5
context: fork
allowed-tools: [read_file, write_file, list_files]
---
You are a Flutter migration specialist for **{{PROJECT_NAME}}**.
This project currently uses GetX ({{ARCHITECTURE}}). Your goal is incremental,
safe migration to Riverpod without breaking existing features.
## Migration mapping
| GetX | Riverpod equivalent |
|------|---------------------|
| `GetxController` with `.obs` | `AsyncNotifier` or `Notifier` |
| `Obx()` | `ref.watch()` in `ConsumerWidget` |
| `Get.find<Controller>()` | `ref.read(provider.notifier)` |
| `Get.toNamed()` | `context.go()` (after GoRouter migration) |
| `GetxController.onInit()` | `build()` method in `AsyncNotifier` |
| `GetxController.onClose()` | `ref.onDispose()` |
## Migration process (feature by feature)
1. **Read** the existing `GetxController` in full
2. **Write tests** for the existing GetX version first (if none exist)
3. **Map** `.obs` variables → state class fields
4. **Write** the new `AsyncNotifier` with equivalent logic
5. **Write tests** for the Riverpod version using `ProviderContainer`
6. **Migrate** the View: `GetView<C>` → `ConsumerWidget`, `Obx()` → `ref.watch()`
7. **Verify** all tests pass for both old and new
8. **Remove** GetX code from that feature
## When NOT to migrate (mark with TODO: MIGRATE-LATER)
- Controller shared across 5+ screens (high blast radius — plan separately)
- Feature ships in the next sprint (postpone — don't hold up a release)
- No tests exist AND you can't write them first (write GetX tests first)
## Output per migration
1. New Riverpod provider file
2. Updated ConsumerWidget screen file
3. Test file for the new provider
4. Diff showing what GetX code is removed
@@ -0,0 +1,47 @@
---
name: security-agent
description: "Deep security review for {{PROJECT_NAME}}. Consult for auth flows, payment screens, and sensitive data handling. Ask: '@security-agent review auth flow'"
model: claude-opus-4-5
context: fork
allowed-tools: [read_file, list_files]
---
You are a mobile security expert conducting a deep review for **{{PROJECT_NAME}}**.
> Note: This agent provides deep security analysis.
> The `security-standards.mdc` rule provides always-on enforcement.
> This agent is for detailed consultations on specific security concerns.
## Deep review focus areas
### Auth flow ({{AUTH}})
- Token storage: is `flutter_secure_storage` used for ALL tokens?
- Token refresh: is refresh handled atomically (no race condition)?
- Session expiry: does the app handle 401 gracefully without data loss?
- Certificate pinning: configured and tested?
### Data at rest
- SQLite/Hive encryption: sensitive DBs encrypted?
- Cache poisoning: cached API responses validated before use?
- Keychain/Keystore usage for cryptographic keys
### Network security
- All endpoints HTTPS — any http:// URLs?
- Certificate validation — any `badCertificateCallback: true`?
- Sensitive data in URL params/query strings?
- Request/response logging in production? (must be off)
### Code injection risks
- Dynamic code execution patterns
- WebView usage — JavaScript interface security
- Deep link parameter validation (no path traversal)
## Output format
For each finding:
```
[RISK: Critical/High/Medium/Low]
LOCATION: File / function
ISSUE: Detailed description
CVSS-like impact: Confidentiality/Integrity/Availability
REMEDIATION: Specific code fix
```
@@ -0,0 +1,33 @@
---
name: test-writer
description: "Writes {{STATE_MANAGEMENT}} unit tests for {{PROJECT_NAME}}. Ask: 'Write tests for [class]' or '@test-writer generate tests'"
model: claude-sonnet-4-20250514
context: auto
allowed-tools: [read_file, write_file, list_files]
---
You are a Flutter test engineer for **{{PROJECT_NAME}}** using **{{STATE_MANAGEMENT}}**.
## Test pattern to follow
```dart
{{TEST_PATTERN}}
```
## When asked to write tests:
1. Read the source file completely
2. Identify all public methods and state transitions
3. Write tests for:
- Happy path (successful operation)
- Error path (failure, exception handling)
- Edge cases (empty data, boundary values)
4. Use `mocktail` for all mocking
5. Follow `Given/When/Then` naming: `'given X, when Y, then emits Z'`
## File placement
- Unit tests: `test/features/[feature]/[file]_test.dart`
- Widget tests: `test/features/[feature]/[screen]_widget_test.dart`
- Coverage target: 80% minimum for business logic classes
## Output
Write the complete test file, ready to run. Include all imports.
After writing, run: `dart test path/to/test_file.dart`
@@ -0,0 +1,47 @@
---
name: ui-validator
description: "Validates {{PROJECT_NAME}} UI against design system and UX standards. Ask: 'Validate this screen' or '@ui-validator check'"
model: claude-sonnet-4-20250514
context: auto
allowed-tools: [read_file, list_files]
---
You are a UI/UX validator for **{{PROJECT_NAME}}**.
## Validate every screen for:
### State coverage ({{STATE_MANAGEMENT}})
- Loading state: shows shimmer (NOT spinner unless brief)
- Empty state: shows illustration + CTA (NOT blank screen)
- Error state: shows message + retry button (NOT toast-only)
- Data state: renders content correctly
### Accessibility
- All interactive widgets have semantic labels
- Minimum touch targets: 48×48dp
- Sufficient color contrast (4.5:1 minimum)
### Responsive layout
- No hardcoded pixel widths
- Tested at 375px and 414px viewport widths
- `SafeArea` used correctly on iOS
### Platform-specific ({{PLATFORMS_LIST}})
{{#if web}}
- No dart:io imports in web-targeted code
- PWA-compatible (no native-only APIs without fallbacks)
{{/if}}
{{#if ios}}
- Safe area respected (notch, Dynamic Island)
- iOS Human Interface Guidelines followed
{{/if}}
### Security (UI layer)
- No credentials shown in plaintext
- Sensitive screens wrapped with screenshot prevention
## Output
List each violation with:
- Location (file:widget)
- What's wrong
- How to fix it
@@ -0,0 +1,141 @@
#!/usr/bin/env ts-node
// arch-guard.ts — Pre-commit hook: enforces {{ARCHITECTURE}} import boundaries
// Generated for {{PROJECT_NAME}} ({{ARCHITECTURE}} architecture)
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
const RED = '\x1b[31m';
const YELLOW = '\x1b[33m';
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
interface ArchRule {
sourcePattern: RegExp;
forbiddenImports: RegExp[];
message: string;
}
// Architecture-specific rules for {{ARCHITECTURE}}
const ARCH_RULES: ArchRule[] = [
...getArchRules()
];
function getArchRules(): ArchRule[] {
const arch = '{{ARCH_RAW}}';
if (arch === 'clean') {
return [
{
sourcePattern: /features\/\w+\/domain\//,
forbiddenImports: [/features\/\w+\/data\//, /features\/\w+\/presentation\//],
message: 'domain/ must not import from data/ or presentation/',
},
{
sourcePattern: /features\/\w+\/presentation\//,
forbiddenImports: [/features\/\w+\/data\//],
message: 'presentation/ must not import from data/ directly (use domain interfaces)',
},
];
}
if (arch === 'feature_first') {
return [
{
// A feature file must not import from another feature
sourcePattern: /features\/(\w+)\//,
forbiddenImports: [], // checked dynamically below
message: 'Features must not import from other feature folders',
},
];
}
if (arch === 'mvvm') {
return [
{
sourcePattern: /viewmodels\//,
forbiddenImports: [/package:flutter\//, /widgets\//],
message: 'ViewModels must not import Flutter widgets — they must be plain Dart testable',
},
];
}
return [];
}
function getChangedDartFiles(): string[] {
try {
const result = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' });
return result.split('\n').filter(f => f.endsWith('.dart') && fs.existsSync(f));
} catch {
return [];
}
}
function checkFile(filePath: string): string[] {
const violations: string[] = [];
const content = fs.readFileSync(filePath, 'utf8');
const imports = content.match(/^import\s+'[^']+'/gm) ?? [];
for (const rule of ARCH_RULES) {
if (!rule.sourcePattern.test(filePath)) continue;
// Feature-first cross-feature check
if ('{{ARCH_RAW}}' === 'feature_first') {
const srcFeatureMatch = filePath.match(/features\/(\w+)\//);
if (srcFeatureMatch) {
const srcFeature = srcFeatureMatch[1];
for (const imp of imports) {
const impFeatureMatch = imp.match(/features\/(\w+)\//);
if (impFeatureMatch && impFeatureMatch[1] !== srcFeature) {
violations.push(
`${RED}ARCH VIOLATION${RESET} in ${filePath}:\n` +
` ${YELLOW}${rule.message}${RESET}\n` +
` Import: ${imp}\n` +
` Fix: Move shared code to core/ or shared/`
);
}
}
}
continue;
}
// Standard forbidden import check
for (const imp of imports) {
for (const forbidden of rule.forbiddenImports) {
if (forbidden.test(imp)) {
violations.push(
`${RED}ARCH VIOLATION${RESET} in ${filePath}:\n` +
` ${YELLOW}${rule.message}${RESET}\n` +
` Import: ${imp}`
);
}
}
}
}
return violations;
}
const changedFiles = getChangedDartFiles();
if (changedFiles.length === 0) {
console.log(`${GREEN}✔ arch-guard: no Dart files changed${RESET}`);
process.exit(0);
}
console.log(`🏛 arch-guard checking ${changedFiles.length} file(s) for {{ARCHITECTURE}} violations...`);
const allViolations: string[] = [];
for (const file of changedFiles) {
allViolations.push(...checkFile(file));
}
if (allViolations.length > 0) {
console.error(`\n${RED}✘ Architecture boundary violations detected:${RESET}\n`);
for (const v of allViolations) console.error(v + '\n');
console.error(`Total: ${allViolations.length} violation(s). Fix before committing.`);
process.exit(1);
}
console.log(`${GREEN}✔ arch-guard: no violations found${RESET}`);
@@ -0,0 +1,32 @@
#!/usr/bin/env ts-node
// flutter-analyze.ts — Pre-commit hook: runs dart analyze and dart format check
import { execSync } from 'child_process';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
function run(cmd: string): { stdout: string; code: number } {
try {
const stdout = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
return { stdout, code: 0 };
} catch (e: any) {
return { stdout: e.stdout ?? e.message, code: e.status ?? 1 };
}
}
console.log('🔍 Running flutter analyze...');
const analyze = run('flutter analyze --no-congratulate');
if (analyze.code !== 0) {
console.error(`${RED}✘ flutter analyze failed:\n${analyze.stdout}${RESET}`);
process.exit(1);
}
console.log('🎨 Checking dart format...');
const format = run('dart format --output=none --set-exit-if-changed lib/ test/');
if (format.code !== 0) {
console.error(`${RED}✘ Unformatted files detected. Run: dart format lib/ test/${RESET}`);
process.exit(1);
}
console.log(`${GREEN}✔ flutter analyze + dart format passed${RESET}`);
@@ -0,0 +1,16 @@
#!/usr/bin/env ts-node
// grind-tests.ts — Pre-push hook: runs flutter test
import { execSync } from 'child_process';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
console.log('🧪 Running flutter test...');
try {
execSync('flutter test --coverage', { stdio: 'inherit' });
console.log(`${GREEN}✔ All tests passed${RESET}`);
} catch {
console.error(`${RED}✘ Tests failed. Fix failing tests before pushing.${RESET}`);
process.exit(1);
}
@@ -0,0 +1,19 @@
{
"hooks": [
{
"name": "pre-commit: flutter analyze",
"command": "npx ts-node .cursor/hooks/flutter-analyze.ts",
"events": ["pre-commit"]
},
{
"name": "pre-commit: arch guard",
"command": "npx ts-node .cursor/hooks/arch-guard.ts",
"events": ["pre-commit"]
},
{
"name": "pre-push: run tests",
"command": "npx ts-node .cursor/hooks/grind-tests.ts",
"events": ["pre-push"]
}
]
}
@@ -0,0 +1,53 @@
---
description: "Clean Architecture conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Clean Architecture — {{PROJECT_NAME}}
## Layer structure
```
lib/features/[feature]/
├── domain/
│ ├── entities/ ← Pure Dart, no framework imports
│ ├── repositories/ ← Abstract interfaces only
│ └── usecases/ ← Single-responsibility business operations
├── data/
│ ├── datasources/ ← Remote (API) + Local (cache) implementations
│ ├── models/ ← DTOs with fromJson/toJson (can use Freezed)
│ └── repositories/ ← Implements domain/repositories interfaces
└── presentation/
├── bloc/ or notifiers/
├── pages/
└── widgets/
```
## Import rules (STRICTLY ENFORCED by arch-guard hook)
{{ARCH_IMPORT_RULES}}
## UseCase pattern
```dart
// One UseCase = one operation = one method
class GetProductsUseCase {
final ProductRepository _repository;
const GetProductsUseCase(this._repository);
Future<Either<AppError, List<Product>>> call(ProductFilter filter) =>
_repository.getProducts(filter);
}
```
## Entity rules
- Entities are pure Dart — zero Flutter or framework imports
- Entities are immutable — use `final` fields + factory constructors
- Entities NEVER have `fromJson`/`toJson` — that belongs in the data layer model
## Repository rules
- Domain defines the **interface** (abstract class)
- Data layer **implements** it
- Use `Either<Failure, T>` or `Result<T>` return types — never throw in domain
## Dependency injection
- Use `injectable` + `get_it` if `codegen` includes `injectable`
- All UseCases injected into BLoC/Notifier via constructor
- `DataSource → Repository → UseCase → Bloc` dependency direction
@@ -0,0 +1,49 @@
---
description: "Feature-First architecture conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Feature-First Architecture — {{PROJECT_NAME}}
## Folder structure
```
lib/
features/
auth/
auth_screen.dart
auth_provider.dart (or auth_bloc.dart)
auth_repository.dart
auth_model.dart
widgets/
login_form.dart
social_login_button.dart
home/
home_screen.dart
home_provider.dart
...
core/
di/ ← Dependency injection setup
network/ ← HTTP client, interceptors
storage/ ← Local storage abstraction
widgets/ ← Shared widgets used by 3+ features
theme/ ← App theme, colors, text styles
routing/ ← Router config
shared/
models/ ← Models shared across features
utils/ ← Pure utility functions
```
## Import rules (STRICTLY ENFORCED by arch-guard hook)
{{ARCH_IMPORT_RULES}}
## Feature isolation rules
- A feature folder is self-contained: its own screen, state, model, repository
- Features MUST NOT import from other feature folders
- Shared code MUST be extracted to `core/` or `shared/` before sharing
- The 3-feature rule: if 3+ features need the same widget → move it to `core/widgets/`
## File naming within a feature
- `[feature]_screen.dart` — the main screen widget
- `[feature]_provider.dart` — Riverpod providers (or `[feature]_bloc.dart`)
- `[feature]_repository.dart` — data fetching + caching
- `[feature]_model.dart` — data model (Freezed or plain Dart)
@@ -0,0 +1,17 @@
---
description: "Layered architecture conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Layered Architecture — {{PROJECT_NAME}}
## Layers (top → bottom)
```
Presentation → Service/BLoC → Repository → Data Source
```
## Rules
- Dependencies flow downward only — upper layers depend on lower layers
- Each layer communicates via interfaces (abstract classes)
- Data transformations happen at layer boundaries (DTOs → domain models)
{{ARCH_IMPORT_RULES}}
@@ -0,0 +1,20 @@
---
description: "MVC architecture conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# MVC Architecture — {{PROJECT_NAME}}
## Layer responsibilities
- **Model**: Data + business logic. Pure Dart.
- **View**: Renders UI, observes Controller state. No business logic.
- **Controller** (GetX): Connects Model ↔ View. Manages state transitions.
## Import rules
{{ARCH_IMPORT_RULES}}
## Controller rules
- Controllers are injected via `Binding`, never created in widgets
- One Controller per feature screen (not per widget)
- Controllers fetch data in `onInit()`, clean up in `onClose()`
- All reactive state marked with `.obs`
@@ -0,0 +1,37 @@
---
description: "MVVM architecture conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# MVVM Architecture — {{PROJECT_NAME}}
## Layer responsibilities
- **View** (Widget): Renders UI, dispatches user actions to ViewModel. Zero business logic.
- **ViewModel**: Holds UI state, calls Model/Repository, exposes streams/notifiers. Zero Flutter imports.
- **Model**: Plain Dart data structures + repository interfaces.
## Import rules
{{ARCH_IMPORT_RULES}}
## ViewModel rules
```dart
// ViewModel has NO Flutter imports — testable with plain dart test
class AuthViewModel extends ChangeNotifier {
final AuthRepository _repository;
AuthViewModel(this._repository);
AuthViewState _state = const AuthViewState.initial();
AuthViewState get state => _state;
Future<void> login(String email, String password) async {
_state = const AuthViewState.loading();
notifyListeners();
final result = await _repository.login(email, password);
_state = result.fold(
(error) => AuthViewState.error(error.message),
(user) => AuthViewState.success(user),
);
notifyListeners();
}
}
```
@@ -0,0 +1,49 @@
---
description: "Firebase conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Firebase Standards — {{PROJECT_NAME}}
## Firestore
- Collection names: `camelCase` plural — `users`, `products`, `orderItems`
- Document IDs: use Firebase auto-IDs unless a natural key exists
- **Streams vs Futures**: use `snapshots()` for live data, `get()` for one-time reads
- Always handle `FirebaseException` explicitly — catch by `e.code` not generic `Exception`
- Paginate large collections with `startAfterDocument` — never fetch unbounded collections
```dart
// ✅ Stream-based real-time listener
Stream<List<Product>> watchProducts() {
return _firestore.collection('products')
.where('isActive', isEqualTo: true)
.snapshots()
.map((snap) => snap.docs.map(Product.fromDoc).toList());
}
// ✅ Handle FirebaseException by code
try {
await _firestore.collection('orders').add(order.toMap());
} on FirebaseException catch (e) {
switch (e.code) {
case 'permission-denied': throw AppError.authError('Insufficient permissions');
case 'unavailable': throw AppError.networkError();
default: throw AppError.unknown(e);
}
}
```
## Firebase Auth
- Always use `authStateChanges()` stream — never cache auth state locally
- Handle all error codes: `user-not-found`, `wrong-password`, `email-already-in-use`, `network-request-failed`
- Sign-out: clear all local state AND call `FirebaseAuth.instance.signOut()`
## Cloud Functions
- Call via `FirebaseFunctions.instance.httpsCallable('functionName')`
- Handle `FirebaseFunctionsException` with `.code` and `.message`
- Never expose internal errors to client — functions return structured error responses
## Security (complement to security-standards.mdc)
- Firestore Security Rules must be tested with the emulator before deploying
- No `allow read, write: if true` — even in development
- Rule coverage: every collection must have explicit rules
@@ -0,0 +1,25 @@
---
description: "Real-time feature conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Real-time Features — {{PROJECT_NAME}}
## Connection management
- Always expose a `connectionState` stream — UI must show "offline" indicator
- Implement exponential backoff for reconnection (1s, 2s, 4s, 8s, max 60s)
- Cancel all subscriptions in `dispose()` — memory leaks are the #1 bug in real-time apps
## Offline-first strategy
- Cache last known state locally (Hive, Drift, or Isar)
- Show stale data with a "last updated" timestamp while reconnecting
- Queue mutations offline, replay on reconnect (use `connectivity_plus`)
## WebSocket / SSE
- Use `web_socket_channel` for WebSocket — never raw `dart:io` WebSocket
- Implement heartbeat/ping to detect dead connections
- Parse and validate all incoming messages — never trust raw server data
## UI indicators
- Show a persistent banner when offline: "You're offline — changes will sync when reconnected"
- Animate the banner away on reconnection — don't just hide it abruptly
@@ -0,0 +1,52 @@
---
description: "REST API conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# REST API Standards — {{PROJECT_NAME}}
## HTTP client setup (Dio)
```dart
// In core/network/dio_client.dart
Dio createDioClient({required AppConfig config}) {
final dio = Dio(BaseOptions(
baseUrl: config.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
));
dio.interceptors.addAll([
AuthInterceptor(tokenStorage: getIt<TokenStorage>()),
LoggingInterceptor(), // debug builds only
RetryInterceptor(dio), // 3 retries on network errors
]);
return dio;
}
```
## DTOs (Data Transfer Objects)
- DTOs live in the `data/` layer — **never** pass raw JSON maps to the domain layer
- Use Freezed for DTOs if `codegen` includes `json_serializable` or `freezed`
- `fromJson` factory must handle null fields gracefully — API response fields are not guaranteed
## Error handling
```dart
// Map HTTP errors → AppError domain types
AppError _mapDioError(DioException e) => switch (e.type) {
DioExceptionType.connectionTimeout => const NetworkError(statusCode: null),
DioExceptionType.receiveTimeout => const NetworkError(statusCode: null),
DioExceptionType.badResponse => _mapStatusCode(e.response?.statusCode),
DioExceptionType.connectionError => const NetworkError(statusCode: null),
_ => UnknownError(e),
};
```
## API versioning
- Base URL includes version: `https://api.{{PROJECT_NAME}}.com/v1/`
- When upgrading API version, keep old version working until all clients migrate
## Auth token interceptor
- Inject `Authorization: Bearer <token>` automatically on every request
- On 401: refresh token once, retry original request, then logout if refresh fails
- On 403: map to `AppError.authError('Insufficient permissions')`
@@ -0,0 +1,52 @@
---
description: "Supabase conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Supabase Standards — {{PROJECT_NAME}}
## Row Level Security (RLS) awareness
- **ALWAYS** assume RLS is enabled — never write queries that assume full table access
- Test queries in the Supabase dashboard before implementing in Flutter
- If a query returns empty unexpectedly, check RLS policies first
## Queries
```dart
// ✅ Type-safe query with error handling
Future<List<Product>> getProducts() async {
final response = await _supabase
.from('products')
.select('id, name, price, category_id, categories(name)')
.eq('is_active', true)
.order('created_at', ascending: false)
.limit(50);
return response.map(Product.fromMap).toList();
}
```
## Realtime subscriptions
```dart
StreamSubscription<List<Map<String, dynamic>>>? _sub;
void watchOrders(String userId) {
_sub = _supabase
.from('orders')
.stream(primaryKey: ['id'])
.eq('user_id', userId)
.listen(
(data) => _updateOrders(data),
onError: (e) => _handleError(e),
);
}
@override
void dispose() {
_sub?.cancel(); // ALWAYS cancel in dispose()
super.dispose();
}
```
## Auth session
- Use `supabase.auth.onAuthStateChange` stream — never poll auth state
- Persist session: `supabase-flutter` handles this automatically via secure storage
- `session.accessToken` expires — check `session.isExpired` before sensitive operations
@@ -0,0 +1,64 @@
---
description: "Freezed code generation conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Freezed Standards — {{PROJECT_NAME}}
## When to use Freezed
- All domain entities and data models — use `@freezed`
- Union types / sealed classes — use `@freezed` with multiple constructors
- BLoC states and events — use `@freezed`
## Model pattern
```dart
@freezed
class Product with _$Product {
const factory Product({
required String id,
required String name,
required double price,
@Default(0) int stockCount,
String? imageUrl,
}) = _Product;
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
}
```
## Union type pattern
```dart
@freezed
sealed class ProductState with _$ProductState {
const factory ProductState.initial() = ProductInitial;
const factory ProductState.loading() = ProductLoading;
const factory ProductState.loaded(List<Product> products) = ProductLoaded;
const factory ProductState.error(String message) = ProductError;
}
// Usage — exhaustive switch
final widget = state.when(
initial: () => const SizedBox.shrink(),
loading: () => const ProductShimmer(),
loaded: (products) => ProductList(products: products),
error: (msg) => ErrorWidget(message: msg),
);
```
## Critical rules
- **NEVER** edit `.freezed.dart` or `.g.dart` files — they are generated
- Run `dart run build_runner build --delete-conflicting-outputs` after changes
- Run `dart run build_runner watch` during development
- Add `*.freezed.dart` and `*.g.dart` to `.gitignore` (or commit them — team must decide)
- Always define `copyWith` via Freezed — never write manual `copyWith`
## Patterns to avoid
```dart
// ❌ Manual copyWith — replaced by Freezed
Product copyWith({String? name, double? price}) => Product(
id: id, name: name ?? this.name, price: price ?? this.price,
);
// ✅ Let Freezed generate it
product.copyWith(name: 'New Name', price: 9.99)
```
@@ -0,0 +1,42 @@
---
description: "Injectable dependency injection conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Injectable (get_it) Standards — {{PROJECT_NAME}}
## Setup
```dart
// lib/core/di/injection.dart
@InjectableInit()
void configureDependencies() => getIt.init();
```
## Annotations
```dart
@singleton // One instance for app lifetime
@lazySingleton // Created on first access (preferred for most services)
@injectable // New instance each time (use sparingly)
@factoryMethod // Custom factory logic
```
## Example
```dart
@lazySingleton
class ProductRepository {
final DioClient _client;
const ProductRepository(this._client); // Constructor injection
}
@injectable
class GetProductsUseCase {
final ProductRepository _repo;
const GetProductsUseCase(this._repo);
}
```
## Rules
- Run `dart run build_runner build` after adding/modifying `@injectable` annotations
- **NEVER** use `getIt<T>()` in widget `build()` methods — inject via constructor or provider
- Use `@module` for third-party registrations (Dio, SharedPreferences, etc.)
- Register environment-specific implementations with `@Environment('dev')` / `@Environment('prod')`
@@ -0,0 +1,37 @@
---
description: "json_serializable conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# json_serializable Standards — {{PROJECT_NAME}}
## Model annotation
```dart
@JsonSerializable(explicitToJson: true) // explicitToJson for nested objects
class ProductDto {
final String id;
final String name;
@JsonKey(name: 'unit_price') // snake_case API → camelCase Dart
final double unitPrice;
@JsonKey(defaultValue: false)
final bool isActive;
final DateTime createdAt; // auto-converted from ISO 8601 string
const ProductDto({
required this.id,
required this.name,
required this.unitPrice,
required this.isActive,
required this.createdAt,
});
factory ProductDto.fromJson(Map<String, dynamic> json) => _$ProductDtoFromJson(json);
Map<String, dynamic> toJson() => _$ProductDtoToJson(this);
}
```
## Critical rules
- **NEVER** edit `*.g.dart` files
- Use `@JsonKey(defaultValue: ...)` for nullable API fields — API contracts change
- Use `explicitToJson: true` whenever the model has nested objects
- Null safety: API fields not guaranteed to be non-null should be `String?` not `String`
@@ -0,0 +1,30 @@
---
description: "Retrofit (Dio) API client conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Retrofit Standards — {{PROJECT_NAME}}
## API client definition
```dart
@RestApi()
abstract class ProductApiClient {
factory ProductApiClient(Dio dio, {String? baseUrl}) = _ProductApiClient;
@GET('/products')
Future<List<ProductDto>> getProducts(@Query('category') String? category);
@GET('/products/{id}')
Future<ProductDto> getProduct(@Path('id') String id);
@POST('/products')
Future<ProductDto> createProduct(@Body() CreateProductDto dto);
}
```
## Rules
- **NEVER** edit `*.g.dart` files
- Run `dart run build_runner build` after modifying API client
- All DTOs used in Retrofit must have `fromJson`/`toJson` (via `json_serializable` or Freezed)
- Handle `DioException` in the repository layer — never let it reach the presentation layer
- Use `@Headers({'Content-Type': 'application/json'})` at class level, not per-method
@@ -0,0 +1,67 @@
---
description: "Global error handling strategy for {{PROJECT_NAME}} — always applied"
alwaysApply: true
---
# Global Error Handling — {{PROJECT_NAME}}
## Flutter error boundaries
Configure in `main.dart` — do this ONCE and never bypass it:
```dart
void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
// TODO: Send to crash reporter (Sentry/Firebase Crashlytics)
crashReporter.recordFlutterError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
// TODO: Send to crash reporter
crashReporter.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}
```
## Error types hierarchy
Define a sealed class for domain errors — never throw raw exceptions in business logic:
```dart
sealed class AppError {
const AppError();
}
class NetworkError extends AppError { final int? statusCode; const NetworkError({this.statusCode}); }
class AuthError extends AppError { final String reason; const AuthError(this.reason); }
class NotFoundError extends AppError { final String resource; const NotFoundError(this.resource); }
class UnknownError extends AppError { final Object cause; const UnknownError(this.cause); }
```
## Repository layer
- Wrap ALL external calls in try/catch and return `Either<AppError, T>` or `Result<T>`
- NEVER let raw exceptions bubble to the presentation layer
- Log at the repository layer, not the UI layer
## Presentation layer
- Every async widget MUST handle error state explicitly — no silent failures
- Show user-friendly error messages: map `AppError` subtype → readable string
- Provide a "Try again" action for recoverable errors (network, timeout)
- For fatal errors (auth expired), redirect to login — never show a dead screen
## Crash reporting
- Integrate Sentry or Firebase Crashlytics before first TestFlight / Play beta
- Set `user` context on crash reporter after login (id only, no PII)
- Add `breadcrumbs` for key user actions to aid reproduction
## Logging strategy
```
Level | Use case
DEBUG | Development only (strip from release)
INFO | Key user flows (login, purchase, etc.)
WARNING | Recoverable errors, fallbacks used
ERROR | Unrecoverable errors, unexpected states
```
- Use `logger` package — never bare `print()`
- Logger instance per class: `final _log = Logger('ClassName');`
@@ -0,0 +1,54 @@
---
description: "Localization / i18n conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Localization Standards — {{PROJECT_NAME}}
## Supported locales: {{LOCALES_LIST}}
## Setup
- Use Flutter's built-in `AppLocalizations` (generated from `.arb` files)
- ARB files: `lib/l10n/app_en.arb`, `lib/l10n/app_fr.arb`, etc.
- Never hardcode user-facing strings — always use `context.l10n.stringKey`
## AppLocalizations access
```dart
// In widgets:
final l10n = AppLocalizations.of(context)!;
Text(l10n.welcomeMessage) // ✅
// Extension for convenience:
extension LocalizationX on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this)!;
}
Text(context.l10n.welcomeMessage) // ✅
```
## ARB file format
```json
{
"welcomeMessage": "Welcome back, {name}!",
"@welcomeMessage": {
"description": "Shown on home screen after login",
"placeholders": {
"name": { "type": "String", "example": "Alice" }
}
},
"itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
"@itemCount": {
"placeholders": {
"count": { "type": "int" }
}
}
}
```
## Rules
- **NEVER** hardcode user-facing strings as string literals in widget files
- **Technical keys** (route paths, storage/analytics keys, JSON fields): one shared module (e.g. `lib/core/constants/app_strings.dart`) — no duplicated literals across features
- All new strings added to ALL locale ARB files simultaneously — broken translations break builds
- Use ICU message format for plurals and gendered strings
- Date/time formatting: use `intl` package `DateFormat` — never `date.toString()`
- Currency: use `NumberFormat.currency(locale: locale)` — never manual formatting
- RTL support: wrap with `Directionality` where needed; test with Arabic locale
@@ -0,0 +1,34 @@
---
description: "Android-specific conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Android Platform Standards — {{PROJECT_NAME}}
## Target SDK
- `compileSdkVersion` / `targetSdkVersion`: 34 (Android 14) minimum for new apps
- `minSdkVersion`: 21 (Android 5.0) unless brief specifies otherwise
- Update `android/app/build.gradle` when bumping target SDK
## Permissions
- Declare only needed permissions in `AndroidManifest.xml`
- Runtime permissions: use `permission_handler` — never skip rationale step
- Android 13+: granular media permissions (`READ_MEDIA_IMAGES` not `READ_EXTERNAL_STORAGE`)
- Android 14+: `FOREGROUND_SERVICE_TYPE` required for foreground services
## Adaptive icons
- Provide both foreground and background layers in `android/app/src/main/res/`
- Test on dark theme, coloured theme, and themed icons (Android 13+)
## Deep links / App Links
- Verify domain ownership: `.well-known/assetlinks.json` on your server
- Test with: `adb shell am start -a android.intent.action.VIEW -d "https://{{PROJECT_NAME}}.com/products/123"`
## ProGuard / R8
- Obfuscation rules in `android/app/proguard-rules.pro`
- Keep rules for: Dio, Freezed models, `@JsonKey` annotated classes
- Test release build thoroughly — obfuscation can break reflection-based code
## Notifications
- Create notification channels before showing any notification (Android 8+)
- Notification icons must be monochrome on Android 5+
@@ -0,0 +1,36 @@
---
description: "Flutter Desktop conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Flutter Desktop Standards — {{PROJECT_NAME}}
## Window management
- Use `window_manager` package for window title, size, minimize/maximize controls
- Set minimum window size to prevent unusable UI: `windowManager.setMinimumSize(const Size(800, 600))`
- Remember window position/size across sessions using `shared_preferences`
## Keyboard shortcuts
- All primary actions must have keyboard shortcuts
- Use `Shortcuts` widget + `Actions` widget for app-wide shortcuts
- Follow platform conventions: `Cmd+S` (macOS), `Ctrl+S` (Windows/Linux) for save
## Context menus
- Right-click context menus: use `ContextMenuRegion` widget
- Desktop users expect right-click everywhere
## Platform-specific behavior
```dart
if (Platform.isMacOS) {
// macOS: use mac_window_manager for traffic lights placement
} else if (Platform.isWindows) {
// Windows: custom title bar with min/max/close buttons
} else if (Platform.isLinux) {
// Linux: respect window manager decorations
}
```
## File system
- `path_provider` provides platform-appropriate directories
- `file_picker` for open/save dialogs — never hardcode paths
- Handle file permission errors gracefully (especially on macOS with sandboxing)
@@ -0,0 +1,35 @@
---
description: "iOS-specific conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# iOS Platform Standards — {{PROJECT_NAME}}
## Platform-specific imports
- Use `dart:io` checks: `if (Platform.isIOS)` for conditional code
- iOS-only plugins: declare in `pubspec.yaml` with platform filter
- **NEVER** call `dart:io` directly in shared code — use `platform_channel` or `universal_io`
## iOS-specific requirements
- Minimum deployment target: iOS 13.0 (or as specified in `ios/Podfile`)
- Privacy manifests: `ios/Runner/PrivacyInfo.xcprivacy` — required for App Store since 2024
- Required `Info.plist` keys before using:
- Camera: `NSCameraUsageDescription`
- Photo library: `NSPhotoLibraryUsageDescription`
- Location: `NSLocationWhenInUseUsageDescription`
- Notifications: handled via `permission_handler`
## Push notifications (iOS)
- Configure APNs certificates in Xcode signing & capabilities
- Request permission with `permission_handler` — show rationale screen first
- Handle foreground vs background vs terminated app states separately
- Test on a physical device — iOS simulator does not support push
## Safe area
- Always wrap root scaffold with `SafeArea` or use `MediaQuery.of(context).padding`
- Dynamic Island / notch: test on iPhone 14 Pro and iPhone 15 Pro simulators
## App Store compliance
- Screenshot: use `ScreenshotController` to exclude sensitive screens
- Sign in with Apple: required if any third-party social login is offered
- IPv6 compatibility required — no IPv4-only network code
@@ -0,0 +1,45 @@
---
description: "Flutter Web conventions for {{PROJECT_NAME}} — Pillar 4"
alwaysApply: true
---
# Flutter Web Standards — {{PROJECT_NAME}}
## Critical: No dart:io on web
- **NEVER** import `dart:io` in shared code — it crashes on web
- Use `dart:html` or `universal_io` for platform-specific I/O
- Use `path_provider` alternatives: `universal_html` for web file access
- Check: `kIsWeb` constant before any platform-specific code
```dart
// ✅ Platform-safe
import 'package:universal_io/io.dart';
// ❌ Crashes on web
import 'dart:io';
```
## Web rendering
- Choose renderer carefully:
- `canvaskit` — pixel-perfect, larger initial load, better for graphics
- `html` — smaller load, uses DOM elements, inconsistent rendering
- Set in `index.html`: `flutterWebRenderer: "canvaskit"`
## PWA requirements
- Update `web/manifest.json`: name, icons (192×192, 512×512), theme_color
- Service worker: configure for offline caching of app shell
- Test with Chrome DevTools → Lighthouse → PWA audit
## Web-specific rendering caveats
- `BackdropFilter` has limited support on `html` renderer
- `Canvas` operations differ between renderers — test both
- Text selection differs — use `SelectableText` not `Text` where appropriate
- Scrollbars appear automatically on web — style or hide with `ScrollbarTheme`
## URL strategy
- Use `usePathUrlStrategy()` in `main.dart` for clean URLs (no `#`)
- Configure server to redirect all paths to `index.html` (SPA routing)
## Performance
- Lazy-load routes — use `GoRouter` deferred loading
- Initial load budget: < 3MB (canvaskit) or < 1MB (html renderer)
@@ -0,0 +1,31 @@
---
description: "Auto Route conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Auto Route Standards — {{PROJECT_NAME}}
## Route definitions
```dart
@AutoRouterConfig()
class AppRouter extends $AppRouter {
@override
List<AutoRoute> get routes => [
AutoRoute(page: HomeRoute.page, initial: true),
AutoRoute(page: ProductRoute.page, path: '/products/:id'),
AutoRoute(page: LoginRoute.page, guards: [AuthGuard]),
];
}
```
## Navigation
```dart
context.router.push(ProductRoute(id: product.id));
context.router.pop();
context.router.replace(HomeRoute());
```
## Rules
- Always use typed `Route` classes — never string paths
- Guards implement `AutoRouteGuard`
- **NEVER** use `Navigator.push` directly
@@ -0,0 +1,32 @@
---
description: "GetX Navigation conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# GetX Navigation — {{PROJECT_NAME}}
## Named routes
```dart
// app_pages.dart — central route definitions
abstract class AppPages {
static const initial = Routes.home;
static final routes = [
GetPage(name: Routes.home, page: () => const HomeView(), binding: HomeBinding()),
GetPage(name: Routes.product, page: () => const ProductView(), binding: ProductBinding()),
];
}
// Navigate — always use named routes
Get.toNamed(Routes.product, arguments: product); // push
Get.offAllNamed(Routes.home); // replace stack
Get.back(); // pop
```
## Bindings
- Every route has a `Binding` class that creates and injects dependencies
- **NEVER** use `Get.put()` in a widget — only in Bindings
- Use `Get.lazyPut()` for deferred creation
## Rules
- **NEVER** use `Navigator.push/pop`
- All route strings in `lib/core/routing/routes.dart` as constants
@@ -0,0 +1,61 @@
---
description: "GoRouter conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# GoRouter Standards — {{PROJECT_NAME}}
## Typed routes (mandatory)
```dart
// Define typed routes — never use string paths directly in navigation calls
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
const HomeRoute();
@override Widget build(BuildContext ctx, GoRouterState state) => const HomeScreen();
}
@TypedGoRoute<ProductRoute>(path: '/products/:id')
class ProductRoute extends GoRouteData {
final String id;
const ProductRoute({required this.id});
@override Widget build(BuildContext ctx, GoRouterState state) => ProductScreen(id: id);
}
// Navigate with type safety
const ProductRoute(id: product.id).go(context); // ✅
context.go('/products/${product.id}'); // ❌ — don't do this
```
## Auth guard
```dart
// Redirect logic in router config
redirect: (context, state) {
final isLoggedIn = ref.read(authProvider).isAuthenticated;
final isLoginRoute = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoginRoute) return '/login';
if (isLoggedIn && isLoginRoute) return '/';
return null;
},
```
## Shell routes for bottom navigation
```dart
ShellRoute(
builder: (ctx, state, child) => MainScaffold(child: child),
routes: [
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
],
)
```
## Deep links
- Register URL schemes in `Info.plist` (iOS) and `AndroidManifest.xml`
- Test deep links with: `adb shell am start -a android.intent.action.VIEW -d "app://{{PACKAGE_ID}}/products/123"`
- Handle `GoRouter.of(context).routerDelegate.currentConfiguration` for dynamic links
## Rules
- **NEVER** use `Navigator.push/pop` — use `context.go()`, `context.push()`, `context.pop()`
- All routes declared in one file: `lib/core/routing/app_router.dart`
- `BlocProvider`s for route-level blocs created inside the `builder` of each route
@@ -0,0 +1,109 @@
---
description: "Security standards for {{PROJECT_NAME}} — ALWAYS APPLIED on every file write"
alwaysApply: true
---
# Security Standards — {{PROJECT_NAME}}
> **Pillar 5**: Security is an always-on rule, not just a reactive agent.
> These rules apply to EVERY file write, regardless of feature or context.
## Credential & secret management
- **NEVER** hardcode API keys, tokens, or secrets in source code
- **NEVER** commit `.env` files — use `.gitignore` and document required vars in `.env.example`
- API keys belong in: flavored `--dart-define` build args, or a secrets manager (e.g. AWS Secrets)
- Use `flutter_secure_storage` for tokens/credentials — **NEVER** `SharedPreferences` for sensitive data
- Obfuscation: enable `--obfuscate --split-debug-info=build/debug-symbols/` for release builds
## Authentication & sessions
- JWT/session tokens: stored in `flutter_secure_storage`, never in `SharedPreferences` or local DB
- Implement token refresh with retry logic — never let a 401 show a raw error to the user
- Certificate pinning: required for production builds on {{AUTH}} — use `dio_pinning_interceptor`
- Biometric re-auth: require for any transaction > defined threshold
## Data & privacy
- **NEVER** log PII (names, emails, phone numbers, addresses) to console or crash reporters
- Sanitize all user inputs before sending to backend
- Use `SensitiveWidget` wrapper to exclude screens from screenshots / app switcher thumbnails
- Comply with platform privacy requirements: iOS `NSPrivacyAccessedAPITypes`, Android permissions
## Network security
- All API calls use HTTPS — no http:// in production
- Validate SSL certificates — never bypass with `badCertificateCallback: (_,_,_) => true`
- Add a timeout to every HTTP call: `connectTimeout: Duration(seconds: 10)`
- Rate-limit sensitive endpoints: auth, OTP, password reset
## Dependency security
- Run `dart pub outdated` monthly — flag packages with known CVEs
- Never use a package with <100 pub points without explicit tech lead approval
- Pin critical security packages to exact versions in `pubspec.yaml`
## Secure coding patterns
```dart
// ✅ Correct: secure token storage
final storage = FlutterSecureStorage();
await storage.write(key: 'access_token', value: token);
// ❌ Wrong: SharedPreferences for sensitive data
final prefs = await SharedPreferences.getInstance();
prefs.setString('access_token', token); // NEVER do this
// ✅ Correct: PII-safe logging
logger.info('User authenticated: userId=${user.id}'); // ok — id, not email
logger.debug('Payment processed: orderId=$orderId'); // ok — no card data
// ❌ Wrong: PII in logs
logger.info('Login: email=${user.email}, phone=${user.phone}'); // NEVER
// ✅ Correct: --dart-define for secrets (build arg, not in code)
// flutter build apk --dart-define=API_KEY=$API_KEY
const apiKey = String.fromEnvironment('API_KEY'); // acceptable
// ✅ Correct: certificate pinning with Dio
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.badCertificateCallback = (cert, host, port) => false; // strict
return client;
};
```
## Deep linking & intent security
- Validate all incoming deep link parameters — never trust raw URL params
- Use `app_links` package for verified deep link handling
- Restrict URL schemes to known patterns — reject unknown schemes
- For OAuth callbacks: validate `state` parameter to prevent CSRF
## Storage security
- Encrypt locally cached sensitive data using `hive` with `HiveAesCipher`
- Clear sensitive data from memory after use (set to null, trigger GC)
- Do NOT cache auth tokens in network layer (no `dio_cache_interceptor` for auth endpoints)
- Use `SecureRandom` for nonce/token generation — never `Random()`
## Code obfuscation & binary protection
```yaml
# android/app/build.gradle — release config
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
```
```bash
# Flutter release build with obfuscation
flutter build apk --release --obfuscate --split-debug-info=build/debug-symbols/
flutter build ipa --release --obfuscate --split-debug-info=build/debug-symbols/
```
## Release checklist
Before every production release, verify:
- [ ] No hardcoded secrets (`grep -r "api_key\|secret\|password\|token" lib/`)
- [ ] Debug flags disabled (`kDebugMode` guards on all debug-only paths)
- [ ] Obfuscation enabled in release build config
- [ ] Certificate pinning active and tested on both platforms
- [ ] Crash reporter PII filters configured (Sentry `beforeSend`, Firebase `setConsentType`)
- [ ] `flutter_secure_storage` used for all tokens — no `SharedPreferences` for sensitive keys
- [ ] Network calls all HTTPS — scan for `http://` in lib/
- [ ] Dependencies audited: `dart pub outdated --json | jq '.packages[] | select(.isDiscontinued)'`
- [ ] Deep link parameters validated
- [ ] App transport security / network security config reviewed
@@ -0,0 +1,64 @@
---
description: "BLoC / Cubit conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# BLoC / Cubit Standards — {{PROJECT_NAME}}
## When to use BLoC vs Cubit
- **Cubit**: simple state with no meaningful event history (toggle, counter, pagination)
- **BLoC**: event-driven flows where event history or transitions matter (auth, checkout, form wizard)
## Event and State classes
```dart
// Events — sealed class (exhaustive switch)
sealed class AuthEvent { const AuthEvent(); }
final class AuthLoginRequested extends AuthEvent {
final String email, password;
const AuthLoginRequested({required this.email, required this.password});
}
final class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); }
// States — sealed class, immutable
sealed class AuthState { const AuthState(); }
final class AuthInitial extends AuthState { const AuthInitial(); }
final class AuthLoading extends AuthState { const AuthLoading(); }
final class AuthAuthenticated extends AuthState {
final User user;
const AuthAuthenticated(this.user);
}
final class AuthUnauthenticated extends AuthState { const AuthUnauthenticated(); }
final class AuthFailure extends AuthState {
final String message;
const AuthFailure(this.message);
}
```
## BlocProvider placement
- Create `BlocProvider` at the **route level** (in {{ROUTING}} route definitions)
- `MultiBlocProvider` at route level for screens needing multiple blocs
- **NEVER** create `BlocProvider` inside a widget's `build()` method
## Usage rules
- **NEVER** call `bloc.add()` inside `build()` — only in gesture callbacks or `initState()`
- Use `BlocConsumer` only when BOTH `listen` + `build` logic are needed
- Use `BlocSelector` when only a subset of state triggers a rebuild
- Every BLoC must override `close()` and cancel `StreamSubscription`s
## BlocBuilder patterns
```dart
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => switch (state) {
AuthInitial() => const SizedBox.shrink(),
AuthLoading() => const LoadingIndicator(),
AuthAuthenticated(user: final u) => HomeScreen(user: u),
AuthUnauthenticated() => const LoginScreen(),
AuthFailure(message: final m) => ErrorScreen(message: m),
},
)
```
## File locations in {{PROJECT_NAME}}
- `lib/features/[feature]/presentation/bloc/[feature]_bloc.dart`
- `lib/features/[feature]/presentation/bloc/[feature]_event.dart`
- `lib/features/[feature]/presentation/bloc/[feature]_state.dart`
@@ -0,0 +1,67 @@
---
description: "GetX conventions for {{PROJECT_NAME}} (legacy — migration available)"
alwaysApply: true
---
# GetX Standards — {{PROJECT_NAME}}
> ⚠️ This project uses GetX. See `migration-agent` for incremental migration to Riverpod.
## Controller structure
```dart
class ProductsController extends GetxController {
final ProductRepository _repo;
ProductsController(this._repo);
final RxList<Product> products = <Product>[].obs;
final RxBool isLoading = false.obs;
final Rx<String?> error = Rx(null);
@override
void onInit() {
super.onInit();
fetchProducts();
}
Future<void> fetchProducts() async {
isLoading.value = true;
error.value = null;
try {
products.value = await _repo.getProducts();
} catch (e) {
error.value = e.toString();
} finally {
isLoading.value = false;
}
}
}
```
## View pattern
```dart
// Views extend GetView<Controller> — never GetWidget or raw StatelessWidget
class ProductsView extends GetView<ProductsController> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Obx(() {
if (controller.isLoading.value) return const ProductShimmer();
if (controller.error.value != null) return ErrorWidget(controller.error.value!);
return ProductList(controller.products);
}),
);
}
}
```
## Rules
- **NEVER** pass `BuildContext` into a controller
- Use `Binding` classes for dependency injection — never `Get.put()` in a widget
- Use `.obs` for all reactive state — never call `update()` on non-observable state
- Use `Get.find<Controller>()` only in `Binding` classes, not in widgets
- **No business logic in Views** — controllers handle all logic
## File locations in {{PROJECT_NAME}}
- `lib/features/[feature]/views/[feature]_view.dart`
- `lib/features/[feature]/controllers/[feature]_controller.dart`
- `lib/features/[feature]/bindings/[feature]_binding.dart`
- `lib/features/[feature]/models/[feature]_model.dart`
@@ -0,0 +1,44 @@
---
description: "Hooks + Riverpod conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Hooks + Riverpod Standards — {{PROJECT_NAME}}
## Widget base classes
- `HookConsumerWidget` — when you need BOTH hooks and Riverpod providers
- `HookWidget` — when you need ONLY hooks (no Riverpod)
- `ConsumerWidget` — when you need ONLY Riverpod (no hooks)
## Hook rules
```dart
class ProductSearchWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Hooks at the TOP of build() — never inside conditions or loops
final searchQuery = useState('');
final searchCtrl = useTextEditingController();
final focusNode = useFocusNode();
final debounced = useDebounced(searchQuery.value, const Duration(milliseconds: 300));
// Riverpod below hooks
final results = ref.watch(searchResultsProvider(debounced));
return // ...
}
}
```
## What goes in hooks vs providers
| Concern | Tool |
|---------|------|
| Local UI state (text controller, animation, focus) | `useState`, `useAnimationController` |
| Server/async state | Riverpod `AsyncNotifier` |
| Cross-widget/feature state | Riverpod providers |
| Lifecycle side effects | `useEffect` |
## Rules
- **NEVER** call hooks inside `if`, `for`, or callbacks
- `useEffect` cleanup MUST return a dispose function
- Custom hooks: prefix with `use`, live in `lib/core/hooks/`
- Do not use `flutter_riverpod` `ref.watch` inside `useEffect` — use `useRef` + `ref.listen`
@@ -0,0 +1,58 @@
---
description: "Riverpod conventions for {{PROJECT_NAME}}"
alwaysApply: true
---
# Riverpod Standards — {{PROJECT_NAME}}
## Provider types
| Type | Use case |
|------|----------|
| `AsyncNotifier` | Async state from {{BACKEND}} |
| `Notifier` | Synchronous derived/local UI state |
| `StreamNotifier` | Real-time subscriptions |
| `@riverpod` function | Simple computed/derived values |
## Code generation (mandatory)
```dart
// ✅ Always use @riverpod annotation
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
Future<User?> build() => ref.watch(authRepositoryProvider).currentUser();
Future<void> login(String email, String password) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(authRepositoryProvider).login(email, password),
);
}
}
// ❌ Never write manual provider declarations
final authProvider = StateNotifierProvider<AuthNotifier, AsyncValue<User?>>(
(ref) => AuthNotifier(),
); // DON'T DO THIS
```
## Rules
- `ref.watch()` inside `build()` ONLY — **never** `ref.read()` inside `build()`
- `ref.read(provider.notifier).method()` for mutations in gesture handlers
- `ref.invalidate(provider)` to refresh — never manually reset state to `AsyncLoading()`
- Family providers for parameterized data: `productDetailsProvider(productId)`
- Providers scoped at feature level; core providers in `lib/core/di/`
## AsyncValue in widgets
Every `AsyncValue` MUST handle all three states:
```dart
ref.watch(productsProvider).when(
data: (products) => ProductList(products: products),
loading: () => const ProductListShimmer(), // required
error: (e, _) => ErrorWidget(error: e), // required
)
```
## File locations in {{PROJECT_NAME}}
- `lib/features/[feature]/[feature]_provider.dart` (generated: `[feature]_provider.g.dart`)
- `lib/features/[feature]/[feature]_repository.dart`
- Run `dart run build_runner watch` during development
@@ -0,0 +1,60 @@
---
description: "BLoC testing conventions for {{PROJECT_NAME}}"
alwaysApply: false
---
# BLoC Testing Standards — {{PROJECT_NAME}}
## Test pattern (bloc_test)
```dart
// {{TEST_PATTERN}}
void main() {
late AuthBloc authBloc;
late MockAuthRepository mockRepo;
setUp(() {
mockRepo = MockAuthRepository();
authBloc = AuthBloc(repository: mockRepo);
});
tearDown(() => authBloc.close());
group('AuthBloc', () {
blocTest<AuthBloc, AuthState>(
'emits [Loading, Authenticated] when login succeeds',
build: () {
when(() => mockRepo.login(any(), any()))
.thenAnswer((_) async => const Right(User(id: '1', email: 'test@test.com')));
return authBloc;
},
act: (bloc) => bloc.add(const AuthLoginRequested(email: 'test@test.com', password: 'pass')),
expect: () => [
const AuthLoading(),
isA<AuthAuthenticated>(),
],
);
blocTest<AuthBloc, AuthState>(
'emits [Loading, Failure] when login fails',
build: () {
when(() => mockRepo.login(any(), any()))
.thenAnswer((_) async => const Left(AuthError('Invalid credentials')));
return authBloc;
},
act: (bloc) => bloc.add(const AuthLoginRequested(email: 'bad', password: 'bad')),
expect: () => [
const AuthLoading(),
const AuthFailure('Invalid credentials'),
],
);
});
}
```
## Rules
- Use `mocktail` for mocking — never `mockito`
- Every BLoC test file: `test/features/[feature]/[feature]_bloc_test.dart`
- Coverage requirement: all state transitions must be tested
- Use `Given/When/Then` naming in test descriptions
- Test error paths as thoroughly as success paths
@@ -0,0 +1,37 @@
---
description: "Patrol E2E testing conventions for {{PROJECT_NAME}}"
alwaysApply: false
---
# Patrol E2E Testing — {{PROJECT_NAME}}
## Test structure
```dart
void main() {
patrolTest('User can complete checkout flow', ($) async {
await $.pumpWidgetAndSettle(const App());
// Login
await $(LoginScreen).waitUntilVisible();
await $(#emailField).enterText('test@example.com');
await $(#passwordField).enterText('password123');
await $('Sign In').tap();
// Add to cart
await $(ProductCard).at(0).tap();
await $('Add to Cart').tap();
// Checkout
await $('Cart').tap();
await $('Checkout').tap();
await $(CheckoutSuccessScreen).waitUntilVisible();
});
}
```
## Rules
- E2E tests in `integration_test/` — separate from unit tests
- Use `patrolTest` not `testWidgets` for E2E scenarios
- Tag tests with `@Tags(['slow'])` so CI can skip on PRs
- Run against real emulators/simulators, not mocked environments
- Test on minimum supported OS version for each platform
@@ -0,0 +1,40 @@
---
description: "GetX testing conventions for {{PROJECT_NAME}}"
alwaysApply: false
---
# GetX Testing Standards — {{PROJECT_NAME}}
## Test pattern
```dart
void main() {
late ProductsController controller;
late MockProductRepository mockRepo;
setUp(() {
mockRepo = MockProductRepository();
Get.testMode = true;
controller = Get.put(ProductsController(mockRepo));
});
tearDown(() => Get.deleteAll());
test('loads products on init', () async {
when(() => mockRepo.getProducts()).thenAnswer((_) async => [fakeProduct]);
await controller.fetchProducts();
expect(controller.products, [fakeProduct]);
expect(controller.isLoading.value, false);
});
testWidgets('ProductsView shows shimmer while loading', (tester) async {
controller.isLoading.value = true;
await tester.pumpWidget(GetMaterialApp(home: ProductsView()));
expect(find.byType(ProductShimmer), findsOneWidget);
});
}
```
## Rules
- Use `Get.testMode = true` in setUp
- Always call `Get.deleteAll()` in tearDown
- Wrap widget tests in `GetMaterialApp`, not `MaterialApp`
@@ -0,0 +1,56 @@
---
description: "Riverpod testing conventions for {{PROJECT_NAME}}"
alwaysApply: false
---
# Riverpod Testing Standards — {{PROJECT_NAME}}
## Test pattern
```dart
// {{TEST_PATTERN}}
void main() {
late ProviderContainer container;
late MockProductRepository mockRepo;
setUp(() {
mockRepo = MockProductRepository();
container = ProviderContainer(overrides: [
productRepositoryProvider.overrideWithValue(mockRepo),
]);
});
tearDown(() => container.dispose()); // ALWAYS dispose
test('ProductsNotifier loads products successfully', () async {
when(() => mockRepo.getProducts()).thenAnswer((_) async => [fakeProduct]);
final notifier = container.read(productsProvider.notifier);
await notifier.loadProducts();
final state = container.read(productsProvider);
expect(state, isA<AsyncData<List<Product>>>());
expect(state.value, [fakeProduct]);
});
}
```
## Widget tests with Riverpod
```dart
testWidgets('ProductScreen shows shimmer while loading', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
productsProvider.overrideWith((ref) => const AsyncLoading()),
],
child: const MaterialApp(home: ProductScreen()),
),
);
expect(find.byType(ProductShimmer), findsOneWidget);
});
```
## Rules
- **Never** use a real `ProviderScope` in unit tests — always use `ProviderContainer` with overrides
- `addTearDown(container.dispose)` in every test that creates a container
- Test all three `AsyncValue` states: loading, data, error
@@ -0,0 +1,40 @@
---
description: "Core Flutter conventions for {{PROJECT_NAME}} — always applied"
alwaysApply: true
---
# Flutter Core Standards — {{PROJECT_NAME}}
## Const and performance
- Use `const` constructors wherever possible — compile-time guarantee of no rebuild
- Prefer `const` widgets at the leaf level: `const SizedBox.shrink()`, `const Padding(...)`
- Never use `const` with mutable values; lint: `prefer_const_constructors` is enabled
## Null safety
- Never use `!` (bang operator) unless you have a 100% safe runtime guarantee with a comment
- Prefer `??`, `?.`, and `if (x != null)` guards
- Use `required` for all non-nullable named parameters
- Never use `late` without a guarantee of initialisation before first access
## Widget lifecycle
- Override `dispose()` in every `StatefulWidget` that uses controllers, streams, or timers
- Cancel `StreamSubscription` in `dispose()`, not in `didUpdateWidget`
- Use `WidgetsBinding.instance.addPostFrameCallback` for post-build logic, not `Future.delayed(Duration.zero)`
## Naming conventions
- Files: `snake_case.dart`
- Classes: `PascalCase`
- Variables/functions: `camelCase`
- Constants: `kCamelCase` or `SCREAMING_SNAKE` for true compile-time constants
- Private members: `_camelCase`
## Imports
- Order: dart: → package: → relative
- Use relative imports within a feature; absolute for cross-feature
- Never import a feature's internal files from outside that feature
## Code quality
- Max function length: 40 lines. Extract widgets and helpers aggressively
- No `print()` in production code — use a logging package
- All `TODO:` comments must include a ticket number: `// TODO: PROJ-123 — fix this`
- Run `dart format` before every commit
@@ -0,0 +1,64 @@
---
description: "Project context for {{PROJECT_NAME}} — always applied"
alwaysApply: true
---
# Project Context — {{PROJECT_NAME}}
## Project identity
- **Name:** {{PROJECT_NAME}}
- **Package:** {{PACKAGE_ID}}
- **Description:** {{DESCRIPTION}}
- **Scale:** {{SCALE}}
## Technology stack
- **State management:** {{STATE_MANAGEMENT}}
- **Architecture:** {{ARCHITECTURE}}
- **Routing:** {{ROUTING}}
- **Backend:** {{BACKEND}}
- **Auth:** {{AUTH}}
- **Platforms:** {{PLATFORMS_LIST}}
- **Code generation:** {{CODEGEN_LIST}}
## Feature modules
{{FEATURES_LIST}}
## Special capabilities
{{SPECIAL_FEATURES}}
## Environments / flavors
- Flavors: {{FLAVORS_LIST}}
- CI/CD: {{CICD_TOOL}}
## Design & API references
- Design source: {{DESIGN_SOURCE}}
- API docs: {{API_DOCS_FORMAT}} at `{{API_DOCS_PATH}}`
## Code references
### Git repositories
{{GIT_REFS_BLOCK}}
### Local paths
{{LOCAL_PATHS_BLOCK}}
## Product UX / themes & roles
- **Theme variants:** {{THEME_SUMMARY}}
- **Roles:** {{ROLES_SUMMARY}}
{{HIGH_CONTRAST_NOTE}}
## Reviews — which rule owns what
- **Theme, colors, typography, spacing/radius tokens** → `ui-ux-standards.mdc` (widgets read `Theme.of(context)` only)
- **User-visible copy & locales** → `localization.mdc` (ARB / `AppLocalizations`; no UI string literals)
- **Imports, structure, naming** → `flutter-core.mdc` + architecture rule
## Architecture boundaries
{{ARCH_IMPORT_RULES}}
## When generating code for this project
1. Always use {{STATE_MANAGEMENT}} patterns — never suggest alternatives
2. Always follow {{ARCHITECTURE}} folder structure
3. Always use {{ROUTING}} for navigation — never `Navigator.push` directly
4. Always target platforms: {{PLATFORMS_LIST}}
5. If code generation tools are used ({{CODEGEN_LIST}}), follow their conventions
6. Apply visuals only through theme (`ColorScheme`, `TextTheme`, `ThemeExtension`) — never ad-hoc colors/fonts in feature widgets
7. No user-facing string literals in widgets — l10n or shared constants per localization rule
@@ -0,0 +1,48 @@
---
description: "UI/UX standards for {{PROJECT_NAME}} — always applied"
alwaysApply: true
---
# UI / UX Standards — {{PROJECT_NAME}}
## Theme & design tokens (single source of truth)
- Define **one** light/dark `ThemeData` (and optional `ThemeExtension`s for brand spacing, radii, semantic colors). Feature code reads `Theme.of(context)` only.
- **Colors:** `colorScheme` / extensions — never hex/`Color(...)` literals in widgets except inside the theme definition file(s).
- **Typography:** `textTheme` / `primaryTextTheme` — never raw `TextStyle(fontSize:, fontFamily:)` in feature UI.
- **Spacing & shapes:** `ThemeExtension` or documented constants consumed consistently — avoid one-off magic numbers for padding/radius.
## Loading states
- Every async operation MUST show a loading skeleton (shimmer), NOT a spinner unless < 300ms
- Use `shimmer` package with a shimmer that matches the final layout shape
- Never show a blank screen during loading — skeleton must fill the same space as the content
## Empty states
- Every list/grid MUST have a distinct empty state widget: illustration + headline + CTA
- Empty state is different from error state — never reuse the same widget for both
- Empty state copy: positive framing ("No items yet — add your first one")
## Error states
- Every async failure MUST show: error message + retry button
- Never swallow errors silently
- Error text: user-friendly, never expose stack traces or raw API messages
## Navigation & transitions
- Use `IndexedStack` for bottom nav tabs — preserves scroll position
- Named routes only — never `Navigator.push(context, MaterialPageRoute(...))`
- Page transitions: use `CustomTransitionPage` with `FadeTransition` for modal sheets
## Responsive layout
- Use `LayoutBuilder` or `MediaQuery` for breakpoints, not hardcoded pixel values
- Minimum touch target: 48×48 logical pixels (Material guideline)
- Test on 375px (iPhone SE) and 414px (iPhone Pro Max) widths minimum
## Haptics
- Use `HapticFeedback.lightImpact()` on primary CTAs
- Use `HapticFeedback.selectionClick()` on toggle/checkbox interactions
- Never add haptics to destructive actions without confirmation
## Accessibility
- All interactive widgets must have a `Semantics` label or `tooltip`
- Minimum contrast ratio: 4.5:1 (WCAG AA)
- Test with TalkBack / VoiceOver before each release
{{HIGH_CONTRAST_UX_LINE}}
@@ -0,0 +1,18 @@
# Create Build Flavor — {{PROJECT_NAME}}
Creates a new build flavor/environment. Current flavors: {{FLAVORS_LIST}}.
## Usage
```
Create a new flavor called [flavor_name]
```
## What gets created
1. Android: new `productFlavors` block in `android/app/build.gradle`
2. iOS: new scheme + configuration in Xcode (instructions provided)
3. `lib/core/config/[flavor_name]_config.dart`
4. `.env.[flavor_name]` template
5. {{CICD_TOOL}} pipeline update
## CI/CD: {{CICD_TOOL}}
Generate the pipeline config snippet for the new flavor in {{CICD_TOOL}} format.
@@ -0,0 +1,21 @@
# Deploy — {{PROJECT_NAME}}
Guides through deployment to {{CICD_TOOL}} for any of the flavors: {{FLAVORS_LIST}}.
## Usage
```
Deploy [flavor] to [store/environment]
```
## {{CICD_TOOL}} pipeline
The AI will generate or update the {{CICD_RAW}} configuration file for:
- Build signing
- Running tests
- Distributing to Firebase App Distribution (beta) or App Store / Play Store (prod)
## Pre-deploy checklist
- [ ] Version bump in `pubspec.yaml`
- [ ] Obfuscation enabled for prod: `--obfuscate --split-debug-info=build/debug-symbols/`
- [ ] No debug flags in production code
- [ ] Security checklist from `security-standards.mdc` passed
- [ ] `cursor_gen --validate` passes
@@ -0,0 +1,16 @@
# Generate API Client — {{PROJECT_NAME}}
Generates type-safe API clients from {{API_DOCS_FORMAT}} spec at `{{API_DOCS_PATH}}`.
## Usage
```
Generate API client for [endpoint or resource name]
```
## Generated files
1. **DTO** (`data/models/[resource]_dto.dart`) — request/response models with json_serializable
2. **DataSource** (`data/datasources/[resource]_remote_datasource.dart`) — Dio calls with error handling
3. **Repository impl** (`data/repositories/[resource]_repository_impl.dart`)
## After generation
Run: `dart run build_runner build --delete-conflicting-outputs`
@@ -0,0 +1,41 @@
# Generate Tests — {{PROJECT_NAME}}
Generates comprehensive unit, widget, and integration tests for **{{STATE_MANAGEMENT}}** patterns.
## Usage
```
Generate tests for [ClassName or file path]
```
## Test generation process
1. Read the source file completely
2. Identify all testable units (public methods, state transitions, UI states)
3. Generate tests following this pattern:
```
{{TEST_PATTERN}}
```
4. Create mocks with `mocktail` for all dependencies
5. Place test file at `test/[mirror of source path]_test.dart`
## Coverage targets
- Business logic (UseCases, Repositories, BLoC/Notifiers): **80% minimum**
- Widget tests: all three states (loading/error/data) must be tested
- E2E: only critical user flows
## Test file structure
```dart
void main() {
// Setup
group('[ClassName]', () {
// Happy path tests
group('success cases', () { ... });
// Error path tests
group('error cases', () { ... });
// Edge cases
group('edge cases', () { ... });
});
}
```
## Naming convention
`'given [precondition], when [action], then [expected outcome]'`
@@ -0,0 +1,69 @@
# Scaffold Feature — {{PROJECT_NAME}}
Scaffolds a complete new feature module following **{{ARCHITECTURE}}** architecture with **{{STATE_MANAGEMENT}}** state management.
## Usage
```
Create a feature called [feature_name] with [description]
```
## What gets generated
### For {{ARCHITECTURE}} architecture:
The AI will create all necessary files for the `[feature_name]` feature following {{ARCHITECTURE}} patterns.
### File structure to create:
**Clean Architecture:**
```
lib/features/[feature_name]/
domain/
entities/[feature_name].dart
repositories/[feature_name]_repository.dart
usecases/
get_[feature_name]_usecase.dart
create_[feature_name]_usecase.dart
data/
models/[feature_name]_dto.dart
datasources/[feature_name]_remote_datasource.dart
repositories/[feature_name]_repository_impl.dart
presentation/
[state_files]/ ← based on {{STATE_MANAGEMENT}}
pages/[feature_name]_page.dart
widgets/
```
**Feature-First:**
```
lib/features/[feature_name]/
[feature_name]_screen.dart
[feature_name]_provider.dart ← or [feature_name]_bloc.dart
[feature_name]_repository.dart
[feature_name]_model.dart
widgets/
```
## State management boilerplate
### For {{STATE_MANAGEMENT}}:
Generate the appropriate state management files with:
- Initial/loading/success/error states
- All necessary events (for BLoC)
- Repository connection
- Dependency injection registration
## Steps the AI takes
1. Ask: "What is the feature name and brief description?"
2. Ask: "What data does this feature manage? (e.g., list of products, single user profile)"
3. Generate all files with correct imports and patterns
4. Add the feature to the DI container
5. Add the route to {{ROUTING}} router
6. Create a placeholder test file
## Code generation
{{#if codegen_freezed}}
- Generate Freezed model: run `dart run build_runner build` after scaffolding
{{/if}}
{{#if codegen_injectable}}
- Register in injectable: add `@lazySingleton` to repository
{{/if}}
@@ -0,0 +1,61 @@
# Scaffold Screen — {{PROJECT_NAME}}
Creates a complete screen widget with all states handled, following **{{STATE_MANAGEMENT}}** patterns.
## Usage
```
Create a screen for [screen_name] that shows [content description]
```
## Generated screen template
### {{STATE_MANAGEMENT}} screen pattern:
**BLoC:**
```dart
class [ScreenName]Screen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<[Feature]Bloc, [Feature]State>(
builder: (context, state) => switch (state) {
[Feature]Initial() || [Feature]Loading() => const [ScreenName]Shimmer(),
[Feature]Loaded(:final data) => [ScreenName]Content(data: data),
[Feature]Empty() => const [ScreenName]EmptyState(),
[Feature]Error(:final message) => [ScreenName]ErrorState(
message: message,
onRetry: () => context.read<[Feature]Bloc>().add(const [Feature]LoadRequested()),
),
},
);
}
}
```
**Riverpod:**
```dart
class [ScreenName]Screen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch([feature]Provider);
return state.when(
loading: () => const [ScreenName]Shimmer(),
data: (data) => [ScreenName]Content(data: data),
error: (e, _) => [ScreenName]ErrorState(error: e),
);
}
}
```
## Required sub-widgets to generate
1. `[ScreenName]Shimmer` — skeleton loading layout matching final content
2. `[ScreenName]EmptyState` — illustration + headline + CTA button
3. `[ScreenName]ErrorState` — error message + retry button
4. `[ScreenName]Content` — the actual data display
## Platform considerations ({{PLATFORMS_LIST}})
{{#if platform_web}}
- Web: ensure no dart:io usage; test at 375px and 1280px widths
{{/if}}
{{#if platform_desktop}}
- Desktop: add keyboard shortcut support for primary actions
{{/if}}
@@ -3,7 +3,6 @@
import 'dart:io'; import 'dart:io';
import 'package:test/test.dart'; import 'package:test/test.dart';
import '../src/brief_loader.dart';
import '../src/resolver.dart'; import '../src/resolver.dart';
import '../src/renderer.dart'; import '../src/renderer.dart';
import '../src/validator.dart'; import '../src/validator.dart';
@@ -95,6 +94,7 @@ final _getxMvcBrief = ProjectBrief(
locales: ['en'], locales: ['en'],
); );
void main() {
// ─── Resolver tests ───────────────────────────────────────────────────────── // ─── Resolver tests ─────────────────────────────────────────────────────────
group('Resolver', () { group('Resolver', () {
@@ -133,7 +133,8 @@ group('Resolver', () {
// Agents // Agents
expect(files, contains('agents/code-reviewer')); expect(files, contains('agents/code-reviewer'));
expect(files, containsNot('agents/migration-agent')); // not GetX expect(files, containsNot('agents/migration-agent')); // not GetX
expect(files, contains('agents/security-agent')); // firebase_auth triggers it expect(files,
contains('agents/security-agent')); // firebase_auth triggers it
// NOT included // NOT included
expect(files, containsNot('rules/state-management/riverpod')); expect(files, containsNot('rules/state-management/riverpod'));
@@ -159,19 +160,46 @@ group('Resolver', () {
expect(files, contains('rules/state-management/getx')); expect(files, contains('rules/state-management/getx'));
expect(files, contains('rules/architecture/mvc')); expect(files, contains('rules/architecture/mvc'));
expect(files, contains('agents/api-client-gen')); // rest backend expect(files, contains('agents/api-client-gen')); // rest backend
expect(files, contains('skills/generate-api-client')); // apiDocsFormat != none expect(files,
contains('skills/generate-api-client')); // apiDocsFormat != none
// Hooks are emitted only when stack.codegen is non-empty
expect(files, containsNot('hooks/hooks-json'));
expect(files, containsNot('hooks/flutter-analyze'));
});
test('Codegen stack includes Cursor hooks templates', () {
final files = Resolver.resolve(_blocCleanBrief);
expect(files, contains('hooks/hooks-json'));
expect(files, contains('hooks/arch-guard'));
}); });
test('Realtime special feature includes realtime template', () { test('Realtime special feature includes realtime template', () {
final brief = ProjectBrief( final brief = ProjectBrief(
projectName: 'RealtimeApp', packageId: 'com.test.rt', description: '', projectName: 'RealtimeApp',
scale: 'medium', stateManagement: 'riverpod', routing: 'gorouter', packageId: 'com.test.rt',
architecture: 'feature_first', backends: ['supabase'], auth: 'supabase_auth', description: '',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions', scale: 'medium',
testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none', stateManagement: 'riverpod',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '', routing: 'gorouter',
referenceRepos: [], localPaths: [], featureModules: [], architecture: 'feature_first',
specialFeatures: ['realtime'], i18nEnabled: false, locales: ['en'], backends: ['supabase'],
auth: 'supabase_auth',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'github_actions',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: ['realtime'],
i18nEnabled: false,
locales: ['en'],
); );
final files = Resolver.resolve(brief); final files = Resolver.resolve(brief);
expect(files, contains('rules/backend/realtime')); expect(files, contains('rules/backend/realtime'));
@@ -179,14 +207,31 @@ group('Resolver', () {
test('E2E testing depth includes e2e template', () { test('E2E testing depth includes e2e template', () {
final brief = ProjectBrief( final brief = ProjectBrief(
projectName: 'E2EApp', packageId: 'com.test.e2e', description: '', projectName: 'E2EApp',
scale: 'small', stateManagement: 'bloc', routing: 'gorouter', packageId: 'com.test.e2e',
architecture: 'clean', backends: ['rest'], auth: 'none', description: '',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions', scale: 'small',
testingDepth: 'full', e2eTool: 'patrol', designSource: 'none', stateManagement: 'bloc',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '', routing: 'gorouter',
referenceRepos: [], localPaths: [], featureModules: [], architecture: 'clean',
specialFeatures: [], i18nEnabled: false, locales: ['en'], backends: ['rest'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'github_actions',
testingDepth: 'full',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
); );
final files = Resolver.resolve(brief); final files = Resolver.resolve(brief);
expect(files, contains('rules/testing/testing-e2e-patrol')); expect(files, contains('rules/testing/testing-e2e-patrol'));
@@ -195,7 +240,8 @@ group('Resolver', () {
test('No duplicate files in resolved list', () { test('No duplicate files in resolved list', () {
final files = Resolver.resolve(_blocCleanBrief); final files = Resolver.resolve(_blocCleanBrief);
final unique = files.toSet(); final unique = files.toSet();
expect(files.length, equals(unique.length), reason: 'Duplicate template files detected'); expect(files.length, equals(unique.length),
reason: 'Duplicate template files detected');
}); });
}); });
@@ -212,16 +258,18 @@ group('Renderer — placeholder substitution', () {
}; };
final template = 'Project: {{PROJECT_NAME}} ({{PACKAGE_ID}})'; final template = 'Project: {{PROJECT_NAME}} ({{PACKAGE_ID}})';
final result = template final result = template
.replaceAll('{{PROJECT_NAME}}', 'TestApp') .replaceAll('{{PROJECT_NAME}}', context['PROJECT_NAME']!)
.replaceAll('{{PACKAGE_ID}}', 'com.test.testapp'); .replaceAll('{{PACKAGE_ID}}', context['PACKAGE_ID']!);
expect(result, equals('Project: TestApp (com.test.testapp)')); expect(result, equals('Project: TestApp (com.test.testapp)'));
expect(result, isNot(contains('{{'))); expect(result, isNot(contains('{{')));
}); });
test('Rendered output has no unreplaced {{PLACEHOLDER}} patterns', () async { test('Rendered output has no unreplaced {{PLACEHOLDER}} patterns',
() async {
final templateDir = _templateDir(); final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) { if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template directory not found at $templateDir — run from generator/'); markTestSkipped(
'Template directory not found at $templateDir — run from generator/');
return; return;
} }
@@ -238,7 +286,31 @@ group('Renderer — placeholder substitution', () {
unresolved.add('${entry.key}: ${m.group(0)}'); unresolved.add('${entry.key}: ${m.group(0)}');
} }
} }
expect(unresolved, isEmpty, reason: 'Unreplaced placeholders:\n${unresolved.join("\n")}'); expect(unresolved, isEmpty,
reason: 'Unreplaced placeholders:\n${unresolved.join("\n")}');
});
test('hooks/arch-guard renders real template content, not placeholder',
() async {
final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) {
markTestSkipped(
'Template directory not found at $templateDir — run from generator/');
return;
}
final rendered = await Renderer.render(
brief: _blocCleanBrief,
templateFiles: ['hooks/arch-guard'],
templateSrc: templateDir,
);
final content = rendered['hooks/arch-guard.ts'];
expect(content, isNotNull, reason: 'arch-guard.ts must be produced');
expect(content, isNot(contains('Template not found')),
reason:
'Default template dir should resolve real arch-guard.ts.tmpl');
expect(content, contains('arch-guard'));
}); });
}); });
@@ -253,14 +325,31 @@ group('Validator', () {
test('Invalid state_management fails validation', () async { test('Invalid state_management fails validation', () async {
final brief = ProjectBrief( final brief = ProjectBrief(
projectName: 'X', packageId: 'com.x.x', description: '', projectName: 'X',
scale: 'small', stateManagement: 'invalid_sm', routing: 'gorouter', packageId: 'com.x.x',
architecture: 'clean', backends: ['rest'], auth: 'none', description: '',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions', scale: 'small',
testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none', stateManagement: 'invalid_sm',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '', routing: 'gorouter',
referenceRepos: [], localPaths: [], featureModules: [], architecture: 'clean',
specialFeatures: [], i18nEnabled: false, locales: ['en'], backends: ['rest'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'github_actions',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
); );
final result = await Validator.validate(brief); final result = await Validator.validate(brief);
expect(result.isValid, isFalse); expect(result.isValid, isFalse);
@@ -269,14 +358,31 @@ group('Validator', () {
test('Missing project name fails validation', () async { test('Missing project name fails validation', () async {
final brief = ProjectBrief( final brief = ProjectBrief(
projectName: '', packageId: 'com.x.x', description: '', projectName: '',
scale: 'small', stateManagement: 'riverpod', routing: 'gorouter', packageId: 'com.x.x',
architecture: 'clean', backends: ['rest'], auth: 'none', description: '',
platforms: ['ios'], codegenTools: [], flavors: ['dev'], cicd: 'github_actions', scale: 'small',
testingDepth: 'unit_widget', e2eTool: 'patrol', designSource: 'none', stateManagement: 'riverpod',
figmaUrl: '', apiDocsFormat: 'none', apiDocsPath: '', routing: 'gorouter',
referenceRepos: [], localPaths: [], featureModules: [], architecture: 'clean',
specialFeatures: [], i18nEnabled: false, locales: ['en'], backends: ['rest'],
auth: 'none',
platforms: ['ios'],
codegenTools: [],
flavors: ['dev'],
cicd: 'github_actions',
testingDepth: 'unit_widget',
e2eTool: 'patrol',
designSource: 'none',
figmaUrl: '',
apiDocsFormat: 'none',
apiDocsPath: '',
referenceRepos: [],
localPaths: [],
featureModules: [],
specialFeatures: [],
i18nEnabled: false,
locales: ['en'],
); );
final result = await Validator.validate(brief); final result = await Validator.validate(brief);
expect(result.isValid, isFalse); expect(result.isValid, isFalse);
@@ -290,7 +396,8 @@ group('Golden file tests', () {
test('BLoC + Clean renders match golden files', () async { test('BLoC + Clean renders match golden files', () async {
final templateDir = _templateDir(); final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) { if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template dir not found'); return; markTestSkipped('Template dir not found');
return;
} }
final rendered = await Renderer.render( final rendered = await Renderer.render(
@@ -319,7 +426,8 @@ group('Golden file tests', () {
test('Riverpod + FF renders match golden files', () async { test('Riverpod + FF renders match golden files', () async {
final templateDir = _templateDir(); final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) { if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template dir not found'); return; markTestSkipped('Template dir not found');
return;
} }
final rendered = await Renderer.render( final rendered = await Renderer.render(
brief: _riverpodFFBrief, brief: _riverpodFFBrief,
@@ -332,7 +440,8 @@ group('Golden file tests', () {
test('GetX + MVC renders match golden files', () async { test('GetX + MVC renders match golden files', () async {
final templateDir = _templateDir(); final templateDir = _templateDir();
if (!Directory(templateDir).existsSync()) { if (!Directory(templateDir).existsSync()) {
markTestSkipped('Template dir not found'); return; markTestSkipped('Template dir not found');
return;
} }
final rendered = await Renderer.render( final rendered = await Renderer.render(
brief: _getxMvcBrief, brief: _getxMvcBrief,
@@ -342,10 +451,12 @@ group('Golden file tests', () {
_compareGoldens('test/golden/getx-mvc-rest', rendered); _compareGoldens('test/golden/getx-mvc-rest', rendered);
}); });
}); });
}
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
Future<void> _compareGoldens(String goldenDir, Map<String, String> rendered) async { Future<void> _compareGoldens(
String goldenDir, Map<String, String> rendered) async {
for (final entry in rendered.entries) { for (final entry in rendered.entries) {
final goldenFile = File('$goldenDir/${entry.key}'); final goldenFile = File('$goldenDir/${entry.key}');
if (!goldenFile.existsSync()) { if (!goldenFile.existsSync()) {
@@ -359,14 +470,18 @@ Future<void> _compareGoldens(String goldenDir, Map<String, String> rendered) asy
} }
String _templateDir() { String _templateDir() {
// When running from generator/, go up one level to find templates/ // Walk up from CWD until we find the sibling templates/ directory.
final script = Platform.script.toFilePath(); // Works under `dart test` (snapshot CWD) and direct script execution alike.
return script.replaceAll(RegExp(r'test[/\\][^/\\]+$'), '../templates'); var dir = Directory.current;
for (var i = 0; i < 5; i++) {
final candidate = Directory('${dir.path}/../templates');
if (candidate.existsSync()) return candidate.path;
final inside = Directory('${dir.path}/templates');
if (inside.existsSync()) return inside.path;
if (dir.parent.path == dir.path) break;
dir = dir.parent;
}
return '${Directory.current.path}/../templates';
} }
// Custom matcher Matcher containsNot(dynamic expected) => isNot(contains(expected));
Matcher get containsNot => isNot(contains);
extension on Matcher {
Matcher call(dynamic value) => isNot(contains(value));
}
@@ -0,0 +1,54 @@
---
name: code-reviewer
description: "Reviews TestApp code for BLoC / Cubit patterns, Clean Architecture boundaries, and Firebase usage. Ask: 'Review this code' or '@code-reviewer check PR'"
model: claude-opus-4-5
context: auto
allowed-tools: [read_file, list_files]
---
You are a senior Flutter engineer reviewing code for **TestApp**.
## Your review checklist
### BLoC / Cubit patterns
- Are state classes immutable and sealed?
- Is state management correctly separated from UI logic?
- Are streams/subscriptions properly cancelled in dispose()?
- Check for anti-patterns specific to BLoC / Cubit
### Clean Architecture boundaries
- presentation/ MUST NOT import from data/
- domain/ MUST NOT import from data/ or presentation/
- data/ CAN import from domain/ (implements interfaces)
- Use dependency injection to invert data → domain dependency
- Flag any violation of these import rules immediately
### Firebase usage
- Are all exceptions caught and mapped to domain errors?
- Are streams vs futures used appropriately?
- Are connections/subscriptions disposed correctly?
### Security (always check)
- No hardcoded API keys or secrets
- No PII logged to console or crash reporters
- Sensitive data using flutter_secure_storage, not SharedPreferences
- All user inputs validated before sending to backend
### General Flutter
- `const` used where possible
- `dispose()` overridden for all controllers/subscriptions
- No `print()` in production paths
- Loading/empty/error states all handled
## Output format
For each issue found:
```
[SEVERITY: critical/major/minor] File:line — Issue description
WHY: Why this matters
FIX: Specific fix recommendation
```
Severity guide:
- **critical**: Security issue, data loss risk, crash potential
- **major**: Incorrect pattern, boundary violation, missing error handling
- **minor**: Style, naming, optimization opportunity
@@ -0,0 +1,47 @@
---
name: security-agent
description: "Deep security review for TestApp. Consult for auth flows, payment screens, and sensitive data handling. Ask: '@security-agent review auth flow'"
model: claude-opus-4-5
context: fork
allowed-tools: [read_file, list_files]
---
You are a mobile security expert conducting a deep review for **TestApp**.
> Note: This agent provides deep security analysis.
> The `security-standards.mdc` rule provides always-on enforcement.
> This agent is for detailed consultations on specific security concerns.
## Deep review focus areas
### Auth flow (Firebase Auth)
- Token storage: is `flutter_secure_storage` used for ALL tokens?
- Token refresh: is refresh handled atomically (no race condition)?
- Session expiry: does the app handle 401 gracefully without data loss?
- Certificate pinning: configured and tested?
### Data at rest
- SQLite/Hive encryption: sensitive DBs encrypted?
- Cache poisoning: cached API responses validated before use?
- Keychain/Keystore usage for cryptographic keys
### Network security
- All endpoints HTTPS — any http:// URLs?
- Certificate validation — any `badCertificateCallback: true`?
- Sensitive data in URL params/query strings?
- Request/response logging in production? (must be off)
### Code injection risks
- Dynamic code execution patterns
- WebView usage — JavaScript interface security
- Deep link parameter validation (no path traversal)
## Output format
For each finding:
```
[RISK: Critical/High/Medium/Low]
LOCATION: File / function
ISSUE: Detailed description
CVSS-like impact: Confidentiality/Integrity/Availability
REMEDIATION: Specific code fix
```
@@ -0,0 +1,33 @@
---
name: test-writer
description: "Writes BLoC / Cubit unit tests for TestApp. Ask: 'Write tests for [class]' or '@test-writer generate tests'"
model: claude-sonnet-4-20250514
context: auto
allowed-tools: [read_file, write_file, list_files]
---
You are a Flutter test engineer for **TestApp** using **BLoC / Cubit**.
## Test pattern to follow
```dart
blocTest<MyCubit, MyState>(description, build: () => MyCubit(), act: (c) => c.method(), expect: () => [MyState()])
```
## When asked to write tests:
1. Read the source file completely
2. Identify all public methods and state transitions
3. Write tests for:
- Happy path (successful operation)
- Error path (failure, exception handling)
- Edge cases (empty data, boundary values)
4. Use `mocktail` for all mocking
5. Follow `Given/When/Then` naming: `'given X, when Y, then emits Z'`
## File placement
- Unit tests: `test/features/[feature]/[file]_test.dart`
- Widget tests: `test/features/[feature]/[screen]_widget_test.dart`
- Coverage target: 80% minimum for business logic classes
## Output
Write the complete test file, ready to run. Include all imports.
After writing, run: `dart test path/to/test_file.dart`
@@ -0,0 +1,47 @@
---
name: ui-validator
description: "Validates TestApp UI against design system and UX standards. Ask: 'Validate this screen' or '@ui-validator check'"
model: claude-sonnet-4-20250514
context: auto
allowed-tools: [read_file, list_files]
---
You are a UI/UX validator for **TestApp**.
## Validate every screen for:
### State coverage (BLoC / Cubit)
- Loading state: shows shimmer (NOT spinner unless brief)
- Empty state: shows illustration + CTA (NOT blank screen)
- Error state: shows message + retry button (NOT toast-only)
- Data state: renders content correctly
### Accessibility
- All interactive widgets have semantic labels
- Minimum touch targets: 48×48dp
- Sufficient color contrast (4.5:1 minimum)
### Responsive layout
- No hardcoded pixel widths
- Tested at 375px and 414px viewport widths
- `SafeArea` used correctly on iOS
### Platform-specific (ios, android)
{{#if web}}
- No dart:io imports in web-targeted code
- PWA-compatible (no native-only APIs without fallbacks)
{{/if}}
{{#if ios}}
- Safe area respected (notch, Dynamic Island)
- iOS Human Interface Guidelines followed
{{/if}}
### Security (UI layer)
- No credentials shown in plaintext
- Sensitive screens wrapped with screenshot prevention
## Output
List each violation with:
- Location (file:widget)
- What's wrong
- How to fix it
@@ -0,0 +1,141 @@
#!/usr/bin/env ts-node
// arch-guard.ts — Pre-commit hook: enforces Clean Architecture import boundaries
// Generated for TestApp (Clean Architecture architecture)
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
const RED = '\x1b[31m';
const YELLOW = '\x1b[33m';
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
interface ArchRule {
sourcePattern: RegExp;
forbiddenImports: RegExp[];
message: string;
}
// Architecture-specific rules for Clean Architecture
const ARCH_RULES: ArchRule[] = [
...getArchRules()
];
function getArchRules(): ArchRule[] {
const arch = 'clean';
if (arch === 'clean') {
return [
{
sourcePattern: /features\/\w+\/domain\//,
forbiddenImports: [/features\/\w+\/data\//, /features\/\w+\/presentation\//],
message: 'domain/ must not import from data/ or presentation/',
},
{
sourcePattern: /features\/\w+\/presentation\//,
forbiddenImports: [/features\/\w+\/data\//],
message: 'presentation/ must not import from data/ directly (use domain interfaces)',
},
];
}
if (arch === 'feature_first') {
return [
{
// A feature file must not import from another feature
sourcePattern: /features\/(\w+)\//,
forbiddenImports: [], // checked dynamically below
message: 'Features must not import from other feature folders',
},
];
}
if (arch === 'mvvm') {
return [
{
sourcePattern: /viewmodels\//,
forbiddenImports: [/package:flutter\//, /widgets\//],
message: 'ViewModels must not import Flutter widgets — they must be plain Dart testable',
},
];
}
return [];
}
function getChangedDartFiles(): string[] {
try {
const result = execSync('git diff --cached --name-only --diff-filter=ACM', { encoding: 'utf8' });
return result.split('\n').filter(f => f.endsWith('.dart') && fs.existsSync(f));
} catch {
return [];
}
}
function checkFile(filePath: string): string[] {
const violations: string[] = [];
const content = fs.readFileSync(filePath, 'utf8');
const imports = content.match(/^import\s+'[^']+'/gm) ?? [];
for (const rule of ARCH_RULES) {
if (!rule.sourcePattern.test(filePath)) continue;
// Feature-first cross-feature check
if ('clean' === 'feature_first') {
const srcFeatureMatch = filePath.match(/features\/(\w+)\//);
if (srcFeatureMatch) {
const srcFeature = srcFeatureMatch[1];
for (const imp of imports) {
const impFeatureMatch = imp.match(/features\/(\w+)\//);
if (impFeatureMatch && impFeatureMatch[1] !== srcFeature) {
violations.push(
`${RED}ARCH VIOLATION${RESET} in ${filePath}:\n` +
` ${YELLOW}${rule.message}${RESET}\n` +
` Import: ${imp}\n` +
` Fix: Move shared code to core/ or shared/`
);
}
}
}
continue;
}
// Standard forbidden import check
for (const imp of imports) {
for (const forbidden of rule.forbiddenImports) {
if (forbidden.test(imp)) {
violations.push(
`${RED}ARCH VIOLATION${RESET} in ${filePath}:\n` +
` ${YELLOW}${rule.message}${RESET}\n` +
` Import: ${imp}`
);
}
}
}
}
return violations;
}
const changedFiles = getChangedDartFiles();
if (changedFiles.length === 0) {
console.log(`${GREEN}✔ arch-guard: no Dart files changed${RESET}`);
process.exit(0);
}
console.log(`🏛 arch-guard checking ${changedFiles.length} file(s) for Clean Architecture violations...`);
const allViolations: string[] = [];
for (const file of changedFiles) {
allViolations.push(...checkFile(file));
}
if (allViolations.length > 0) {
console.error(`\n${RED}✘ Architecture boundary violations detected:${RESET}\n`);
for (const v of allViolations) console.error(v + '\n');
console.error(`Total: ${allViolations.length} violation(s). Fix before committing.`);
process.exit(1);
}
console.log(`${GREEN}✔ arch-guard: no violations found${RESET}`);
@@ -0,0 +1,32 @@
#!/usr/bin/env ts-node
// flutter-analyze.ts — Pre-commit hook: runs dart analyze and dart format check
import { execSync } from 'child_process';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
function run(cmd: string): { stdout: string; code: number } {
try {
const stdout = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
return { stdout, code: 0 };
} catch (e: any) {
return { stdout: e.stdout ?? e.message, code: e.status ?? 1 };
}
}
console.log('🔍 Running flutter analyze...');
const analyze = run('flutter analyze --no-congratulate');
if (analyze.code !== 0) {
console.error(`${RED}✘ flutter analyze failed:\n${analyze.stdout}${RESET}`);
process.exit(1);
}
console.log('🎨 Checking dart format...');
const format = run('dart format --output=none --set-exit-if-changed lib/ test/');
if (format.code !== 0) {
console.error(`${RED}✘ Unformatted files detected. Run: dart format lib/ test/${RESET}`);
process.exit(1);
}
console.log(`${GREEN}✔ flutter analyze + dart format passed${RESET}`);
@@ -0,0 +1,16 @@
#!/usr/bin/env ts-node
// grind-tests.ts — Pre-push hook: runs flutter test
import { execSync } from 'child_process';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const RESET = '\x1b[0m';
console.log('🧪 Running flutter test...');
try {
execSync('flutter test --coverage', { stdio: 'inherit' });
console.log(`${GREEN}✔ All tests passed${RESET}`);
} catch {
console.error(`${RED}✘ Tests failed. Fix failing tests before pushing.${RESET}`);
process.exit(1);
}
@@ -0,0 +1,19 @@
{
"hooks": [
{
"name": "pre-commit: flutter analyze",
"command": "npx ts-node .cursor/hooks/flutter-analyze.ts",
"events": ["pre-commit"]
},
{
"name": "pre-commit: arch guard",
"command": "npx ts-node .cursor/hooks/arch-guard.ts",
"events": ["pre-commit"]
},
{
"name": "pre-push: run tests",
"command": "npx ts-node .cursor/hooks/grind-tests.ts",
"events": ["pre-push"]
}
]
}
@@ -0,0 +1,56 @@
---
description: "Clean Architecture conventions for TestApp"
alwaysApply: true
---
# Clean Architecture — TestApp
## Layer structure
```
lib/features/[feature]/
├── domain/
│ ├── entities/ ← Pure Dart, no framework imports
│ ├── repositories/ ← Abstract interfaces only
│ └── usecases/ ← Single-responsibility business operations
├── data/
│ ├── datasources/ ← Remote (API) + Local (cache) implementations
│ ├── models/ ← DTOs with fromJson/toJson (can use Freezed)
│ └── repositories/ ← Implements domain/repositories interfaces
└── presentation/
├── bloc/ or notifiers/
├── pages/
└── widgets/
```
## Import rules (STRICTLY ENFORCED by arch-guard hook)
- presentation/ MUST NOT import from data/
- domain/ MUST NOT import from data/ or presentation/
- data/ CAN import from domain/ (implements interfaces)
- Use dependency injection to invert data → domain dependency
## UseCase pattern
```dart
// One UseCase = one operation = one method
class GetProductsUseCase {
final ProductRepository _repository;
const GetProductsUseCase(this._repository);
Future<Either<AppError, List<Product>>> call(ProductFilter filter) =>
_repository.getProducts(filter);
}
```
## Entity rules
- Entities are pure Dart — zero Flutter or framework imports
- Entities are immutable — use `final` fields + factory constructors
- Entities NEVER have `fromJson`/`toJson` — that belongs in the data layer model
## Repository rules
- Domain defines the **interface** (abstract class)
- Data layer **implements** it
- Use `Either<Failure, T>` or `Result<T>` return types — never throw in domain
## Dependency injection
- Use `injectable` + `get_it` if `codegen` includes `injectable`
- All UseCases injected into BLoC/Notifier via constructor
- `DataSource → Repository → UseCase → Bloc` dependency direction
@@ -0,0 +1,49 @@
---
description: "Firebase conventions for TestApp"
alwaysApply: true
---
# Firebase Standards — TestApp
## Firestore
- Collection names: `camelCase` plural — `users`, `products`, `orderItems`
- Document IDs: use Firebase auto-IDs unless a natural key exists
- **Streams vs Futures**: use `snapshots()` for live data, `get()` for one-time reads
- Always handle `FirebaseException` explicitly — catch by `e.code` not generic `Exception`
- Paginate large collections with `startAfterDocument` — never fetch unbounded collections
```dart
// ✅ Stream-based real-time listener
Stream<List<Product>> watchProducts() {
return _firestore.collection('products')
.where('isActive', isEqualTo: true)
.snapshots()
.map((snap) => snap.docs.map(Product.fromDoc).toList());
}
// ✅ Handle FirebaseException by code
try {
await _firestore.collection('orders').add(order.toMap());
} on FirebaseException catch (e) {
switch (e.code) {
case 'permission-denied': throw AppError.authError('Insufficient permissions');
case 'unavailable': throw AppError.networkError();
default: throw AppError.unknown(e);
}
}
```
## Firebase Auth
- Always use `authStateChanges()` stream — never cache auth state locally
- Handle all error codes: `user-not-found`, `wrong-password`, `email-already-in-use`, `network-request-failed`
- Sign-out: clear all local state AND call `FirebaseAuth.instance.signOut()`
## Cloud Functions
- Call via `FirebaseFunctions.instance.httpsCallable('functionName')`
- Handle `FirebaseFunctionsException` with `.code` and `.message`
- Never expose internal errors to client — functions return structured error responses
## Security (complement to security-standards.mdc)
- Firestore Security Rules must be tested with the emulator before deploying
- No `allow read, write: if true` — even in development
- Rule coverage: every collection must have explicit rules
@@ -0,0 +1,64 @@
---
description: "Freezed code generation conventions for TestApp — Pillar 4"
alwaysApply: true
---
# Freezed Standards — TestApp
## When to use Freezed
- All domain entities and data models — use `@freezed`
- Union types / sealed classes — use `@freezed` with multiple constructors
- BLoC states and events — use `@freezed`
## Model pattern
```dart
@freezed
class Product with _$Product {
const factory Product({
required String id,
required String name,
required double price,
@Default(0) int stockCount,
String? imageUrl,
}) = _Product;
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
}
```
## Union type pattern
```dart
@freezed
sealed class ProductState with _$ProductState {
const factory ProductState.initial() = ProductInitial;
const factory ProductState.loading() = ProductLoading;
const factory ProductState.loaded(List<Product> products) = ProductLoaded;
const factory ProductState.error(String message) = ProductError;
}
// Usage — exhaustive switch
final widget = state.when(
initial: () => const SizedBox.shrink(),
loading: () => const ProductShimmer(),
loaded: (products) => ProductList(products: products),
error: (msg) => ErrorWidget(message: msg),
);
```
## Critical rules
- **NEVER** edit `.freezed.dart` or `.g.dart` files — they are generated
- Run `dart run build_runner build --delete-conflicting-outputs` after changes
- Run `dart run build_runner watch` during development
- Add `*.freezed.dart` and `*.g.dart` to `.gitignore` (or commit them — team must decide)
- Always define `copyWith` via Freezed — never write manual `copyWith`
## Patterns to avoid
```dart
// ❌ Manual copyWith — replaced by Freezed
Product copyWith({String? name, double? price}) => Product(
id: id, name: name ?? this.name, price: price ?? this.price,
);
// ✅ Let Freezed generate it
product.copyWith(name: 'New Name', price: 9.99)
```
@@ -0,0 +1,67 @@
---
description: "Global error handling strategy for TestApp — always applied"
alwaysApply: true
---
# Global Error Handling — TestApp
## Flutter error boundaries
Configure in `main.dart` — do this ONCE and never bypass it:
```dart
void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
// TODO: Send to crash reporter (Sentry/Firebase Crashlytics)
crashReporter.recordFlutterError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
// TODO: Send to crash reporter
crashReporter.recordError(error, stack, fatal: true);
return true;
};
runApp(const MyApp());
}
```
## Error types hierarchy
Define a sealed class for domain errors — never throw raw exceptions in business logic:
```dart
sealed class AppError {
const AppError();
}
class NetworkError extends AppError { final int? statusCode; const NetworkError({this.statusCode}); }
class AuthError extends AppError { final String reason; const AuthError(this.reason); }
class NotFoundError extends AppError { final String resource; const NotFoundError(this.resource); }
class UnknownError extends AppError { final Object cause; const UnknownError(this.cause); }
```
## Repository layer
- Wrap ALL external calls in try/catch and return `Either<AppError, T>` or `Result<T>`
- NEVER let raw exceptions bubble to the presentation layer
- Log at the repository layer, not the UI layer
## Presentation layer
- Every async widget MUST handle error state explicitly — no silent failures
- Show user-friendly error messages: map `AppError` subtype → readable string
- Provide a "Try again" action for recoverable errors (network, timeout)
- For fatal errors (auth expired), redirect to login — never show a dead screen
## Crash reporting
- Integrate Sentry or Firebase Crashlytics before first TestFlight / Play beta
- Set `user` context on crash reporter after login (id only, no PII)
- Add `breadcrumbs` for key user actions to aid reproduction
## Logging strategy
```
Level | Use case
DEBUG | Development only (strip from release)
INFO | Key user flows (login, purchase, etc.)
WARNING | Recoverable errors, fallbacks used
ERROR | Unrecoverable errors, unexpected states
```
- Use `logger` package — never bare `print()`
- Logger instance per class: `final _log = Logger('ClassName');`
@@ -0,0 +1,34 @@
---
description: "Android-specific conventions for TestApp — Pillar 4"
alwaysApply: true
---
# Android Platform Standards — TestApp
## Target SDK
- `compileSdkVersion` / `targetSdkVersion`: 34 (Android 14) minimum for new apps
- `minSdkVersion`: 21 (Android 5.0) unless brief specifies otherwise
- Update `android/app/build.gradle` when bumping target SDK
## Permissions
- Declare only needed permissions in `AndroidManifest.xml`
- Runtime permissions: use `permission_handler` — never skip rationale step
- Android 13+: granular media permissions (`READ_MEDIA_IMAGES` not `READ_EXTERNAL_STORAGE`)
- Android 14+: `FOREGROUND_SERVICE_TYPE` required for foreground services
## Adaptive icons
- Provide both foreground and background layers in `android/app/src/main/res/`
- Test on dark theme, coloured theme, and themed icons (Android 13+)
## Deep links / App Links
- Verify domain ownership: `.well-known/assetlinks.json` on your server
- Test with: `adb shell am start -a android.intent.action.VIEW -d "https://TestApp.com/products/123"`
## ProGuard / R8
- Obfuscation rules in `android/app/proguard-rules.pro`
- Keep rules for: Dio, Freezed models, `@JsonKey` annotated classes
- Test release build thoroughly — obfuscation can break reflection-based code
## Notifications
- Create notification channels before showing any notification (Android 8+)
- Notification icons must be monochrome on Android 5+
@@ -0,0 +1,35 @@
---
description: "iOS-specific conventions for TestApp — Pillar 4"
alwaysApply: true
---
# iOS Platform Standards — TestApp
## Platform-specific imports
- Use `dart:io` checks: `if (Platform.isIOS)` for conditional code
- iOS-only plugins: declare in `pubspec.yaml` with platform filter
- **NEVER** call `dart:io` directly in shared code — use `platform_channel` or `universal_io`
## iOS-specific requirements
- Minimum deployment target: iOS 13.0 (or as specified in `ios/Podfile`)
- Privacy manifests: `ios/Runner/PrivacyInfo.xcprivacy` — required for App Store since 2024
- Required `Info.plist` keys before using:
- Camera: `NSCameraUsageDescription`
- Photo library: `NSPhotoLibraryUsageDescription`
- Location: `NSLocationWhenInUseUsageDescription`
- Notifications: handled via `permission_handler`
## Push notifications (iOS)
- Configure APNs certificates in Xcode signing & capabilities
- Request permission with `permission_handler` — show rationale screen first
- Handle foreground vs background vs terminated app states separately
- Test on a physical device — iOS simulator does not support push
## Safe area
- Always wrap root scaffold with `SafeArea` or use `MediaQuery.of(context).padding`
- Dynamic Island / notch: test on iPhone 14 Pro and iPhone 15 Pro simulators
## App Store compliance
- Screenshot: use `ScreenshotController` to exclude sensitive screens
- Sign in with Apple: required if any third-party social login is offered
- IPv6 compatibility required — no IPv4-only network code
@@ -0,0 +1,61 @@
---
description: "GoRouter conventions for TestApp"
alwaysApply: true
---
# GoRouter Standards — TestApp
## Typed routes (mandatory)
```dart
// Define typed routes — never use string paths directly in navigation calls
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
const HomeRoute();
@override Widget build(BuildContext ctx, GoRouterState state) => const HomeScreen();
}
@TypedGoRoute<ProductRoute>(path: '/products/:id')
class ProductRoute extends GoRouteData {
final String id;
const ProductRoute({required this.id});
@override Widget build(BuildContext ctx, GoRouterState state) => ProductScreen(id: id);
}
// Navigate with type safety
const ProductRoute(id: product.id).go(context); // ✅
context.go('/products/${product.id}'); // ❌ — don't do this
```
## Auth guard
```dart
// Redirect logic in router config
redirect: (context, state) {
final isLoggedIn = ref.read(authProvider).isAuthenticated;
final isLoginRoute = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoginRoute) return '/login';
if (isLoggedIn && isLoginRoute) return '/';
return null;
},
```
## Shell routes for bottom navigation
```dart
ShellRoute(
builder: (ctx, state, child) => MainScaffold(child: child),
routes: [
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()),
],
)
```
## Deep links
- Register URL schemes in `Info.plist` (iOS) and `AndroidManifest.xml`
- Test deep links with: `adb shell am start -a android.intent.action.VIEW -d "app://com.test.testapp/products/123"`
- Handle `GoRouter.of(context).routerDelegate.currentConfiguration` for dynamic links
## Rules
- **NEVER** use `Navigator.push/pop` — use `context.go()`, `context.push()`, `context.pop()`
- All routes declared in one file: `lib/core/routing/app_router.dart`
- `BlocProvider`s for route-level blocs created inside the `builder` of each route
@@ -0,0 +1,109 @@
---
description: "Security standards for TestApp — ALWAYS APPLIED on every file write"
alwaysApply: true
---
# Security Standards — TestApp
> **Pillar 5**: Security is an always-on rule, not just a reactive agent.
> These rules apply to EVERY file write, regardless of feature or context.
## Credential & secret management
- **NEVER** hardcode API keys, tokens, or secrets in source code
- **NEVER** commit `.env` files — use `.gitignore` and document required vars in `.env.example`
- API keys belong in: flavored `--dart-define` build args, or a secrets manager (e.g. AWS Secrets)
- Use `flutter_secure_storage` for tokens/credentials — **NEVER** `SharedPreferences` for sensitive data
- Obfuscation: enable `--obfuscate --split-debug-info=build/debug-symbols/` for release builds
## Authentication & sessions
- JWT/session tokens: stored in `flutter_secure_storage`, never in `SharedPreferences` or local DB
- Implement token refresh with retry logic — never let a 401 show a raw error to the user
- Certificate pinning: required for production builds on Firebase Auth — use `dio_pinning_interceptor`
- Biometric re-auth: require for any transaction > defined threshold
## Data & privacy
- **NEVER** log PII (names, emails, phone numbers, addresses) to console or crash reporters
- Sanitize all user inputs before sending to backend
- Use `SensitiveWidget` wrapper to exclude screens from screenshots / app switcher thumbnails
- Comply with platform privacy requirements: iOS `NSPrivacyAccessedAPITypes`, Android permissions
## Network security
- All API calls use HTTPS — no http:// in production
- Validate SSL certificates — never bypass with `badCertificateCallback: (_,_,_) => true`
- Add a timeout to every HTTP call: `connectTimeout: Duration(seconds: 10)`
- Rate-limit sensitive endpoints: auth, OTP, password reset
## Dependency security
- Run `dart pub outdated` monthly — flag packages with known CVEs
- Never use a package with <100 pub points without explicit tech lead approval
- Pin critical security packages to exact versions in `pubspec.yaml`
## Secure coding patterns
```dart
// ✅ Correct: secure token storage
final storage = FlutterSecureStorage();
await storage.write(key: 'access_token', value: token);
// ❌ Wrong: SharedPreferences for sensitive data
final prefs = await SharedPreferences.getInstance();
prefs.setString('access_token', token); // NEVER do this
// ✅ Correct: PII-safe logging
logger.info('User authenticated: userId=${user.id}'); // ok — id, not email
logger.debug('Payment processed: orderId=$orderId'); // ok — no card data
// ❌ Wrong: PII in logs
logger.info('Login: email=${user.email}, phone=${user.phone}'); // NEVER
// ✅ Correct: --dart-define for secrets (build arg, not in code)
// flutter build apk --dart-define=API_KEY=$API_KEY
const apiKey = String.fromEnvironment('API_KEY'); // acceptable
// ✅ Correct: certificate pinning with Dio
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.badCertificateCallback = (cert, host, port) => false; // strict
return client;
};
```
## Deep linking & intent security
- Validate all incoming deep link parameters — never trust raw URL params
- Use `app_links` package for verified deep link handling
- Restrict URL schemes to known patterns — reject unknown schemes
- For OAuth callbacks: validate `state` parameter to prevent CSRF
## Storage security
- Encrypt locally cached sensitive data using `hive` with `HiveAesCipher`
- Clear sensitive data from memory after use (set to null, trigger GC)
- Do NOT cache auth tokens in network layer (no `dio_cache_interceptor` for auth endpoints)
- Use `SecureRandom` for nonce/token generation — never `Random()`
## Code obfuscation & binary protection
```yaml
# android/app/build.gradle — release config
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
```
```bash
# Flutter release build with obfuscation
flutter build apk --release --obfuscate --split-debug-info=build/debug-symbols/
flutter build ipa --release --obfuscate --split-debug-info=build/debug-symbols/
```
## Release checklist
Before every production release, verify:
- [ ] No hardcoded secrets (`grep -r "api_key\|secret\|password\|token" lib/`)
- [ ] Debug flags disabled (`kDebugMode` guards on all debug-only paths)
- [ ] Obfuscation enabled in release build config
- [ ] Certificate pinning active and tested on both platforms
- [ ] Crash reporter PII filters configured (Sentry `beforeSend`, Firebase `setConsentType`)
- [ ] `flutter_secure_storage` used for all tokens — no `SharedPreferences` for sensitive keys
- [ ] Network calls all HTTPS — scan for `http://` in lib/
- [ ] Dependencies audited: `dart pub outdated --json | jq '.packages[] | select(.isDiscontinued)'`
- [ ] Deep link parameters validated
- [ ] App transport security / network security config reviewed
@@ -0,0 +1,64 @@
---
description: "BLoC / Cubit conventions for TestApp"
alwaysApply: true
---
# BLoC / Cubit Standards — TestApp
## When to use BLoC vs Cubit
- **Cubit**: simple state with no meaningful event history (toggle, counter, pagination)
- **BLoC**: event-driven flows where event history or transitions matter (auth, checkout, form wizard)
## Event and State classes
```dart
// Events — sealed class (exhaustive switch)
sealed class AuthEvent { const AuthEvent(); }
final class AuthLoginRequested extends AuthEvent {
final String email, password;
const AuthLoginRequested({required this.email, required this.password});
}
final class AuthLogoutRequested extends AuthEvent { const AuthLogoutRequested(); }
// States — sealed class, immutable
sealed class AuthState { const AuthState(); }
final class AuthInitial extends AuthState { const AuthInitial(); }
final class AuthLoading extends AuthState { const AuthLoading(); }
final class AuthAuthenticated extends AuthState {
final User user;
const AuthAuthenticated(this.user);
}
final class AuthUnauthenticated extends AuthState { const AuthUnauthenticated(); }
final class AuthFailure extends AuthState {
final String message;
const AuthFailure(this.message);
}
```
## BlocProvider placement
- Create `BlocProvider` at the **route level** (in GoRouter route definitions)
- `MultiBlocProvider` at route level for screens needing multiple blocs
- **NEVER** create `BlocProvider` inside a widget's `build()` method
## Usage rules
- **NEVER** call `bloc.add()` inside `build()` — only in gesture callbacks or `initState()`
- Use `BlocConsumer` only when BOTH `listen` + `build` logic are needed
- Use `BlocSelector` when only a subset of state triggers a rebuild
- Every BLoC must override `close()` and cancel `StreamSubscription`s
## BlocBuilder patterns
```dart
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => switch (state) {
AuthInitial() => const SizedBox.shrink(),
AuthLoading() => const LoadingIndicator(),
AuthAuthenticated(user: final u) => HomeScreen(user: u),
AuthUnauthenticated() => const LoginScreen(),
AuthFailure(message: final m) => ErrorScreen(message: m),
},
)
```
## File locations in TestApp
- `lib/features/[feature]/presentation/bloc/[feature]_bloc.dart`
- `lib/features/[feature]/presentation/bloc/[feature]_event.dart`
- `lib/features/[feature]/presentation/bloc/[feature]_state.dart`
@@ -0,0 +1,60 @@
---
description: "BLoC testing conventions for TestApp"
alwaysApply: false
---
# BLoC Testing Standards — TestApp
## Test pattern (bloc_test)
```dart
// blocTest<MyCubit, MyState>(description, build: () => MyCubit(), act: (c) => c.method(), expect: () => [MyState()])
void main() {
late AuthBloc authBloc;
late MockAuthRepository mockRepo;
setUp(() {
mockRepo = MockAuthRepository();
authBloc = AuthBloc(repository: mockRepo);
});
tearDown(() => authBloc.close());
group('AuthBloc', () {
blocTest<AuthBloc, AuthState>(
'emits [Loading, Authenticated] when login succeeds',
build: () {
when(() => mockRepo.login(any(), any()))
.thenAnswer((_) async => const Right(User(id: '1', email: 'test@test.com')));
return authBloc;
},
act: (bloc) => bloc.add(const AuthLoginRequested(email: 'test@test.com', password: 'pass')),
expect: () => [
const AuthLoading(),
isA<AuthAuthenticated>(),
],
);
blocTest<AuthBloc, AuthState>(
'emits [Loading, Failure] when login fails',
build: () {
when(() => mockRepo.login(any(), any()))
.thenAnswer((_) async => const Left(AuthError('Invalid credentials')));
return authBloc;
},
act: (bloc) => bloc.add(const AuthLoginRequested(email: 'bad', password: 'bad')),
expect: () => [
const AuthLoading(),
const AuthFailure('Invalid credentials'),
],
);
});
}
```
## Rules
- Use `mocktail` for mocking — never `mockito`
- Every BLoC test file: `test/features/[feature]/[feature]_bloc_test.dart`
- Coverage requirement: all state transitions must be tested
- Use `Given/When/Then` naming in test descriptions
- Test error paths as thoroughly as success paths
@@ -0,0 +1,40 @@
---
description: "Core Flutter conventions for TestApp — always applied"
alwaysApply: true
---
# Flutter Core Standards — TestApp
## Const and performance
- Use `const` constructors wherever possible — compile-time guarantee of no rebuild
- Prefer `const` widgets at the leaf level: `const SizedBox.shrink()`, `const Padding(...)`
- Never use `const` with mutable values; lint: `prefer_const_constructors` is enabled
## Null safety
- Never use `!` (bang operator) unless you have a 100% safe runtime guarantee with a comment
- Prefer `??`, `?.`, and `if (x != null)` guards
- Use `required` for all non-nullable named parameters
- Never use `late` without a guarantee of initialisation before first access
## Widget lifecycle
- Override `dispose()` in every `StatefulWidget` that uses controllers, streams, or timers
- Cancel `StreamSubscription` in `dispose()`, not in `didUpdateWidget`
- Use `WidgetsBinding.instance.addPostFrameCallback` for post-build logic, not `Future.delayed(Duration.zero)`
## Naming conventions
- Files: `snake_case.dart`
- Classes: `PascalCase`
- Variables/functions: `camelCase`
- Constants: `kCamelCase` or `SCREAMING_SNAKE` for true compile-time constants
- Private members: `_camelCase`
## Imports
- Order: dart: → package: → relative
- Use relative imports within a feature; absolute for cross-feature
- Never import a feature's internal files from outside that feature
## Code quality
- Max function length: 40 lines. Extract widgets and helpers aggressively
- No `print()` in production code — use a logging package
- All `TODO:` comments must include a ticket number: `// TODO: PROJ-123 — fix this`
- Run `dart format` before every commit
@@ -0,0 +1,67 @@
---
description: "Project context for TestApp — always applied"
alwaysApply: true
---
# Project Context — TestApp
## Project identity
- **Name:** TestApp
- **Package:** com.test.testapp
- **Description:** Test app for golden tests
- **Scale:** medium
## Technology stack
- **State management:** BLoC / Cubit
- **Architecture:** Clean Architecture
- **Routing:** GoRouter
- **Backend:** Firebase
- **Auth:** Firebase Auth
- **Platforms:** ios, android
- **Code generation:** freezed
## Feature modules
auth, home, products
## Special capabilities
## Environments / flavors
- Flavors: dev, prod
- CI/CD: GitHub Actions
## Design & API references
- Design source: none
- API docs: none at ``
## Code references
### Git repositories
_No Git repository URLs listed._ Add entries under `references.repos` in project-brief.yaml when other repos are part of the product context.
### Local paths
_No local paths listed._ Add monorepo packages or sibling folders under `references.local_paths` in project-brief.yaml when relevant.
## Product UX / themes & roles
- **Theme variants:** light, dark
- **Roles:** Not enabled (`app_context.roles_enabled: false`).
## Reviews — which rule owns what
- **Theme, colors, typography, spacing/radius tokens** → `ui-ux-standards.mdc` (widgets read `Theme.of(context)` only)
- **User-visible copy & locales** → `localization.mdc` (ARB / `AppLocalizations`; no UI string literals)
- **Imports, structure, naming** → `flutter-core.mdc` + architecture rule
## Architecture boundaries
- presentation/ MUST NOT import from data/
- domain/ MUST NOT import from data/ or presentation/
- data/ CAN import from domain/ (implements interfaces)
- Use dependency injection to invert data → domain dependency
## When generating code for this project
1. Always use BLoC / Cubit patterns — never suggest alternatives
2. Always follow Clean Architecture folder structure
3. Always use GoRouter for navigation — never `Navigator.push` directly
4. Always target platforms: ios, android
5. If code generation tools are used (freezed), follow their conventions
6. Apply visuals only through theme (`ColorScheme`, `TextTheme`, `ThemeExtension`) — never ad-hoc colors/fonts in feature widgets
7. No user-facing string literals in widgets — l10n or shared constants per localization rule
@@ -0,0 +1,48 @@
---
description: "UI/UX standards for TestApp — always applied"
alwaysApply: true
---
# UI / UX Standards — TestApp
## Theme & design tokens (single source of truth)
- Define **one** light/dark `ThemeData` (and optional `ThemeExtension`s for brand spacing, radii, semantic colors). Feature code reads `Theme.of(context)` only.
- **Colors:** `colorScheme` / extensions — never hex/`Color(...)` literals in widgets except inside the theme definition file(s).
- **Typography:** `textTheme` / `primaryTextTheme` — never raw `TextStyle(fontSize:, fontFamily:)` in feature UI.
- **Spacing & shapes:** `ThemeExtension` or documented constants consumed consistently — avoid one-off magic numbers for padding/radius.
## Loading states
- Every async operation MUST show a loading skeleton (shimmer), NOT a spinner unless < 300ms
- Use `shimmer` package with a shimmer that matches the final layout shape
- Never show a blank screen during loading — skeleton must fill the same space as the content
## Empty states
- Every list/grid MUST have a distinct empty state widget: illustration + headline + CTA
- Empty state is different from error state — never reuse the same widget for both
- Empty state copy: positive framing ("No items yet — add your first one")
## Error states
- Every async failure MUST show: error message + retry button
- Never swallow errors silently
- Error text: user-friendly, never expose stack traces or raw API messages
## Navigation & transitions
- Use `IndexedStack` for bottom nav tabs — preserves scroll position
- Named routes only — never `Navigator.push(context, MaterialPageRoute(...))`
- Page transitions: use `CustomTransitionPage` with `FadeTransition` for modal sheets
## Responsive layout
- Use `LayoutBuilder` or `MediaQuery` for breakpoints, not hardcoded pixel values
- Minimum touch target: 48×48 logical pixels (Material guideline)
- Test on 375px (iPhone SE) and 414px (iPhone Pro Max) widths minimum
## Haptics
- Use `HapticFeedback.lightImpact()` on primary CTAs
- Use `HapticFeedback.selectionClick()` on toggle/checkbox interactions
- Never add haptics to destructive actions without confirmation
## Accessibility
- All interactive widgets must have a `Semantics` label or `tooltip`
- Minimum contrast ratio: 4.5:1 (WCAG AA)
- Test with TalkBack / VoiceOver before each release
@@ -0,0 +1,18 @@
# Create Build Flavor — TestApp
Creates a new build flavor/environment. Current flavors: dev, prod.
## Usage
```
Create a new flavor called [flavor_name]
```
## What gets created
1. Android: new `productFlavors` block in `android/app/build.gradle`
2. iOS: new scheme + configuration in Xcode (instructions provided)
3. `lib/core/config/[flavor_name]_config.dart`
4. `.env.[flavor_name]` template
5. GitHub Actions pipeline update
## CI/CD: GitHub Actions
Generate the pipeline config snippet for the new flavor in GitHub Actions format.
@@ -0,0 +1,21 @@
# Deploy — TestApp
Guides through deployment to GitHub Actions for any of the flavors: dev, prod.
## Usage
```
Deploy [flavor] to [store/environment]
```
## GitHub Actions pipeline
The AI will generate or update the github_actions configuration file for:
- Build signing
- Running tests
- Distributing to Firebase App Distribution (beta) or App Store / Play Store (prod)
## Pre-deploy checklist
- [ ] Version bump in `pubspec.yaml`
- [ ] Obfuscation enabled for prod: `--obfuscate --split-debug-info=build/debug-symbols/`
- [ ] No debug flags in production code
- [ ] Security checklist from `security-standards.mdc` passed
- [ ] `cursor_gen --validate` passes
@@ -0,0 +1,41 @@
# Generate Tests — TestApp
Generates comprehensive unit, widget, and integration tests for **BLoC / Cubit** patterns.
## Usage
```
Generate tests for [ClassName or file path]
```
## Test generation process
1. Read the source file completely
2. Identify all testable units (public methods, state transitions, UI states)
3. Generate tests following this pattern:
```
blocTest<MyCubit, MyState>(description, build: () => MyCubit(), act: (c) => c.method(), expect: () => [MyState()])
```
4. Create mocks with `mocktail` for all dependencies
5. Place test file at `test/[mirror of source path]_test.dart`
## Coverage targets
- Business logic (UseCases, Repositories, BLoC/Notifiers): **80% minimum**
- Widget tests: all three states (loading/error/data) must be tested
- E2E: only critical user flows
## Test file structure
```dart
void main() {
// Setup
group('[ClassName]', () {
// Happy path tests
group('success cases', () { ... });
// Error path tests
group('error cases', () { ... });
// Edge cases
group('edge cases', () { ... });
});
}
```
## Naming convention
`'given [precondition], when [action], then [expected outcome]'`
@@ -0,0 +1,69 @@
# Scaffold Feature — TestApp
Scaffolds a complete new feature module following **Clean Architecture** architecture with **BLoC / Cubit** state management.
## Usage
```
Create a feature called [feature_name] with [description]
```
## What gets generated
### For Clean Architecture architecture:
The AI will create all necessary files for the `[feature_name]` feature following Clean Architecture patterns.
### File structure to create:
**Clean Architecture:**
```
lib/features/[feature_name]/
domain/
entities/[feature_name].dart
repositories/[feature_name]_repository.dart
usecases/
get_[feature_name]_usecase.dart
create_[feature_name]_usecase.dart
data/
models/[feature_name]_dto.dart
datasources/[feature_name]_remote_datasource.dart
repositories/[feature_name]_repository_impl.dart
presentation/
[state_files]/ ← based on BLoC / Cubit
pages/[feature_name]_page.dart
widgets/
```
**Feature-First:**
```
lib/features/[feature_name]/
[feature_name]_screen.dart
[feature_name]_provider.dart ← or [feature_name]_bloc.dart
[feature_name]_repository.dart
[feature_name]_model.dart
widgets/
```
## State management boilerplate
### For BLoC / Cubit:
Generate the appropriate state management files with:
- Initial/loading/success/error states
- All necessary events (for BLoC)
- Repository connection
- Dependency injection registration
## Steps the AI takes
1. Ask: "What is the feature name and brief description?"
2. Ask: "What data does this feature manage? (e.g., list of products, single user profile)"
3. Generate all files with correct imports and patterns
4. Add the feature to the DI container
5. Add the route to GoRouter router
6. Create a placeholder test file
## Code generation
{{#if codegen_freezed}}
- Generate Freezed model: run `dart run build_runner build` after scaffolding
{{/if}}
{{#if codegen_injectable}}
- Register in injectable: add `@lazySingleton` to repository
{{/if}}

Some files were not shown because too many files have changed in this diff Show More