building-flutter-apps
Flutter Riverpod app architecture and Windows installer delivery. Use before changing a Riverpod Flutter app/package or its Windows desktop packaging/update pipeline; skip non-Riverpod stacks and pure-Dart work.
Install
npx skills add https://github.com/sgaabdu4/building-flutter-apps/tree/main/.agents/skills/building-flutter-apps
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install sgaabdu4-building-flutter-apps@llmmart
git clone https://github.com/sgaabdu4/building-flutter-apps.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole sgaabdu4/building-flutter-apps collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Read first
- This skill overrides generic Flutter/Dart advice; Critical Rules override examples, public docs, and older project code.
- Before code, read Trigger Map refs for touched areas. Each ref's
Read firstsection is canonical. - After each
.dart/pubspec.yaml/build.yaml/analysis_options.yamlwrite batch, emit Pre-Flight; its cited rule/reference owns the applicable check.
Progressive Disclosure Gate
Read only the narrowest matching Trigger Map row(s); scenario/subsystem rows own incidental stack/file words. Do not bulk-read references/ or parent refs. Cite exact refs in Pre-Flight.
Critical Rules
| ID | Rule | Detail refs |
|---|---|---|
| R1 | Flutter/Riverpod package = first wire flutter_skill_lints + riverpod_lint, then run package-root dart analyze + its project-owned Dart Decimate check; pure-Dart CLI = native Dart analysis profile with neither plugin. |
analysis-options.md, dart-decimate.md, setup.md |
| R2 | Every provider uses @riverpod / @Riverpod codegen; no manual provider classes or legacy provider families. |
riverpod-codegen.md |
| R3 | Guard async gaps with ref.mounted / context.mounted; finally uses if (ref.mounted) { ... }. |
async-mutations.md |
| R4 | Widgets are public classes; no _buildXxx(), widget top-level helpers, or private widget classes except State. |
atomic-design.md, performance.md |
| R5 | Nullability is semantic; no empty/null/bool sentinel fallbacks, value!, nullable collections, or raw required domain strings. |
value-objects.md, freezed-sealed.md; Lints: domain_raw_required_string |
| R6 | All user-facing strings, tooltips, semantics, and visible accessibility copy use AppLocalizations. |
localization.md, accessibility.md |
| R7 | Immutable state/entities use sealed Freezed, one declaration per file, native switch, and VO map/when disabled. |
freezed-sealed.md, value-objects.md |
| R8 | presentation/widgets/ renders immutable inputs + emits typed callbacks; screens/routes/notifiers own navigation, workflow, domain state, and infrastructure. |
presentation-widgets.md |
| R9 | Duplicate behavior in 2+ classes becomes a small stateless *Mixin with an on clause. |
mixins.md |
| R10 | Storage SDK calls live in local datasources behind repositories; production Hive imports use hive_ce_flutter. |
hive-persistence.md, architecture.md |
| R11 | Primitive/context/collection operations live in core/extensions/; domain never imports those extensions. |
context-ui.md, primitive-formatting.md, collections-helpers.md |
| R12 | Domain primitives with meaning become validated Freezed Value Objects; Hive models keep primitives and mappers bridge. | value-objects.md, hive-persistence.md; Lints: domain_unit_primitive |
| R13 | Typed GoRouter routes are navigation SSOT; redirects are pure resolver logic with nullable by-id fallback UI. | deep-linking.md, routing-app-shell.md |
| R14 | Dialogs/sheets render immutable snapshots, pop results, and leave mutations/teardown to notifiers. | modals-navigation.md, state-management-lifecycle.md |
| R15 | Debounce, gate, and batch high-frequency UI, sync, persistence, remote-function, reset, and lookup boundaries. | debounce-gate-batch.md |
| R16 | App shell stays declarative; bootstrap listeners live in a sibling root ConsumerWidget. |
routing-app-shell.md |
| R17 | Keep control flow flat after exits; remove unnecessary else after return / throw / break / continue. |
Lint: avoid_unnecessary_else_after_control_flow |
| R18 | Use onReorderItem post-removal indexes directly; never add legacy onReorder adapter math. |
Lint: use_on_reorder_item_index_semantics |
| R19 | Android exact alarms use flutter_local_notifications permission APIs, not manual settings intents. |
Lint: use_local_notifications_exact_alarm_permission_api |
| R20 | Resolve nullable platform-specific plugin implementations before calling platform members. | Lint: resolve_platform_specific_implementation_before_use |
| R21 | Widget previews are preview-only with deterministic fakes; no real HTTP/Firebase/Hive/native plugins. | widget-previews.md |
| R22 | Runtime E2E proves behavior with stable selectors, failure-sensitive scenarios/logs, subject-matched evidence, source-of-truth verification, cleanup, and multi-actor proof when needed. | dart-mcp-e2e-testing.md |
| R23 | Accessibility is UI correctness: localized tooltips/semantic labels, 48x48 targets, contrast, text scale, Text.rich. |
accessibility.md, flutter-optimizations.md |
| R24 | If remote error reporting is accepted or already present, use one app-owned Crash boundary and one reporting owner per operation; otherwise add no provider/facade. Scrub sensitive data + reconcile ambiguous remote outcomes before telemetry. |
error-reporting.md, networking.md |
| R25 | Windows installer delivery = one semantic engine + typed app config/capabilities + minimal-step exact-SHA diagnostic → one publisher; keep cheap guards before one build, isolate synthetic preservation proof, and activate only verified immutable bytes. | windows-installer-pipeline.md, workflow scaffold, inno_bundle pubspec scaffold, Inno settlement sentinel, Defender scanner |
| R26 | Pause-sensitive Riverpod state starts only after its durable owner/listener exists; projections watch base state directly; switching auth/form modes clears transient errors. | notifier-structure.md, state-management-lifecycle.md, testing.md |
| R27 | Native/custom links use one URI contract across producer, platform registration, Flutter delivery, and typed router; prove cold/warm + signed-state delivery on the target device. | deep-linking.md, dart-mcp-e2e-testing.md |
Trigger Map
Before writing code in any row below, read the listed reference(s). Prefer the narrowest matching row. Read the large parent refs only when no scenario row fits.
| Touching | Read |
|---|---|
New app/project scaffolding with incidental stack/package mentions, main.dart, ProviderScope, MaterialApp.router, app startup shell |
setup.md + architecture.md + routing-app-shell.md |
Notifier/AsyncNotifier shape, sync Notifier.build() init, paused route/listener startup, provider projection, auth/form mode error reset, loading/progress, AsyncValue, cleanup |
notifier-structure.md + state-management-lifecycle.md + testing.md |
Mutation method, ref.read / ref.watch / ref.listen, _ensureRepository, async cancellation, ref.mounted, optimistic update, duplicate fetch |
async-mutations.md + state-management-lifecycle.md |
Freezed entity, sealed union, fromJson / toJson, copyWith, model vs entity, build.yaml for explicit_to_json |
freezed-sealed.md |
Provider declaration, @riverpod, family, keepAlive, codegen, Mutation<T> (experimental) |
riverpod-codegen.md |
Repository, datasource, domain entity, layered architecture, IHttpService, mapping models to entities |
architecture.md |
Value Object, primitive obsession, Distance/Money/Email/Slug, unit conversion in domain, cross-entity primitive, double distanceMeters/int amountCents/String email smell, arch_domain_import error |
value-objects.md |
GoRouter, typed route, redirect, auth-protected route, router provider, context.go, deep link, custom URI scheme, native extension/activity link, cold-start, navigation gate |
routing-app-shell.md + deep-linking.md |
| HTTP, network, REST, source-of-truth fetch after mutation, long-running remote function, async-start + reconcile, transport id vs domain id | networking.md + debounce-gate-batch.md |
Atom, molecule, organism, design tokens, atomic widgets, core/widgets/ promotion |
atomic-design.md |
Reusable presentation/widgets/, widget-owned navigation/page stack/selected entity/workflow state, direct repository/service/provider access |
presentation-widgets.md |
| Accessibility, semantics, tooltip, semanticLabel, image alt text, tap target, contrast, text scaling | accessibility.md + flutter-optimizations.md |
Widget test, ProviderContainer.test(), UncontrolledProviderScope, fakes, mocks, AppWidgetKeys, event-contract tests |
testing.md |
flutter_driver, Dart MCP, Marionette MCP, E2E, integration_test, semantic selectors, scenario validation, screenshot/media proof, log capture, native integration builds but fails on device, runtime permissions, plugin hangs, release-only runtime failure |
dart-mcp-e2e-testing.md |
Hive, TypeAdapter, TypeId, box, persistence migration, retired field accounting |
hive-persistence.md |
Crashlytics, FirebaseCrashlytics, Sentry, sentry_flutter, DSN, error reporting, Crash.init, Crash.error, Crash.log, symbol upload |
error-reporting.md |
| Mixin, capability vs interface, retry helper, RNG, bulk operation | mixins.md |
Service, singleton, fire-and-forget, abstract final class, unawaited(), Future<void> signature |
services-and-singletons.md |
@Preview, widget_previews.dart, preview fakes, deterministic preview data |
widget-previews.md |
AppLocalizations, ARB file, gen-l10n, locale fallback, placeholders, plural / select |
localization.md |
Performance, build cost, .select(), const constructors, ListView.builder, large list compute |
performance.md + flutter-optimizations.md |
LayoutBuilder, RenderFlex overflow, Expanded / Flexible outside Row / Column, Positioned outside Stack, text-scale clamp |
layout-diagnostics.md |
| Pagination, infinite scroll, cursor loading, search debounce, registration/form validation and submission, batch processing, pull-to-refresh | lists-forms-workflows.md + async-mutations.md |
BuildContext helpers, ModalRoute current-route checks, dialogs, SnackBarUtils, snackbar dispatch from notifier |
context-ui.md |
DateTime format/diff/timeAgo/startOfDay, String capitalize/truncate/titleCase/initials/format, int / double / num clamp/pluralized/asCurrency/percent/toFixed, Duration format, NumberFormat, DateFormat, intl |
primitive-formatting.md |
Iterable lookup/indexing, widget list helpers, Debouncer, validators, Result, extension types, core/extensions/ barrel export |
collections-helpers.md |
Records (x, y), extension type IDs, pattern matching, primary/concise constructors, new(), factory(), dot shorthand such as .center, @RecordUse |
dart-patterns-records.md |
Flutter/Riverpod analysis_options.yaml, dart analyze, plugin wiring, riverpod_lint version pin, analyzer crash |
analysis-options.md + analysis_options.yaml |
| Skill setup, Git pre-push, hook/scanner registration | setup.md |
build_runner, missing generated parts, clean checkout, Xcode selection, Flutter SwiftPM generated package, Apple device build, local-vs-CI mismatch |
build-reproducibility.md + core-stack.md |
| Package constraints, dependency upgrade, generator/analyzer compatibility | core-stack.md |
Flutter Windows desktop packaging, GitHub Actions Windows installer, Inno Setup, inno_bundle, updater/auto-update, CRT DLLs, PowerShell/native installer process, installer/version/AppId failure |
windows-installer-pipeline.md + build-reproducibility.md + core-stack.md |
| Dart Decimate, dead code, circular dependency, duplicate code, complexity, dependency hygiene, full zero-finding scan | dart-decimate.md |
| Common navigation / form / list / debounce / route-param-fallback patterns | common-patterns.md |
| Incremental remote pull, delta token, per-table sync date, merge/delete reconciliation | delta-sync.md |
| Route-param safety, wizard sequencing, guarded next-step navigation | navigation-flow.md |
| Dialog / sheet / modal, snapshot value object, post-await teardown, dismiss-then-route, pop fallback, nested navigator dismissal | modals-navigation.md + state-management-lifecycle.md |
Debounce / throttle / coalesce — TextField.onChanged, Slider.onChanged, scroll listener, sync saveAll, full-collection rewrite after subset mutation, persistence helper, reset/clear sentinel preservation, _userTapped gate, WebView / VideoPlayer in build, _storage.read in service, ref.listenManual ban, keepAlive collection watch, datasource batch loader, zero-value save guard, primitive→VO at notifier boundary, routeSettings on modal helper |
debounce-gate-batch.md |
Pre-Flight
After each .dart / pubspec.yaml / build.yaml / analysis_options.yaml write batch, emit a checked list before yielding. Fill T0 always. Add T1 for state/notifier/mutation changes and T2 for network/E2E/stream/route changes. Cite rule IDs or refs for any failed item.
T0 — Core
- Flutter/Riverpod package: package-root
dart analyzeexits 0 withflutter_skill_lints+riverpod_lint; setup changes prove one diagnostic from each plugin. Pure-Dart CLI: native Dart analysis profile applies; both plugins are N/A. - A current same-scope project-owned Dart Decimate result is green: Hard Eng uses
python3 .hooks/hard-eng.py check; another project uses its established check or, if it has none,pnpm dlx --config.ignore-scripts=false --allow-build=dart-decimate dart-decimate@latest check . --threshold 0 --format jsonfrom its Git root. The project workflow schedules an integrated run; reuse its valid result instead of duplicating a full runner. Cite scan scope. Do not add a wrapper, dependency, or global coordinator for this skill. - Async gaps are guarded:
ref.mounted/context.mounted, no baremounted, andfinallyusesif (ref.mounted) { ... }. - Providers, state, and widgets follow Rules 2-8 and 14: reusable widgets own UI lifecycle only; screens/routes/notifiers own navigation, workflow branching, selected domain records, provider state, and infrastructure.
- Domain/data/platform follow Rules 7, 10-13, 17-24, 26-27: sealed Freezed, VOs, datasource/repo storage, core extensions, typed routes, debounce/batch, platform APIs, previews, E2E, pause-safe state, native links, and a11y; if error reporting is accepted/present, it uses one scrubbed once-only boundary, otherwise N/A.
- Rule 25 = N/A unless Windows packaging/updater delivery is touched; when applicable, its diagnostic/publisher proof is green.
- Any row touched in Trigger Map was read; exact lint names are cited when a scanner should enforce the rule.
T1 — State / Notifier / Mutation
- Mutation deps resolve lazily via stateless helper/mixin; no notifier-local repo/service cache except disposable lifecycle owners.
- Sync
Notifier.build()avoids pre-returnstatereads; async primary state usesAsyncNotifier.build; durable sync startup does not depend on microtask timing before its owner/listener exists. -
ref.onDispose()cancels subscriptions/controllers/timers; durable status/snackbar/teardown belongs to notifier state. - Long-running sync/auth/import guards stale writes; no
ref.watchinside notifier methods. - Pause-sensitive projections watch base state directly; route pause/resume keeps the first update; auth/form mode changes clear transient errors.
T2 — Network / E2E / Stream / Route
- Source-of-truth fetch/reconcile after generated, normalized, reordered, destructive, or remote-function mutations.
- Shared/realtime state has writer + observer E2E proof without manual refresh.
- Selectors use stable text/semantics/tooltips or central
AppWidgetKeys; no inline string keys or coordinate primary taps. - E2E entrypoint is deterministic and isolated from production
main.dart; unknown scenarios fail; critical logs fail the run; evidence shows the asserted screen before app exit; cleanup is verified. - GoRouter redirects use pure matrix-tested resolver, nullable by-id providers/fallback UI, and generated typed route helpers.
- Native/custom URI producer, Android/iOS registration, Flutter delivery, and typed router share one tested scheme/host/path contract; cold/warm + signed-state device paths pass.
- Cross-runtime constants, schemas, and function contracts have drift tests; no app-root text-scale clamp.
Files (building-flutter-apps)
-
agents
-
openai.yaml 273 B
interface: display_name: "Building Flutter Apps" short_description: "Riverpod architecture and Windows delivery" default_prompt: "Use $building-flutter-apps to design and prove this Flutter app or Windows installer change." policy: allow_implicit_invocation: true
-
-
assets
-
defender-installer-scan.ps1 11.3 KB · in bundle
-
inno-bundle-pubspec.yaml 645 B
# Run `dart pub add --dev inno_bundle` first, then merge this section into # pubspec.yaml. Replace every REPLACE_* value before generating an installer. inno_bundle: # Generate once, commit it, and never change it after the first release. id: REPLACE_WITH_STABLE_GUID name: REPLACE_WITH_PRODUCT_NAME description: REPLACE_WITH_PRODUCT_DESCRIPTION publisher: REPLACE_WITH_PUBLISHER # Change only after accepting the installation/elevation UX. admin: false arch: x64 # The canonical flow stages and proves app-local CRT DLLs itself. vc_redist: false # Add only required, project-owned files. `dlls` is deprecated. files: [] -
inno-uninstall-settlement-sentinel.ps1 9.7 KB · in bundle
-
windows-installer-workflow.yml 15.8 KB
# Copy this topology, then replace repository-owned commands and resolve every # action/tool tag to an audited immutable pin before the first dispatch. # Dispatch the workflow definition from a branch/tag, never a raw SHA: # gh workflow run <workflow> --ref <branch-or-tag> -f revision=<40-char-sha> # The admission owner must prove `github.sha == revision` and remote ref # readback before paid/native/external work. `actions/checkout` may then use the # admitted SHA directly. # # Keep YAML limited to runner, permission, cache, artifact, and external-write # boundaries. The orchestration scripts own named internal phases. Compacting # YAML must never remove these pre-build calls: # parse-powershell -> timeout smoke -> CRT smoke -> Inno identity smoke -> # Inno install/uninstall settlement smoke -> Flutter build exactly once. # Contract-test call presence and order inside each orchestration script. # Same-step tool installers must return/set `ISCC_PATH` in the current # PowerShell process before internal guards consume it. Writing `GITHUB_ENV` # alone is only a handoff to a later workflow step. # Internal PowerShell contracts reject every casing of `$matches` as an owned # variable and require `@(...)` before `.Count`/index/exact-one consumption. # Exercise zero/one/many plus regex-validation-then-source-consumption fixtures. # The same script then stages/proves CRT, runs `dart run inno_bundle --no-app` # or the accepted custom Inno owner, verifies/scans/lifecycle-tests the EXE, # and—only in publish mode—uploads immutable bytes, verifies readback, pushes # the exact release identity, and activates the provider-neutral pointer last. # Before release upload, bind the deployed service version to its official SDK, # then exercise candidate size/type, protocol/chunking, and security policy in # an owned temporary object. Reconcile/delete that exact ID before any retry. # Build its manifest with the publication schema owner (including `platform`) # and round-trip the payload through the real signer and verifier; a reduced # ad hoc preflight map or weaker validator is not publication-contract proof. # Keep native verification and downstream storage as separate phase receipts. # Shared YAML does not collapse internal branches: contract same-job codegen vs # verified transfer and first-release vs upgrade against the same core guards. # Reuse one semantic release stage DAG across apps. App identity/provider values # are validated typed configuration or adapters; never fork a copied publisher # or import another app's IDs. Before a second app publishes, map every stage, # branch, input/output, order, timeout, failure, and cleanup invariant to the # proven reference and execute the real adapted branches with controlled stubs. # Declare universal byte-identical engine files separately from typed config, # capability adapters, and app-owned product/preservation files. A clean-target # inventory must prove every configured file, test, helper, and native target; # never copy reference-only paths/targets or production runtime probes. # First-release mode skips only the old installer. It still seeds and preserves # namespaced synthetic AppData/registry/credential fixtures; it never invokes # the production app executable, provider/storage graph, or real user data. # Owned child processes use .NET ProcessStartInfo.ArgumentList token by token; # PowerShell Start-Process -ArgumentList array flattening is not structured argv. # Prove spaced/empty/quote-bearing arguments through the real helper parser. # Run portable local checks first, then the exact-format Linux provider # create/public-read/delete preflight. Run this same orchestration on a clean, # private-repository, repository-scoped ephemeral/JIT self-hosted Windows # runner (or directly on a dedicated disposable Windows VM) before paying for # the sole GitHub-hosted Windows publisher. Never attach a public/shared/ # persistent runner or a production machine holding sensitive user data or live credentials. # `act` is wiring-only: its images are incomplete and do not provide Windows # target-native proof from macOS/Linux. # The orchestration calls `defender-installer-scan.ps1` after installer/lifecycle # proof. It uses official `-ReturnHR`, fails detections/unknown HRESULTs closed, # and retries only exact sharing violation `0x80070020` once within its bound. # Keep scanner parsing + red/green self-tests inside the existing orchestration # contract; never add YAML steps merely to expose this internal phase. # After immutable create returns, poll the unauthenticated consumer read for the # same ID/bytes. Retry only typed `storage_file_not_found`, 429, or 5xx within # one bound; fail 401/403/mismatch immediately. Recovery never rebuilds, # version-bumps, deletes, overwrites, or duplicates the immutable release. # Every Bash entrypoint sets strict mode, resolves its own canonical directory # from BASH_SOURCE before path use, and is exercised through real provider/helper # branches with controlled stubs. Static source presence is not execution proof. # Gateway/download adapters contract literal and one percent-encoded reserved # form, reject double encoding, authenticate before object lookup, and retain # safe status/method/redirect/auth-challenge diagnostics. CI must not create a # temporary runtime user/JWT when an anonymous auth-challenge probe is enough. # If immutable bytes/tag/manifest exist and activation alone fails, use an # explicit activation-only recovery that re-verifies and activates them; never # rebuild, re-upload, retag, or recreate the artifact/manifest. # # Verification writes no tag, release, deployment, publication object, pointer, # or machine install. Publication consumes one exact diagnostic-proven revision. # Dispatch input contract is checked before any checkout-local action runs: # mode = verify-windows | publish # revision = 40 lowercase hexadecimal characters, equal to github.sha # version = MAJOR.MINOR.PATCH, each component 0 or a non-zero decimal integer # diagnostic_run_id = 1-20 decimal digits for publish, empty for verify-windows # Raw values enter only as environment data. Release commands consume validated # job outputs through environment variables, never interpolated command source. name: Flutter Windows installer on: workflow_dispatch: inputs: mode: description: Verify Windows or publish a diagnostic-proven revision required: true default: verify-windows type: choice options: - verify-windows - publish revision: description: Exact 40-character source commit; must equal the dispatch event SHA required: true type: string version: description: Exact application version required: true type: string diagnostic_run_id: description: Required only for publish required: false type: string permissions: {} concurrency: group: windows-installer-release cancel-in-progress: false jobs: validate_inputs: name: Validate dispatch inputs before checkout runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} outputs: mode: ${{ steps.validate.outputs.mode }} revision: ${{ steps.validate.outputs.revision }} event_sha: ${{ steps.validate.outputs.event_sha }} version: ${{ steps.validate.outputs.version }} diagnostic_run_id: ${{ steps.validate.outputs.diagnostic_run_id }} steps: - name: Validate exact input grammar id: validate shell: bash env: DISPATCH_MODE: ${{ inputs.mode }} DISPATCH_REVISION: ${{ inputs.revision }} DISPATCH_EVENT_SHA: ${{ github.sha }} DISPATCH_VERSION: ${{ inputs.version }} DISPATCH_RUN_ID: ${{ inputs.diagnostic_run_id }} run: | set -euo pipefail python3 - <<'PY' import os import re mode = os.environ.get("DISPATCH_MODE", "") revision = os.environ.get("DISPATCH_REVISION", "") event_sha = os.environ.get("DISPATCH_EVENT_SHA", "") version = os.environ.get("DISPATCH_VERSION", "") run_id = os.environ.get("DISPATCH_RUN_ID", "") def fail(field: str, value: str) -> None: raise SystemExit(f"invalid {field}: length={len(value)}") if mode not in {"verify-windows", "publish"}: fail("mode", mode) if re.fullmatch(r"[0-9a-f]{40}", revision) is None: fail("revision", revision) if re.fullmatch(r"[0-9a-f]{40}", event_sha) is None: fail("event_sha", event_sha) if revision != event_sha: fail("revision", revision) if re.fullmatch( r"(?:0|[1-9][0-9]{0,8})\.(?:0|[1-9][0-9]{0,8})\.(?:0|[1-9][0-9]{0,8})", version, ) is None: fail("version", version) if run_id and re.fullmatch(r"[0-9]{1,20}", run_id) is None: fail("diagnostic_run_id", run_id) if mode == "publish" and not run_id: fail("diagnostic_run_id", run_id) output_path = os.environ["GITHUB_OUTPUT"] with open(output_path, "a", encoding="utf-8", newline="\n") as output: for name, value in ( ("mode", mode), ("revision", revision), ("event_sha", event_sha), ("version", version), ("diagnostic_run_id", run_id), ): output.write(f"{name}={value}\n") PY verify_windows: name: Verify Windows without publication needs: - validate_inputs if: needs.validate_inputs.outputs.mode == 'verify-windows' runs-on: [self-hosted, windows, x64, windows-installer-proof] timeout-minutes: 75 permissions: actions: read contents: read steps: - name: Check out exact revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.validate_inputs.outputs.revision }} fetch-depth: 0 persist-credentials: false - name: Set up cached release toolchain uses: ./.github/actions/setup-release-toolchain - name: Build once and prove the complete diagnostic contract shell: pwsh env: GH_TOKEN: ${{ github.token }} DISPATCH_MODE: ${{ needs.validate_inputs.outputs.mode }} DISPATCH_REVISION: ${{ needs.validate_inputs.outputs.revision }} DISPATCH_EVENT_SHA: ${{ needs.validate_inputs.outputs.event_sha }} DISPATCH_VERSION: ${{ needs.validate_inputs.outputs.version }} DISPATCH_RUN_ID: ${{ needs.validate_inputs.outputs.diagnostic_run_id }} run: | $ErrorActionPreference = 'Stop' if ($env:DISPATCH_MODE -ne 'verify-windows') { throw 'Validated dispatch mode is not verify-windows.' } & .\.github\scripts\windows_installer.ps1 verify ` -Revision $env:DISPATCH_REVISION ` -DispatchRevision $env:DISPATCH_EVENT_SHA ` -Version $env:DISPATCH_VERSION ` -Publication none - name: Upload short-lived diagnostic evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows-installer-verification-${{ github.run_id }} path: | ${{ runner.temp }}/windows-installer/installer.exe ${{ runner.temp }}/windows-installer/diagnostic-receipt.json if-no-files-found: error retention-days: 1 admission: name: Admit exact publication revision needs: - validate_inputs if: needs.validate_inputs.outputs.mode == 'publish' runs-on: ubuntu-latest timeout-minutes: 35 permissions: contents: read steps: - name: Check out exact revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.validate_inputs.outputs.revision }} fetch-depth: 0 persist-credentials: false - name: Set up cached release toolchain uses: ./.github/actions/setup-release-toolchain - name: Test, scan, and prepare an exact-revision handoff env: GH_TOKEN: ${{ github.token }} DISPATCH_MODE: ${{ needs.validate_inputs.outputs.mode }} DISPATCH_REVISION: ${{ needs.validate_inputs.outputs.revision }} DISPATCH_EVENT_SHA: ${{ needs.validate_inputs.outputs.event_sha }} DISPATCH_VERSION: ${{ needs.validate_inputs.outputs.version }} DISPATCH_RUN_ID: ${{ needs.validate_inputs.outputs.diagnostic_run_id }} run: | set -euo pipefail test "$DISPATCH_MODE" = publish ./.github/scripts/run_linux_release_admission.sh \ "$DISPATCH_REVISION" \ "$DISPATCH_EVENT_SHA" \ "$DISPATCH_VERSION" \ "$DISPATCH_RUN_ID" - name: Upload verified admission handoff uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows-installer-admission-${{ needs.validate_inputs.outputs.diagnostic_run_id }} path: ${{ runner.temp }}/windows-installer/admission if-no-files-found: error retention-days: 1 publish: name: Build once and publish verified Windows installer needs: - validate_inputs - admission if: needs.validate_inputs.outputs.mode == 'publish' runs-on: windows-latest timeout-minutes: 90 environment: windows-release permissions: actions: read contents: write steps: - name: Check out exact admitted revision uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.validate_inputs.outputs.revision }} fetch-depth: 0 persist-credentials: false - name: Set up cached release toolchain uses: ./.github/actions/setup-release-toolchain - name: Download exact-revision admission handoff uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-installer-admission-${{ needs.validate_inputs.outputs.diagnostic_run_id }} path: ${{ runner.temp }}/windows-installer/admission - name: Build once, verify, publish immutable bytes, and activate last shell: pwsh env: GH_TOKEN: ${{ github.token }} PUBLISHER_TOKEN: ${{ secrets.WINDOWS_PUBLISHER_TOKEN }} MANIFEST_SIGNING_KEY: ${{ secrets.WINDOWS_MANIFEST_SIGNING_KEY }} DISPATCH_MODE: ${{ needs.validate_inputs.outputs.mode }} DISPATCH_REVISION: ${{ needs.validate_inputs.outputs.revision }} DISPATCH_EVENT_SHA: ${{ needs.validate_inputs.outputs.event_sha }} DISPATCH_VERSION: ${{ needs.validate_inputs.outputs.version }} DISPATCH_RUN_ID: ${{ needs.validate_inputs.outputs.diagnostic_run_id }} ADMISSION_ROOT: ${{ runner.temp }}\windows-installer\admission run: | $ErrorActionPreference = 'Stop' if ($env:DISPATCH_MODE -ne 'publish') { throw 'Validated dispatch mode is not publish.' } & .\.github\scripts\windows_installer.ps1 publish ` -Revision $env:DISPATCH_REVISION ` -DispatchRevision $env:DISPATCH_EVENT_SHA ` -Version $env:DISPATCH_VERSION ` -DiagnosticRunId $env:DISPATCH_RUN_ID ` -AdmissionRoot $env:ADMISSION_ROOT - name: Upload final installer evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: app-windows-installer-v${{ needs.validate_inputs.outputs.version }} path: ${{ runner.temp }}/windows-installer/app-windows-installer-v${{ needs.validate_inputs.outputs.version }}.exe if-no-files-found: error retention-days: 7
-
-
references
-
atomic-design
-
accessibility.md 1.7 KB
# Atomic Design — Accessibility ## Read first 1. Accessibility is UI correctness, not polish. 2. Visible labels, tooltips, semantic labels, and accessibility copy are localized through `AppLocalizations`. 3. Verify text scale and contrast locally; do not globally clamp text scale to hide layout problems. ## Trigger Signals: semantics, tooltip, semanticLabel, image alt text, tap target, contrast, text scaling, icon-only button. ## Controls Action/icon buttons need localized tooltips and at least 48x48 tap targets: ```dart IconButton( tooltip: l10n.deleteOrderTooltip, icon: const Icon(Icons.delete_outline), onPressed: onDelete, ) ``` Lints: `prefer_action_button_tooltip`, `avoid_hardcoded_strings`. ## Images Informative images need localized semantic labels: ```dart Image.network( product.imageUrl, semanticLabel: l10n.productImageLabel(product.name), ) ``` Decorative images are excluded: ```dart Image.asset( 'assets/confetti.png', excludeFromSemantics: true, ) ``` Lint: `avoid_missing_image_alt`. ## Styled copy Prefer `Text.rich` over raw `RichText` so app text configuration, scaling, and defaults are preserved. Lint: `prefer_text_rich`. ## Text scale Do not clamp app-wide text scale in `MaterialApp.builder`. Fix the layout where overflow occurs and test large text locally. Lint: `a11y_text_scale_clamp`. ## Design-system placement Accessibility behavior belongs in atoms and promoted primitives where it can be reused. Feature widgets should consume accessible primitives rather than repeat tooltip, target-size, and contrast logic. ## Related - [atomic-design.md](../atomic-design.md#accessibility) - [flutter-optimizations.md](../flutter-optimizations.md#semantics)
-
-
common-patterns
-
debounce-gate-batch.md 9.6 KB
# Common Patterns — Debounce, Gate, and Batch ## Read first 1. Use this for high-frequency UI/network/storage boundaries and destructive remote reconciliation. 2. For reusable `Debouncer` helper ownership or repeated ID lookup utilities, read [collections-helpers.md](../extensions/collections-helpers.md) instead. 3. For search, pagination, and form state shape, read [lists-forms-workflows.md](lists-forms-workflows.md) first, then this file only for the boundary mechanics. ## Trigger Signals: TextField per-keystroke side effects, Slider/scroll throttling, full-collection rewrite, persistence debounce, long-running remote function, destructive reconcile-before-log, reset/clear markers, WebView/VideoPlayer gate, storage-read memoization. ## Debounce, Gate, and Batch High-frequency boundaries (keystrokes, drag gestures, scroll ticks, sync cycles, long-running remote work, destructive reset/delete flows) must coalesce or reconcile before they reach a notifier, network, or disk. Foreground waits must stay tiny: search/realtime debounce <=150ms, visual animation <=120ms, persistence/hard waits <=50ms. Retry/backoff, rest timers, reminders, and sync/backfill settle timers belong in background owners. Each lint below catches one specific shape of the same anti-pattern: treating asynchronous boundaries as if they complete atomically and cheaply. ### Input callbacks — debounce or move to terminal event ```dart // NEVER — fires per keystroke TextField(onChanged: (v) { ref.read(searchProvider.notifier).search(v); // N requests for "hello" }); // DO — Timer cancel-and-restart Timer? _debounce; @override void dispose() { _debounce?.cancel(); super.dispose(); } void _onQueryChanged(String query) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 150), () { unawaited(ref.read(searchProvider.notifier).search(query)); }); } TextField(onChanged: _onQueryChanged); // Slider/RangeSlider — defer terminal effects Slider( value: _local, onChanged: (v) => setState(() => _local = v), // local UI only onChangeEnd: (v) => ref.read(p.notifier).set(v), // one notifier call ); ``` Lints: `text_field_on_changed_no_debounce`, `slider_on_changed_no_debounce`, `scroll_listener_no_throttle`, `user_visible_duration_too_long`. ### Notifier persistence helpers — real debounce, not just generation tokens Use a cancel-and-restart `Timer` / `Future.delayed` / `Debouncer` to coalesce bursts. Keep foreground persistence debounce <=50ms. A queue or generation token only prevents stale/overlapping writes; it does **not** debounce. ```dart @Riverpod(keepAlive: true) class DraftNotifier extends _$DraftNotifier { Timer? _debounce; @override DraftState build() { ref.onDispose(() => _debounce?.cancel()); return const DraftState(); } void _persistDraft() { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 50), _save); } } ``` Lints: `notifier_persistence_no_debounce`, `user_visible_duration_too_long`. ### Sync push — guard with a dirty list ```dart Future<void> pushItems(String userId, List<Entity> items) async { if (items.isEmpty) return; // early return await remote.saveAll(userId, items.map(Model.fromEntity).toList()); } // or outer dirty check if (isDirty) { await remote.saveAll(userId, items.map(Model.fromEntity).toList()); } ``` Lint: `sync_save_all_no_dirty_guard`. ### Remote Functions + destructive reconciliation ```dart // NEVER — blocks the client on a potentially long-running backend function. final execution = await functions.createExecution( functionId: deleteAccountFunctionId, body: payload, xasync: false, ); final result = DeleteResult.fromExecution(execution); // DO — async-start, then reconcile source of truth with bounded polling/realtime. final start = await functions.createExecution( functionId: deleteAccountFunctionId, body: payload, xasync: true, ); if (!DeleteResult.fromAsyncStart(start).ok) return false; return waitForDeleted(userId, maxAttempts: 60, interval: const Duration(seconds: 2)); ``` ```dart // NEVER — false telemetry if the backend completed after the client timed out. } catch (e, s) { Crash.error(e, s); await _reconcileDeletedState(); } // DO — reconcile first; log only when the source of truth still disagrees. } catch (e, s) { final deleted = await _reconcileDeletedState(); if (!deleted) Crash.error(e, s); } ``` Lints: `appwrite_blocking_function_execution_in_client`, `destructive_failure_logged_before_reconcile`. ### Subset changes — write changed rows, not the whole collection ```dart // NEVER — one changed row, full rewrite. final index = items.indexWhere((item) => item.id == changed.id); if (index >= 0) items[index] = changed; await local.saveAll(items.map(Model.fromEntity).toList()); // DO — persist the changed subset. await local.mergeAll([changed].map(Model.fromEntity).toList()); ``` Lint: `save_all_full_collection_after_subset_mutation`. ### Collection getters and id lookup — cache indexes ```dart // NEVER — allocates and scans on every access/call. Map<String, List<Item>> get itemsByGroup { final map = <String, List<Item>>{}; for (final item in items) { (map[item.groupId] ??= <Item>[]).add(item); } return map; } Item get selectedItem => items.firstWhere((item) => item.id == selectedId); // DO — cache immutable indexes and use O(1) lookup. final itemsById = {for (final item in items) item.id: item}; Item? get selectedItem => itemsById[selectedId]; ``` Lints: `collection_getter_allocates_each_access`, `linear_id_lookup_in_hot_path`, `nested_linear_lookup_by_id`. ### Heavy widgets — gate behind a user action ```dart class VideoCard extends StatefulWidget { const VideoCard({super.key}); @override State<VideoCard> createState() => _VideoCardState(); } class _VideoCardState extends State<VideoCard> { bool _userTappedPlay = false; @override Widget build(BuildContext context) { if (_userTappedPlay) return VideoPlayer(controller); return GestureDetector( onTap: () => setState(() => _userTappedPlay = true), child: Placeholder(), ); } } ``` Lint: `webview_init_in_build_no_gate`. ### Service storage reads — memoize ```dart class FlagsService { final Map<String, bool> _cache = {}; Future<bool> flag(String key) async => _cache[key] ??= await _storage.read<bool>(key); } ``` Lint: `service_storage_read_no_memo`. ### Reset/clear flows — hard clear app-owned state ```dart // DO — reset means all app-owned local keys are cleared. Future<void> resetAll() async { await _storage.clear(); } // NEVER — keeps old migration/version/install markers alive. Future<void> resetAll() async { final schemaVersion = await _storage.read<String>(schemaVersionKey); await _storage.clear(); if (schemaVersion != null) { await _storage.save(schemaVersionKey, schemaVersion); } } ``` Lint: `storage_clear_preserves_migration_state`. ### Manual subscriptions — forbidden ```dart class _State extends ConsumerState { void initState() { super.initState(); // NEVER: manual subscription lifecycle in widget state. ref.listenManual(provider, (_, __) {}); } Widget build(BuildContext context) { ref.listen(provider, (_, __) {}); return const SizedBox.shrink(); } } ``` Lint: `riverpod_listen_manual_forbidden`. ### `@Riverpod(keepAlive: true)` — derive from a bounded projection ```dart // NEVER — retains every log for the session @Riverpod(keepAlive: true) List<Log> session(Ref ref) => ref.watch(provider.select((s) => s.logs)); // DO — bounded projection @Riverpod(keepAlive: true) int sessionCount(Ref ref) => ref.watch(provider.select((s) => s.count)); ``` Lint: `keepalive_watches_unbounded_collection`. ### Datasource interfaces — expose a batch loader ```dart abstract class SettingsLocalDatasource { Future<SettingsSnapshot> loadAll(); // one read, all values Future<bool> getOptIn(); // optional single-value getters Future<int> getThemeIndex(); } ``` Lint: `datasource_missing_batch_loader`. ### Save callbacks — guard zero / empty input ```dart void submit(int amount, int count) { if (amount <= 0 && count <= 0) return; // no empty rows ref.read(provider.notifier).saveEntry(amount: amount, count: count); } ``` Lint: `notifier_zero_value_save_no_guard`. ### Widget→notifier boundary — wrap unit primitives in Value Objects ```dart // NEVER — raw unit-bearing primitive crosses the boundary double distanceMeters = parsed; ref.read(provider.notifier).save(distance: distanceMeters); // DO — wrap at the boundary final distance = Distance.fromMeters(parsed); // domain VO with invariants ref.read(provider.notifier).save(distance: distance); ``` Lint: `notifier_param_requires_value_object`. See [value-objects.md](../value-objects.md). ### Modal helpers — always pass `routeSettings` ```dart // Private method on the calling screen — no top-level UI helpers. class OrderScreen extends ConsumerWidget { const OrderScreen({super.key}); Future<T?> _openConfirm<T>(BuildContext context) => showDialog<T>( context: context, routeSettings: const RouteSettings(name: 'confirm-dialog'), builder: (_) => const ConfirmDialog(), ); @override Widget build(BuildContext context, WidgetRef ref) { return DeleteButton(onPressed: () => unawaited(_openConfirm<bool>(context))); } } ``` Lint: `modal_helper_requires_route_settings`. Cross-link: [Modal Snapshot Pattern](modals-navigation.md#modal-snapshot-pattern) covers the dialog mutation half. [State Teardown Belongs in the Notifier](../state-management-lifecycle.md#state-teardown-belongs-in-the-notifier) covers the success-path teardown half. -
delta-sync.md 3.7 KB
# Common Patterns — Delta Sync ## Read first 1. Delta sync = pull changed rows + deleted IDs after the last confirmed server cursor. 2. Repository owns merge/delete reconciliation; notifier triggers sync + guards lifecycle. 3. Advance the cursor only after every table applies successfully. ## Delta Sync (Incremental Remote Pull) Fetch only rows changed since last sync, not all data. ### Repository Interface Additions ```dart abstract interface class IExerciseRepository { // ... existing CRUD ... /// Upserts changed items into local storage by ID. Future<void> mergeAll(List<Exercise> items); /// Removes locally-stored items whose IDs are no longer present remotely. Future<void> deleteByIds(Set<String> ids); } ``` ### mergeAll Implementation ```dart @override Future<void> mergeAll(List<Exercise> items) async { final current = await _local.getAll(); final updated = [...current]; final updatedById = {for (final item in updated) item.id: item}; for (final item in items) { updatedById[item.id] = item; } await _local.saveAll(updatedById.values.toList(growable: false)); } ``` ### deleteByIds Implementation ```dart @override Future<void> deleteByIds(Set<String> ids) async { final current = await _local.getAll(); final filtered = current.where((e) => !ids.contains(e.id)).toList(); await _local.saveAll(filtered); } ``` ### Sync Service Flow ```dart // Per-table delta sync: // 1. Read per-table lastSyncDate from settings // 2. If null → first sync full getAll + mergeAll // 3. If exists → getUpdatedSince(lastSyncDate) + mergeAll // 4. getAllIds from remote, compare to local IDs, deleteByIds for missing // 5. Store newest remote updatedAt; for a successful empty first pull, store // an epoch/sentinel watermark so the next run uses delta, not another full pull. final lastTableSync = await settingsRepo.getTableSyncDate(tableKey); final DateTime? watermark; if (lastTableSync == null) { final all = await remote.getAll(userId); if (all.isNotEmpty) await repo.mergeAll(all.map((m) => m.toEntity()).toList()); watermark = newestUpdatedAt(all) ?? .fromMillisecondsSinceEpoch(0, isUtc: true); } else { final changed = await remote.getUpdatedSince(userId, lastTableSync); if (changed.isNotEmpty) await repo.mergeAll(changed.map((m) => m.toEntity()).toList()); final remoteIds = (await remote.getAllIds(userId)).toSet(); final localIds = (await repo.getAll()).map((e) => e.id).toSet(); final deleted = localIds.difference(remoteIds); if (deleted.isNotEmpty) await repo.deleteByIds(deleted); watermark = newestUpdatedAt(changed); } if (watermark != null) { await settingsRepo.setTableSyncDate(tableKey, watermark); } ``` Reference/catalog data should follow the same contract: full pull only when the per-table marker is missing, then delta pulls on later launches. Do not force an `alwaysFullPull` path for normal app open; reserve explicit full refreshes for manual repair/admin flows. ### Per-Table Sync Date Storage Per-table keys are `StorageKeys` entries such as `StorageKeys.syncDateExercises` ([Key Registries](../architecture.md#key-registries)), never local constants in the repository. ```dart // In settings repository: Future<DateTime?> getTableSyncDate(String key) async { final ms = await _storage.read<int>(key); return ms != null ? .fromMillisecondsSinceEpoch(ms, isUtc: true) : null; } Future<void> setTableSyncDate(String key, DateTime date) async { await _storage.save(key, date.millisecondsSinceEpoch); } ``` ### When to Use | Scenario | Approach | |----------|----------| | Data rarely changes | Delta sync — fetches nothing when no changes | | Frequent small edits | Delta sync — fetches only changed rows | | Full data refresh needed | Full pull with `saveAll` | -
lists-forms-workflows.md 8.6 KB
# Common Patterns — Lists, Forms, and Workflows ## Read first 1. Use Freezed state plus `@Riverpod` codegen for pagination, search, and forms. 2. Debounce high-frequency input in the notifier/helper owner and dispose it with `ref.onDispose`. 3. After async repository work, guard with `if (!ref.mounted) return;` before state writes. ## Trigger Signals: pagination, infinite scroll, cursor loading, search debounce, form validation, batch processing, pull-to-refresh. ## Pagination ```dart @freezed sealed class PaginatedState with _$PaginatedState { const factory PaginatedState({ @Default([]) List<Product> items, @Default(false) bool isLoading, @Default(false) bool isLoadingMore, @Default(true) bool hasMore, @Default(0) int page, }) = _PaginatedState; } @Riverpod(keepAlive: true) class PaginatedProductNotifier extends _$PaginatedProductNotifier { static const _pageSize = 20; @override PaginatedState build() { unawaited(.microtask(() => _loadPage(0))); // Defer — see notifier-structure.md. return const PaginatedState(isLoading: true); } Future<void> _loadPage(int page) async { if (!ref.mounted) return; state = state.copyWith(isLoading: page == 0, isLoadingMore: page > 0); try { final items = await ref.read(productRepositoryProvider).fetchPage(page, _pageSize); if (!ref.mounted) return; state = state.copyWith( items: page == 0 ? items : [...state.items, ...items], page: page, hasMore: items.length >= _pageSize, isLoading: false, isLoadingMore: false, ); } catch (e) { if (!ref.mounted) return; state = state.copyWith(isLoading: false, isLoadingMore: false); } } Future<void> loadMore() async { if (state.isLoadingMore || !state.hasMore) return; await _loadPage(state.page + 1); } Future<void> refresh() => _loadPage(0); } ``` Widget with scroll detection: ```dart class PaginatedProductListScreen extends ConsumerWidget { const PaginatedProductListScreen({super.key}); bool _onScroll(WidgetRef ref, ScrollNotification scroll) { if (scroll.metrics.pixels >= scroll.metrics.maxScrollExtent - 200) { unawaited(ref.read(paginatedProductProvider.notifier).loadMore()); } return false; } @override Widget build(BuildContext context, WidgetRef ref) { final items = ref.watch( paginatedProductProvider.select((s) => s.items), ); final hasMore = ref.watch( paginatedProductProvider.select((s) => s.hasMore), ); return NotificationListener<ScrollNotification>( onNotification: (scroll) => _onScroll(ref, scroll), child: ListView.builder( itemCount: items.length + (hasMore ? 1 : 0), itemBuilder: (context, index) { if (index >= items.length) { return const Center(child: CircularProgressIndicator()); } return ProductCard(product: items[index]); }, ), ); } } ``` ## Search with Debounce ```dart @freezed sealed class SearchState with _$SearchState { const factory SearchState({ @Default('') String query, @Default([]) List<Product> results, @Default(false) bool isSearching, }) = _SearchState; } // Uses Debouncer from core/extensions/ helper owner. // See references/extensions/collections-helpers.md for the Debouncer class. @Riverpod(keepAlive: true) class SearchNotifier extends _$SearchNotifier { final _debouncer = Debouncer(const Duration(milliseconds: 150)); @override SearchState build() { ref.onDispose(_debouncer.dispose); return const SearchState(); } void search(String query) { state = state.copyWith(query: query, isSearching: query.isNotEmpty); if (query.isEmpty) { _debouncer.cancel(); state = state.copyWith(results: [], isSearching: false); return; } _debouncer.call(() => unawaited(_runSearch(query))); } Future<void> _runSearch(String query) async { try { final results = await ref.read(productRepositoryProvider).search(query); if (!ref.mounted) return; state = state.copyWith(results: results, isSearching: false); } catch (e) { if (!ref.mounted) return; state = state.copyWith(isSearching: false); } } } ``` ## Local Filter (No API Call) Filter items in state, no refetch: ```dart @freezed sealed class FilterableState with _$FilterableState { const factory FilterableState({ @Default([]) List<Product> allItems, @Default('') String searchQuery, }) = _FilterableState; const FilterableState._(); List<Product> get displayItems => searchQuery.isEmpty ? allItems : allItems .where((item) => item.name.toLowerCase().contains(searchQuery.toLowerCase())) .toList(); } // Widget — use .select() on the computed getter final items = ref.watch( filterableProvider.select((s) => s.displayItems), ); ``` ## Form Validation Notifiers store typed validation errors; the UI maps them to localized copy ([localization.md](../localization.md#notifier-boundary)). ```dart enum ProductNameError { required, tooShort } enum ProductPriceError { invalidNumber, notPositive } @freezed sealed class ProductFormState with _$ProductFormState { const factory ProductFormState({ @Default('') String draftName, @Default('') String draftDescription, @Default(0.0) double draftPrice, ProductNameError? nameError, ProductPriceError? priceError, @Default(false) bool isSubmitting, }) = _ProductFormState; const ProductFormState._(); bool get isValid => nameError == null && priceError == null && draftName.trim().isNotEmpty && draftPrice > 0; } @Riverpod(keepAlive: true) class ProductFormNotifier extends _$ProductFormNotifier { static const _minNameLength = 3; @override ProductFormState build() => const ProductFormState(); void setName(String value) { ProductNameError? nameError; if (value.length < _minNameLength) nameError = .tooShort; if (value.isEmpty) nameError = .required; state = state.copyWith(draftName: value, nameError: nameError); } void setPrice(String value) { final parsed = double.tryParse(value); ProductPriceError? priceError; if (parsed == null) priceError = .invalidNumber; if (parsed != null && parsed <= 0) priceError = .notPositive; state = state.copyWith( draftPrice: parsed ?? 0, priceError: priceError, ); } Future<void> submit() async { if (!state.isValid || state.isSubmitting) return; state = state.copyWith(isSubmitting: true); try { await ref.read(productRepositoryProvider).create( Product( id: DateTimeX.nowUtc().millisecondsSinceEpoch.toString(), name: state.draftName.trim(), price: state.draftPrice, ), ); if (!ref.mounted) return; // Reset or navigate state = const ProductFormState(); } catch (e) { if (!ref.mounted) return; state = state.copyWith(isSubmitting: false); } } } ``` ```dart // Screen build: map the typed error to localized copy for the field's errorText. final l10n = context.l10n; final nameError = ref.watch(productFormProvider.select((s) => s.nameError)); final nameErrorText = switch (nameError) { .required => l10n.productNameRequired, .tooShort => l10n.productNameTooShort, null => null, }; ``` ## Batch Processing Extract to `core/utils/batch_utils.dart` for cross-feature reuse: ```dart import 'dart:math'; /// Process items in parallel batches to avoid overwhelming the server. Future<void> parallelBatch<T>({ required List<T> items, required Future<void> Function(T) action, int batchSize = 50, }) async { for (int i = 0; i < items.length; i += batchSize) { final end = min(i + batchSize, items.length); final batch = items.sublist(i, end); await Future.wait(batch.map(action)); await Future<void>.value(); // yield to event loop } } // Usage in repository Future<void> updateAll(List<Product> products) async { await parallelBatch( items: products, action: (p) => _remote.update(p), batchSize: 50, ); } ``` ## Pull-to-Refresh ```dart class ProductListScreen extends ConsumerWidget { const ProductListScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final items = ref.watch( productProvider.select((s) => s.items), ); return RefreshIndicator( onRefresh: () async { await ref.read(productProvider.notifier).refresh(); }, child: ListView.builder( itemCount: items.length, itemBuilder: (context, index) => ProductCard(product: items[index]), ), ); } } ``` -
modals-navigation.md 7.4 KB
# Common Patterns — Modals and Navigation ## Read first 1. Modal renders immutable snapshot + returns a typed result. 2. Screen/notifier owns mutation, teardown, and page navigation. 3. Dismiss the active modal navigator before typed-route navigation. ## Modal Snapshot Pattern **Rule.** Screen computes immutable `<Feature>Summary` via `ref.read`. Dialog renders the snapshot and returns a typed result. Screen dispatches the notifier mutation after dismissal. Dialog has no provider reads. Notifier owns success teardown and preserves failure state. ```dart // NEVER — dialog hosts mutation + watches mutable record class ConfirmDialog extends ConsumerWidget { const ConfirmDialog({required this.id}); final String id; @override Widget build(BuildContext context, WidgetRef ref) { final entity = ref.watch(entityProvider(id)); // re-evaluates mid-dismiss final (:isSaving, :itemsByCategoryId) = ref.watch( // record with Map getter → rebuild storm formProvider.select((s) => (isSaving: s.isSaving, itemsByCategoryId: s.itemsByCategoryId)), ); return AppPrimaryButton( onPressed: () async { final ok = await ref.read(formProvider.notifier).save(entity); // mutates state.items if (!context.mounted) return; Navigator.of(context, rootNavigator: true).pop(); // pop AFTER mutation if (ok) context.pop(); // triggers PopScope flash }, label: itemsByCategoryId.isEmpty ? 'Exit' : 'Confirm', ); } } ``` ```dart // DO — value object + caller orchestrates class ConfirmSummary { const ConfirmSummary({required this.entity, required this.confirmed}); final Entity entity; final bool confirmed; static ConfirmSummary? compute({required Entity? entity, required FormState state}) { if (entity == null) return null; return ConfirmSummary(entity: entity, confirmed: state.items.isNotEmpty); } } class ConfirmScreenBoundary extends ConsumerWidget { const ConfirmScreenBoundary({required this.id, super.key}); final String id; Future<void> _onPressed(BuildContext context, WidgetRef ref) async { final summary = ConfirmSummary.compute( entity: ref.read(entityProvider(id)), state: ref.read(formProvider), ); if (summary == null) return; final confirmed = await showDialog<bool>( context: context, routeSettings: const RouteSettings(name: 'confirm-dialog'), builder: (_) => ConfirmDialog(summary: summary), ); if (confirmed != true || !context.mounted) return; await ref.read(formProvider.notifier).save(summary.entity); } @override Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; return AppTextButton(onPressed: () => _onPressed(context, ref), label: l10n.confirmAction); } } class ConfirmDialog extends StatelessWidget { const ConfirmDialog({required this.summary, super.key}); final ConfirmSummary summary; @override Widget build(BuildContext context) { final l10n = context.l10n; return AppPrimaryButton( onPressed: () => Navigator.of(context).pop(true), label: summary.confirmed ? l10n.confirmAction : l10n.exitAction, ); } } ``` **Test.** Pump dialog with synthetic `ConfirmSummary`; don't drive rendering from provider state. ```dart testWidgets('confirm dialog renders from summary', (tester) async { await tester.pumpWidget( MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, home: ConfirmDialog( summary: const ConfirmSummary(entity: e, confirmed: true), ), ), ); expect(find.text('Confirm'), findsOneWidget); }); ``` Lints: `dialog_widget_subscribes_to_mutable_provider`, `modal_high_frequency_watch_not_leaf`, `dialog_button_pop_then_state_mutation`, `select_returns_unstable_record_identity`, `build_method_assigns_to_field`, `build_calls_mutating_instance_method`, `widget_calls_notifier_teardown_after_await`, `popscope_bypass_uses_go_not_pop`, `modal_helper_requires_route_settings`. See also: [State Teardown Belongs in the Notifier](../state-management-lifecycle.md#state-teardown-belongs-in-the-notifier), [Dismiss Modal → Push Route](#dismiss-modal--push-route-bottom-sheet-navigation). ## Dismiss Modal → Push Route (Bottom Sheet Navigation) **Rule.** Sheet pops with a result; the screen that opened it awaits the result, then pushes the route. **NEVER:** ```dart Navigator.of(context).pop(); await const CreateExerciseRoute().push<String>(context); ``` **DO — sheet pops a result; the screen awaits it, then navigates:** The sheet and the button are presentation widgets: the sheet only pops its result, and the button emits a callback. The screen that opens the sheet owns the page navigation ([presentation-widgets.md](../presentation-widgets.md)). ```dart // features/create/presentation/widgets/create_sheet.dart class CreateSheet extends StatelessWidget { const CreateSheet({super.key}); void _onCreateTapped(BuildContext context) { Navigator.of(context).pop(CreateChoice.exercise); } @override Widget build(BuildContext context) { final l10n = context.l10n; return AppPrimaryButton(onPressed: () => _onCreateTapped(context), label: l10n.createExercise); } } ``` ```dart // features/create/presentation/widgets/create_button.dart class CreateButton extends StatelessWidget { const CreateButton({required this.onPressed, super.key}); final VoidCallback onPressed; @override Widget build(BuildContext context) { final l10n = context.l10n; return AppTextButton(onPressed: onPressed, label: l10n.createAction); } } ``` ```dart // features/create/presentation/screens/create_screen.dart class CreateScreen extends ConsumerWidget { const CreateScreen({super.key}); Future<void> _openCreateSheet(BuildContext context) async { final choice = await context.showAppSheet<CreateChoice>( routeName: 'create-sheet', builder: (_) => const CreateSheet(), ); if (!context.mounted) return; if (choice != .exercise) return; await const CreateExerciseRoute().push<String>(context); } @override Widget build(BuildContext context, WidgetRef ref) { return CreateButton(onPressed: () => unawaited(_openCreateSheet(context))); } } ``` `context.showAppSheet` = [Dialog helpers](../extensions/context-ui.md#dialog-helpers). ## Pop Fallback Helpers Check Navigator Stacks Check root + local Navigator stacks before typed fallback. ```dart extension GoRouterPopX on BuildContext { bool popIfCan<T extends Object?>([T? result]) { if (!this.mounted) return false; final rootNavigator = Navigator.maybeOf(this, rootNavigator: true); if (rootNavigator != null && rootNavigator.canPop()) { rootNavigator.pop<T>(result); return true; } final navigator = Navigator.maybeOf(this); if (navigator != null && navigator.canPop()) { navigator.pop<T>(result); return true; } if (!canPop()) return false; pop<T>(result); return true; } void popOrGo<T extends Object?>(GoRouteData fallbackRoute, [T? result]) { if (popIfCan<T>(result)) return; fallbackRoute.go(this); } } ``` Use `popOrGo` on screens opened by either `push` or direct deep link. Fallback must be a typed `GoRouteData`. Lint: `pop_fallback_helper_must_check_navigator_stack` enforces mounted + root/local Navigator checks; `router_context_navigation_extension` allows typed fallback helpers only when they call `popIfCan` first. -
navigation-flow.md 1.4 KB
# Common Patterns — Navigation Flow ## Read first 1. Validate route params at the route boundary. 2. Missing target = nullable provider + fallback UI; never throw from `build()`. 3. Wizard next-step navigation waits for confirmed notifier state. ## Route-Param Safety + Wizard Sequencing Use nullable by-id providers. Keep mutation order strict before navigate. ```dart // Family + keepAlive caches every key forever — memory leak. Use plain `@riverpod` // so each per-id provider auto-disposes when no widget watches it. @riverpod Program? programById(Ref ref, String id) { final state = ref.watch(programsProvider); for (final p in state.items) { if (p.id == id) return p; } return null; } Future<void> onNext(BuildContext context, WidgetRef ref, String programId) async { final program = ref.read(programByIdProvider(programId)); if (program == null) return; // disable CTA / show placeholder // ✅ DO — fixed sequence: persist → targeted sync → navigate. // Reorder = UI flicker (stale parent) OR lost writes on dispose. final updatedParent = program.copyWith(/* ...edits... */); await ref.read(programRepositoryProvider).save(updatedParent); ref.read(programsProvider.notifier).upsertProgram(updatedParent); if (!context.mounted) return; _goNext(context); } void _goNext(BuildContext context) { const NextRoute().go(context); } ``` -
routing-app-shell.md 13.2 KB
# Common Patterns — Routing and App Shell ## Read first 1. Use `go_router_builder` typed route classes as the navigation API; route definitions own paths. 2. `GoRouter.redirect` uses `ref.read`, never `ref.watch`; redirect policy is a pure matrix-tested resolver. 3. `MaterialApp.router` stays declarative. Bootstrap listeners live in a sibling root `ConsumerWidget` under `ProviderScope`. ## Trigger Signals: typed route, GoRouter redirect, auth-protected route, router provider, app shell, `MaterialApp.router`, `ProviderScope`, startup bootstrap. ## Typed GoRouter Route SSOT Use `go_router_builder` for type-safe route definitions. The generated route classes are the app navigation API. Widgets, notifiers, and services call the generated helpers directly. ### Setup ```yaml # pubspec.yaml — see ../core-stack.md for canonical versions dependencies: go_router: <version> dev_dependencies: build_runner: <version> go_router_builder: <version> ``` ### Route Definitions ```dart // core/router/app_routes.dart part 'app_routes.g.dart'; @TypedGoRoute<HomeRoute>( path: '/', routes: [ TypedGoRoute<ProductListRoute>( path: 'products', routes: [ TypedGoRoute<ProductDetailRoute>(path: ':id'), TypedGoRoute<ProductCreateRoute>(path: 'new'), ], ), ], ) class HomeRoute extends GoRouteData with $HomeRoute { const HomeRoute(); @override Widget build(BuildContext context, GoRouterState state) => const HomeScreen(); } class ProductListRoute extends GoRouteData with $ProductListRoute { const ProductListRoute(); @override Widget build(BuildContext context, GoRouterState state) => const ProductListScreen(); } class ProductDetailRoute extends GoRouteData with $ProductDetailRoute { const ProductDetailRoute({required this.id}); final String id; @override Widget build(BuildContext context, GoRouterState state) => ProductDetailScreen(productId: id); } class ProductCreateRoute extends GoRouteData with $ProductCreateRoute { const ProductCreateRoute(); @override Widget build(BuildContext context, GoRouterState state) => const ProductCreateScreen(); } @TypedGoRoute<LoginRoute>(path: '/login') class LoginRoute extends GoRouteData with $LoginRoute { const LoginRoute({this.from}); final String? from; // query parameter @override Widget build(BuildContext context, GoRouterState state) => LoginScreen(from: from); } ``` ### Router Provider with Auth Redirect Create GoRouter once. Use `ref.listen()` + `refreshListenable` to trigger redirect re-evaluation. NEVER `ref.watch()` in redirect — recreates router every state change, resets route stack. Keep redirect decisions pure. The GoRouter closure should read providers, call a pure resolver, and return the result. Matrix-test the resolver. **Redirect rules for apps with multi-step setup (profile completion, roles):** - **During loading, MUST stay put.** Return `null` — NEVER bounce to splash. On web refresh, redirecting `/chat` → `/` → `/home` loses URL. One exception: authenticated users on login/signup redirect to splash. - **Initial sync MUST NOT hold splash.** Once auth/setup state is known, redirect from splash to the shell and keep sync/data refresh running in the background. A cover screen that waits on `InitialSyncStatus.syncing` blocks startup on network, remote query, and local merge latency. Lint: `router_splash_waits_for_initial_sync`. - **Auth pages MUST navigate explicitly.** Add `ref.listen(authProvider)` in login/signup pages, navigate on auth success. `refreshListenable` timing unreliable; explicit nav guarantees transition. - **OAuth MUST skip auth-level `isLoading`.** Use per-button loading (`isGoogleLoading`). Auth `isLoading` triggers premature splash redirect. - **keepAlive providers survive hot reload.** Redirect closure changes need hot restart. ```dart @Riverpod(keepAlive: true) GoRouter router(Ref ref) { final refreshNotifier = ValueNotifier<Object?>(null); ref.listen(setupInfoProvider, (_, __) { refreshNotifier.value = Object(); }); ref.onDispose(refreshNotifier.dispose); return GoRouter( initialLocation: const SplashRoute().location, refreshListenable: refreshNotifier, routes: $appRoutes, redirect: (context, state) { final setupStatus = ref.read(setupInfoProvider).status; final location = state.matchedLocation; return resolveAppRedirect(location: location, setupStatus: setupStatus); }, ); } ``` ```dart String? resolveAppRedirect({ required String location, required SetupStatus setupStatus, }) { switch (setupStatus) { case .loading: return null; // Stay put — preserves URL on web refresh. case .unauthenticated: return _isPublicPage(location) ? null : const LoginRoute().location; case .needsProfileCompletion: return location == '/profile-completion' ? null : '/profile-completion'; case .setupComplete: return _isSetupPage(location) ? const HomeRoute().location : null; } } ``` Test the matrix: loading, signed out, signed in, setup incomplete, setup complete, stale deep links, update-required gates, and auth pages. ### Page Navigation The route class owns the path, params, query params, and generated helper. Call it at the event boundary. The boundary is the page/screen; presentation widgets emit typed callbacks and never navigate ([presentation-widgets.md](../presentation-widgets.md)): ```dart // features/products/presentation/widgets/product_card.dart class ProductCard extends StatelessWidget { const ProductCard({required this.onTap, super.key}); final VoidCallback onTap; @override Widget build(BuildContext context) { return ProductTile(onTap: onTap); } } ``` ```dart // features/products/presentation/screens/product_list_screen.dart class ProductListScreen extends ConsumerWidget { const ProductListScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final ids = ref.watch(productIdsProvider); return ListView.builder( itemCount: ids.length, itemBuilder: (context, index) => ProductCard( onTap: () => ProductDetailRoute(id: ids[index]).go(context), ), ); } } ``` ```dart // Belt-and-suspenders: auth pages navigate directly on authentication. class AppLoginPage extends ConsumerWidget { const AppLoginPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { ref.listen(authProvider, (prev, next) { if (next.isAuthenticated && !(prev?.isAuthenticated ?? false)) { const HomeRoute().go(context); } }); return const LoginPageContent(); } } ``` For pushed screens that may also be opened by deep link, pop when possible and otherwise go to a typed fallback route: ```dart class ProductEditorScreen extends ConsumerWidget { const ProductEditorScreen({super.key}); void _closeEditor(BuildContext context) { if (context.canPop()) { context.pop(); return; } const ProductListRoute().go(context); } @override Widget build(BuildContext context, WidgetRef ref) { return ProductEditorForm(onClose: () => _closeEditor(context)); } } ``` Promote this to a generic helper only if it appears in multiple places; it must take a `GoRouteData` fallback, never a raw string. Generic `BuildContext` fallback helpers are allowed when they do not create route-specific APIs. Do not put route-specific helpers on `BuildContext`; call the generated typed route helper directly at the event boundary. ### Dialogs and Sheets Dialogs and sheets are local presentation, not page routes. Use semantic helper methods such as `context.showAppSheet<T>(...)` or `showConfirmDialog(...)`. Dismiss from inside the modal widget with `Navigator.pop(context, result)` / `Navigator.of(context).maybePop()`. Modals are pop-with-result, not mutation hosts. The dialog widget never subscribes to a provider its own action mutates, never runs code after `Navigator.pop`, and never owns its caller's teardown. See [Modal Snapshot Pattern](modals-navigation.md#modal-snapshot-pattern). ## Long-Running Sync/Auth Cancellation Guard with a generation token or cancellation signal before every state write or remote connection change. ```dart class SyncCoordinator { int _generation = 0; String? _activeUserId; Future<void> syncFor(String userId) async { final generation = ++_generation; _activeUserId = userId; await pull(); if (!_isActive(userId, generation)) return; await push(); if (!_isActive(userId, generation)) return; markComplete(); } void cancel() { _generation++; _activeUserId = null; } bool _isActive(String userId, int generation) => _generation == generation && _activeUserId == userId; } ``` ### Type-Safe Navigation ```dart // Navigate with compile-time checked route parameters. const HomeRoute().go(context); const ProductListRoute().go(context); ProductDetailRoute(id: product.id).go(context); // Push with return value. final result = await ProductCreateRoute(parentId: product.id).push<bool>(context); if (!context.mounted) return; // Replace when entering a same-flow child route whose success exits the whole flow // (auth/login/signup, onboarding step, destructive confirm, import wizard). const LoginRoute().pushReplacement(context); // Safe pop with typed fallback. if (context.canPop()) { context.pop(result); } else { const ProductListRoute().go(context); } ``` **Forbidden (lint enforced)** — every form below has a typed-route replacement above: | Anti-pattern | Lint | |---|---| | String route path from feature code | `router_string_nav` | | Direct `GoRouter.of(context)` usage | `router_gorouter_of` | | Injected router or `context.go(route.location)` usage | `router_direct_route_call` | | Raw `GoRouter` / `GoRoute` definitions outside the router boundary or shared test router helper | `router_raw_route_definition` | | Direct `Navigator` page route usage | `router_untyped_navigator_push` | | Splash/cover waits for initial sync | `router_splash_waits_for_initial_sync` | | Extra navigation wrapper around typed routes | navigation SSOT lints | ### StatefulShellRoute Tabs Use `StatefulNavigationShell.goBranch()` for tab changes. Do not push tab root routes to switch tabs. ```dart class AppShellScaffold extends StatelessWidget { const AppShellScaffold({required this.navigationShell, super.key}); final StatefulNavigationShell navigationShell; @override Widget build(BuildContext context) { final l10n = context.l10n; return BottomNavigationBar( currentIndex: navigationShell.currentIndex, onTap: navigationShell.goBranch, items: [ BottomNavigationBarItem( icon: const Icon(Icons.home), label: l10n.homeTab, ), BottomNavigationBarItem( icon: const Icon(Icons.settings), label: l10n.settingsTab, ), ], ); } } ``` - In reusable sheets/overlays, pass callback from shell-owned caller, don't route in child. **GoRouter 17.x — `ShellRoute` propagates to root observers.** Since 17.0.0 `ShellRoute`/`StatefulShellRoute` notify root `NavigatorObserver`s by default. Most want this (analytics on shell push). Root `RouteObserver` should fire **only** for top-level nav? Pass `notifyRootObserver: false` on `ShellRoute`. - Use typed route `.go(context)` to enter shell from outside, or `goBranch()` when the shell is already available. ```dart Future<void> _createWorkout(BuildContext sheetContext) async { await Navigator.of(sheetContext).maybePop(); if (!sheetContext.mounted) return; navigationShell.goBranch(1); } BentoWorkoutSelectorSheet( onCreateWorkout: () => unawaited(_createWorkout(sheetContext)), ) ``` Wrong for shell tabs: ```dart const ExercisesRoute().push<void>(context); // stacks another route const ExercisesRoute().go(context); // bypasses branch-switch semantics ``` ### App Shell + Bootstrap Boundary `WidgetRef.listen` is designed to be used at the root of `build` for UI side effects (navigation, dialogs/snackbars, logging, splash removal). Do not mix those bootstrap listeners into the same widget that returns `MaterialApp` / `CupertinoApp` / `WidgetsApp`. Use a dedicated bootstrap widget under `ProviderScope`: ```dart Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Crash.init(); runApp( const ProviderScope( child: AppBootstrap(child: MyApp()), ), ); } class AppBootstrap extends ConsumerWidget { const AppBootstrap({required this.child, super.key}); final Widget child; @override Widget build(BuildContext context, WidgetRef ref) { // Eager provider initialization: use watch so the provider stays alive. // Select a stable readiness field when the provider exposes state. ref.watch(startupProvider.select((state) => state.isReady)); // UI side effects: listen at the root of build. ref.listen(authProvider, (previous, next) { if (next case Authenticated()) { const HomeRoute().go(context); } }); return child; } } class MyApp extends ConsumerWidget { const MyApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(routerProvider); return MaterialApp.router(routerConfig: router); } } ``` `MyApp` remains declarative shell config. `AppBootstrap` owns root lifetime side effects. ---
-
-
extensions
-
collections-helpers.md 2.6 KB
# Extensions — Collections And Helpers ## Read first 1. Repeated lookup/indexing belongs in reusable indexes, computed providers, or shared collection extensions. 2. One-off call-site lookup uses `lookupByKey` / `indexOfByKey`; never local helpers, `firstWhere`, or `indexWhere` in hot paths. 3. Debouncers, validators, `Result`, and extension types are shared helpers, not widget-file globals. ## Trigger Signals: `Iterable`, lookup by id, indexing, widget list spacing helpers, `Debouncer`, validators, `Result`, extension types, `extensions.dart` export. ## Iterable lookup ```dart extension IterableLookupX<T> on Iterable<T> { T? lookupByKey<K>(K key, K Function(T item) keyOf) { for (final item in this) { if (keyOf(item) == key) return item; } return null; } Map<K, T> indexOfByKey<K>(K Function(T item) keyOf) { return {for (final item in this) keyOf(item): item}; } } ``` Use a cached index for repeated lookups: ```dart final productsById = state.products.indexOfByKey((product) => product.id); ``` Lints: `ad_hoc_id_index_lookup`, `linear_id_lookup_in_hot_path`, `nested_linear_lookup_by_id`. ## Widget list helpers Keep small layout helpers in extension owners, not top-level widget helpers: ```dart extension WidgetListX on List<Widget> { List<Widget> separatedBy(Widget separator) { return [ for (final (index, child) in indexed) ...[ if (index > 0) separator, child, ], ]; } } ``` ## Debouncer ```dart final class Debouncer { Debouncer(this.duration); final Duration duration; Timer? _timer; void call(VoidCallback action) { _timer?.cancel(); _timer = Timer(duration, action); } void cancel() => _timer?.cancel(); void dispose() => cancel(); } ``` Register disposal with `ref.onDispose(debouncer.dispose)` or widget `dispose()`. ## Validators Validators normalize blank optional text to `null` at boundaries and return typed validation errors, not user-facing strings. UI maps errors to localized `AppLocalizations`. ## Result Use `Result<T, E>` only at boundaries where exceptions are intentionally converted to typed outcomes. Do not hide programming errors behind `Result`. ## Extension types Use extension types for zero-cost typed IDs when Freezed Value Objects are too heavy and no serialization behavior is needed: ```dart extension type ProductId(String value) {} ``` For domain text with validation, prefer a Freezed Value Object. ## Barrel export Export extension owners from `core/extensions/extensions.dart`; do not import individual extension files throughout feature code. -
context-ui.md 2.1 KB
# Extensions — Context And UI ## Read first 1. BuildContext operations live in `core/extensions/context_extensions.dart`, exported from `core/extensions/extensions.dart`. 2. Route-current checks use `context.isCurrentModalRoute`; never inline `ModalRoute` current-route APIs at call sites. 3. Snackbars/dialog helpers are UI boundary utilities; notifiers emit state, widgets listen and dispatch UI effects. ## Trigger Signals: `BuildContext`, `ModalRoute`, `SnackBarUtils`, dialog helpers, route-current guards, snackbar from notifier. ## Context extensions Expose semantic helpers from `core/extensions/extensions.dart`: ```dart // core/extensions/context_extensions.dart extension BuildContextX on BuildContext { AppLocalizations get l10n => .of(this); TextTheme get textTheme => Theme.of(this).textTheme; bool get isCurrentModalRoute => ModalRoute.of(this)?.isCurrent ?? false; } ``` Forbidden outside the extension owner: ```dart ModalRoute.of(context)?.isCurrent; ModalRoute.isCurrentOf(context); ``` Lint: `use_context_is_current_modal_route`. ## Dialog helpers Put repeated modal launch details in semantic `BuildContext` extension members, never top-level functions in widget files. Always pass `routeSettings` so observers/analytics can see modals. ```dart // core/extensions/context_extensions.dart extension ModalContextX on BuildContext { Future<T?> showAppSheet<T>({ required String routeName, required WidgetBuilder builder, }) { return showModalBottomSheet<T>( context: this, routeSettings: RouteSettings(name: routeName), builder: builder, ); } } ``` Lint: `modal_helper_requires_route_settings`. ## Snackbar dispatch Notifier owns durable status fields; widget listens and calls UI helpers. ```dart ref.listen( profileProvider.select((state) => state.errorSerial), (previous, next) { if (previous != next && context.mounted) { showProfileSaveFailedSnackBar(context, context.l10n); } }, ); ``` The UI helper may wrap `SnackBarUtils`. Do not call `SnackBarUtils.show...` from notifiers, repositories, or datasources. -
primitive-formatting.md 2.2 KB
# Extensions — Primitive Formatting ## Read first 1. Primitive formatting lives in `core/extensions/`; call sites use semantic extension getters/methods. 2. Persist/server timestamps in UTC; local calendar buckets convert to local before bucketing. 3. Domain entities do not import `core/extensions/`; use entity getters or Value Objects in domain. ## Trigger Signals: `DateTime`, `String`, `int`, `double`, `num`, `Duration`, `NumberFormat`, `DateFormat`, `intl`, capitalization, currency, percent, clamp. ## DateTime Use semantic helpers, not ad-hoc formatting at call sites: ```dart // core/extensions/date_time_extensions.dart extension DateTimeX on DateTime { static DateTime nowUtc() => .timestamp(); static DateTime nowLocal() => .timestamp().toLocal(); DateTime get localDayStart { final local = toLocal(); return DateTime(local.year, local.month, local.day); } String formatShortDate(AppLocalizations l10n) { return DateFormat.yMMMd(l10n.localeName).format(toLocal()); } } ``` Forbidden at call sites: raw `DateTime.now()` chains, ad-hoc `DateFormat`, inline `.formatted(pattern: ...)`. Lint: `datetime_now_requires_timezone_intent`. ## String ```dart extension StringX on String { String get capitalized => isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}'; String truncate(int maxLength) => length <= maxLength ? this : '${substring(0, maxLength)}...'; } ``` Required domain strings are Value Objects, not raw `String` with empty sentinels. ## Number formatting ```dart extension NumX on num { String asCurrency(AppLocalizations l10n, {String? symbol}) { return NumberFormat.currency(locale: l10n.localeName, symbol: symbol).format(this); } /// `5.23` → `+5.2%`; the receiver is already in percent units. String asSignedPercent(AppLocalizations l10n) { return NumberFormat('+0.0%;-0.0%', l10n.localeName).format(this / 100); } num clamped(num min, num max) => clamp(min, max); } ``` Forbidden at call sites: ad-hoc `NumberFormat`, inline `.clamp(...)`, raw executable magic numbers. ## Duration ```dart extension DurationX on Duration { String get compactLabel { final minutes = inMinutes.remainder(60).toString().padLeft(2, '0'); return '$inHours:$minutes'; } } ```
-
-
state-management
-
async-mutations.md 3.1 KB
# State Management — Async Mutations ## Read first 1. Guard every notifier/repository `await` with `if (!ref.mounted) return;` before touching state or ref again. 2. Inside `finally`, use `if (ref.mounted) { ... }`; do not early-return from `finally`. 3. Stable infrastructure dependencies use `ref.read`; reactive rendering uses `ref.watch` in `build()`. ## Trigger Signals: mutation method, `ref.read`, `ref.watch`, `ref.listen`, `_ensureRepository`, async cancellation, optimistic update, duplicate fetch. ## Mounted guards ```dart Future<void> save() async { state = state.copyWith(isSaving: true); try { await ref.read(orderRepositoryProvider).save(state.order); if (!ref.mounted) return; state = state.copyWith(isSaving: false, successSerial: state.successSerial + 1); } catch (error, stackTrace) { if (!ref.mounted) return; state = state.copyWith(isSaving: false, error: AppErrorMapper.from(error)); Crash.error(error, stackTrace, reason: 'save'); } } ``` ```dart Future<void> refresh() async { state = state.copyWith(isRefreshing: true); try { await _load(); } finally { if (ref.mounted) { state = state.copyWith(isRefreshing: false); } } } ``` ## Dependency readiness Do not cache repositories/services in notifier fields just to avoid reading providers. ```dart Future<void> placeOrder() async { final auth = ref.read(authNotifierProvider); final cart = ref.read(cartNotifierProvider); final repo = ref.read(orderRepositoryProvider); if (auth case Authenticated(:final user)) { await repo.place(user.id, cart.items); if (!ref.mounted) return; state = state.copyWith(successSerial: state.successSerial + 1); } } ``` Use `ref.watch` only in `build()` when the notifier intentionally rebuilds from another provider. Use `ref.listen` in `build()` for side effects tied to provider changes. ## Optimistic updates Optimistic updates need deterministic rollback or source-of-truth reconciliation: ```dart Future<void> markRead(NotificationId id) async { final previous = state; state = state.markRead(id); try { await ref.read(notificationsRepositoryProvider).markRead(id); if (!ref.mounted) return; await _reloadFromSourceOfTruth(); } catch (error, stackTrace) { if (!ref.mounted) return; state = previous.copyWith(error: AppErrorMapper.from(error)); Crash.error(error, stackTrace, reason: 'markRead'); } } ``` ## Duplicate fetches Guard long-running work: ```dart Future<void> loadMore() async { if (state.isLoadingMore || !state.hasMore) return; state = state.copyWith(isLoadingMore: true); final page = await ref.read(productRepositoryProvider).fetch(cursor: state.cursor); if (!ref.mounted) return; state = state.append(page); } ``` ## UI effects Do not create standalone `*Signal` / `*Event` / `*Pulse` providers for one-shot UI effects. Put serial/payload fields on the owning state and listen from the widget: ```dart ref.listen( checkoutNotifierProvider.select((state) => state.successSerial), (previous, next) { if (previous != next) const OrdersRoute().go(context); }, ); ``` -
notifier-structure.md 3.7 KB
# State Management — Notifier Structure ## Read first 1. Use `@riverpod` / `@Riverpod` codegen for every provider and notifier. 2. Sync `Notifier.build()` must not read `state` before the first assignment; seed directly. Use `AsyncNotifier.build()` for primary async state. 3. Keep generated notifier shape conventional: `class FooNotifier extends _$FooNotifier`. ## Trigger Signals: `Notifier`, `AsyncNotifier`, `build()`, loading state, `AsyncValue`, cleanup, progress state. ## Notifier shape ```dart part 'cart_notifier.g.dart'; @Riverpod(keepAlive: true) class CartNotifier extends _$CartNotifier { @override Future<CartState> build() async { final items = await ref.read(cartRepositoryProvider).load(); return CartState(items: items); } } ``` Rules: - Use generated refs from the base class; do not store a `Ref` field. - Do not use `StateNotifier`, `StateNotifierProvider`, `StateProvider`, manual `Provider`, or manual `AsyncNotifierProvider`. - State is a sealed Freezed class when it carries meaningful state. - Use `copyWith` for state updates after the first sync seed. ## Sync notifier init trap Never read `state`, `state.copyWith`, or a provider callback that reads `state` before sync `build()` returns. ```dart // WRONG: state is uninitialized before build returns. @override CounterState build() { state = state.copyWith(isLoading: true); return state; } ``` ```dart // RIGHT: direct seed, then defer the load past build(). @override CounterState build() { unawaited(.microtask(_load)); return const CounterState(isLoading: true); } ``` ## AsyncNotifier Use `AsyncNotifier` when the provider's primary value is loaded asynchronously and the UI naturally renders `AsyncValue<T>`. ```dart @riverpod class ProfileNotifier extends _$ProfileNotifier { @override Future<Profile> build(String userId) async { final repo = ref.read(profileRepositoryProvider); return repo.fetch(userId); } } ``` For app state with explicit flags, keep a Freezed state object behind a sync notifier: seed state in `build()` and defer the first load with `unawaited(.microtask(_load))` (the `avoid_sync_notifier_state_read` fix). Exception: pause-sensitive startup (below) starts from its durable owner after the watch/listen path exists, through an idempotent `load()`. Do not rely on `Future.microtask` ordering there to beat route pause or listener attachment. ## Pause-sensitive startup - Route widgets may stop listening while covered or offstage; route-local listeners cannot own durable startup or one-shot delivery. - Root/bootstrap owner = eagerly watch durable state + register the listener before calling idempotent startup. - One-shot navigation/snackbar/status = durable state with an identity/sequence + explicit acknowledgement; never an unbuffered callback. - Provider projection = watch the base state directly; avoid computed-provider → computed-provider chains for pause-sensitive values. - Regression proof = start covered/paused → emit first update → resume → exact current state or pending event appears once. ## Loading and progress Keep initial loading, background refresh, and load-more state distinct: - `isLoading`: first load only. - `isRefreshing`: already-rendered data is being refreshed. - `isLoadingMore`: paginated append. - `progress`: long-running visible operation with bounded UI updates. ## Cleanup Register lifecycle cleanup in `build()`: ```dart @override SearchState build() { final timer = _debouncer; ref.onDispose(timer.dispose); return const SearchState(); } ``` Do not keep provider-derived caches, repository instances, or subscriptions in widget state. Use generated providers, notifier state, repository/service memo fields, or computed providers as the single source of truth.
-
-
analysis-options.md 5.2 KB
# analysis_options.yaml ## Read first 1. `dart analyze` from package root. No path arg. Never `flutter analyze lib`. 2. Analyzer plugins live ONLY in `analysis_options.yaml` top-level `plugins:` — never `pubspec.yaml` deps. 3. Enable strict casts/inference/raw types. Exclude generated files. 4. Setup/fix answers must explicitly verify one `flutter_skill_lints` diagnostic and one `riverpod_lint` diagnostic can fire before calling setup complete. ## Trigger Signals: analysis_options, dart analyze, flutter_skill_lints, riverpod_lint Copy `references/analysis_options.yaml` to every Flutter project root. For new Flutter apps, also copy `templates/flutter/lib/core/extensions/` to `lib/core/extensions/` so shared context primitives such as `context.isCurrentModalRoute` exist before feature code needs them. ## Required - `strict-inference`: true - Type safety: `no_dynamic_casts`, `no_raw_types` - Async: `unawaited_futures`, `discarded_futures`, `avoid_void_async` - Resources: `avoid_print`, `cancel_subscriptions`, `close_sinks` - Effective Dart Design API shape: `always_declare_return_types`, `type_annotate_public_apis`, `avoid_positional_boolean_parameters`, `avoid_equals_and_hash_code_on_mutable_classes`, `avoid_returning_this`, `avoid_setters_without_getters`, `prefer_mixin`, `use_to_and_as_if_applicable` - Effective Dart Design API safety plugin rules: `avoid_futureor_return_type`, `avoid_nullable_async_or_collection_return_type`, `avoid_public_late_final_without_initializer` - `prefer_type_over_var` is forbidden in this profile: it conflicts with Effective Dart's preference for inferred initialized local variables. - Do not enable `avoid_types_on_closure_parameters`: `strict-inference` sometimes requires explicit closure parameter types at weakly typed APIs such as `testWidgets`. - Do not enable `avoid_classes_with_only_static_members` in Flutter app profiles: the skill uses `abstract final class` namespaces for tiny constants/platform facade APIs where a named owner improves discovery. - Do not enable `one_member_abstracts` for architecture profiles: one-method repository/datasource/service contracts are valid dependency boundaries. - Do not enable `omit_local_variable_types` until the project has removed explicit local types mechanically; this profile no longer enforces the opposite rule. - Do not enable `use_setters_to_change_properties` for async persistence APIs: Dart setters cannot be `async`, and these mutation methods must remain awaitable. - Codegen warnings stay visible. Fix an annotation package or generator constraint when generated annotations are incompatible. Do not silence annotation diagnostics with an analyzer error override. - Exclude: `*.g.dart`, `*.freezed.dart`, `*.gr.dart`, `*.arb` ## Install ```bash flutter pub add dev:flutter_lints ``` Plugin block: ```yaml plugins: riverpod_lint: ^3.1.9 flutter_skill_lints: ^0.13.0 ``` Match bundled [`references/analysis_options.yaml`](analysis_options.yaml) exactly. Keep both plugin constraints aligned with the bundled template. New-project baseline: ```bash cp <skill>/references/analysis_options.yaml ./analysis_options.yaml mkdir -p lib/core/extensions cp <skill>/templates/flutter/lib/core/extensions/*.dart ./lib/core/extensions/ ``` If `lib/core/extensions/` already exists, merge the template files. Do not overwrite project-specific extensions. ## Rules - Plugins go top-level `plugins:`. Not under `analyzer:`. Not in `pubspec.yaml`. - Use `flutter_skill_lints` + `riverpod_lint` as shown. - No `git:`/`path:` under `plugins:` unless local checkout. ## Verify 1. `flutter pub get` 2. `dart analyze --verbose` (package root, no path arg) 3. Fail on `server.pluginError` 4. One `flutter_skill_lints` diagnostic 5. One `riverpod_lint` diagnostic Do not omit steps 4-5 in analyzer setup answers; green analysis alone does not prove both plugins are loaded. Scope: `dart analyze` = analyzer/plugin gate. [Dart Decimate](dart-decimate.md) = complementary graph/code-health gate. Use `flutter pub get` + publish/dry-run for package validity. ## Use `dart analyze`, NOT `flutter analyze` Run `dart analyze --fatal-infos` from package root. No path arg. Avoid `flutter analyze` + `flutter analyze lib` + `dart analyze lib`. Measured on Flutter 3.47.5 / Dart 3.13.4: `flutter analyze` (even at package root) and `dart analyze <dir>` report no plugin diagnostics while printing "No issues found!"; package-root `dart analyze` and single-file paths report them. Without `--fatal-infos`, info diagnostics do not fail the run. CI/scripts: `dart analyze`. Never `flutter analyze lib`. Tracking: https://github.com/flutter/flutter/issues/184190. ## Fix plugin crash 1. Remove analyzer plugins from `pubspec.yaml` deps: `riverpod_lint`, `custom_lint`, `custom_lint_builder`, `flutter_skill_lints`, any package listed under `plugins:`. 2. Keep analyzer plugins only in `analysis_options.yaml plugins:`. 3. Keep included lint packages as normal dev deps, e.g. `flutter_lints`. 4. Run `flutter pub get`. 5. Restart analysis server or rerun `dart analyze`. ## Fix stale `~/.dartServer` ```bash mv ~/.dartServer ~/.dartServer.bak-$(date +%Y%m%d%H%M%S) dart analyze ``` Use absolute `--cache=` paths only. -
analysis_options.yaml 1.2 KB
include: package:flutter_lints/flutter.yaml plugins: riverpod_lint: ^3.1.9 flutter_skill_lints: ^0.13.0 analyzer: exclude: - ".dart_tool/**" - "**/*.g.dart" - "**/*.freezed.dart" - "**/*.gr.dart" - "**/*.arb" language: strict-inference: true errors: missing_required_param: error missing_return: error formatter: page_width: 100 linter: rules: - always_use_package_imports - require_trailing_commas - prefer_single_quotes - directives_ordering - avoid_multiple_declarations_per_line - prefer_const_constructors - prefer_const_declarations - prefer_const_literals_to_create_immutables - prefer_final_locals - avoid_redundant_argument_values # Effective Dart Design additions not covered by flutter_lints. - always_declare_return_types - type_annotate_public_apis - avoid_positional_boolean_parameters - avoid_equals_and_hash_code_on_mutable_classes - avoid_returning_this - avoid_setters_without_getters - prefer_mixin - use_to_and_as_if_applicable - avoid_dynamic_calls - no_dynamic_casts - no_raw_types - avoid_print - avoid_void_async - cancel_subscriptions - close_sinks - discarded_futures - unawaited_futures -
architecture.md 19.7 KB
# Architecture ## Read first 1. Data models != domain entities. Domain has no JSON, Flutter, storage, or SDK imports. 2. Repos/datasources use `abstract interface class`; constructors take interfaces, not concretes. 3. Storage SDK calls live in Local Datasource → Repository only. Not notifier/widget/service/repo. 4. Screens bind providers; reusable widgets render immutable view inputs and emit typed callbacks. 5. Typed GoRouter route helpers are nav SSOT; route defs own paths/params. ## Trigger Signals: clean architecture, four layers, dependency inversion, domain entity, repository interface ## Scope In: state, nav, deep links, persistence, HTTP boundaries, models/JSON, DI, errors, forms (via [Validators](extensions/collections-helpers.md#validators) + [common-patterns.md](common-patterns.md)), localization, atomic widgets, previews, codegen, tests. Out: backend-vendor SDK specifics, full design-system authoring, and a11y beyond `Semantics` notes in [atomic-design.md](atomic-design.md#accessibility). HTTP service internals are covered at boundary level in [networking.md](networking.md). ## Scale Rules - Small feature: one `widgets/` dir. Promote to atomic hierarchy when widgets span 2+ features. - Default providers: `@riverpod`. Use `keepAlive: true` for repos, datasources, app-wide services, and feature notifiers ([riverpod-codegen.md](riverpod-codegen.md#keepalive-providers-long-lived)). - Define interfaces for repos/datasources in multi-feature code. ## Rules — NEVER Violate 1. **MUST** separate data models from domain entities — NEVER reuse one class for both. 2. **MUST** define `abstract interface class` for every repository and datasource. Constructors MUST take interfaces, NEVER concrete types. 3. **MUST NEVER** put `fromJson`/`toJson` on domain entities — serialization = Data layer. 4. **MUST NEVER** import Flutter in Domain — entities pure Dart, zero deps. 5. **MUST** use `model.toEntity()` in repositories for Data → Domain. 6. **MUST** follow the exception owner in [state-management-lifecycle.md](state-management-lifecycle.md#exception-ownership): notifiers catch by default; data layers catch only for documented boundary translations/recovery. 7. **MUST** put feature widgets in `features/x/presentation/widgets/` — shared in `core/widgets/`. 8. **MUST** keep persistence in data/repository layers by default (e.g. local datasource + repository). 9. **MUST NEVER** run repository persistence and notifier persistence as dual SSOT for same state. 10. **MUST NEVER** call a storage SDK (Hive, SharedPreferences, secure_storage, `dart:io`, `path_provider`) from a notifier, widget, or service. Storage lives in `Local<X>Datasource` only, exposed via `<X>Repository`. Imports of `package:hive_ce`, `package:hive_ce_flutter`, `package:shared_preferences`, `package:flutter_secure_storage`, `package:path_provider`, or `dart:io` are forbidden in `presentation/`, `*_notifier.dart`, `*_service.dart`, and `*_repository.dart` files. Exception: one `dart:io` `show` combinator limited to `HttpHeaders`, `HttpStatus`, `SocketException` and `FileSystemException` may supply HTTP constants or boundary exception mapping; it exposes no I/O operations. Broad, `hide` or additional-symbol imports stay forbidden. See [hive-persistence.md](hive-persistence.md) and [exception ownership](state-management-lifecycle.md#exception-ownership). 11. **MUST** bind providers in screens/subscreens, map domain state to immutable view data, and pass typed callbacks to reusable widgets. Widget dependencies MUST NOT include providers, notifiers, repositories, datasources, services, or routes. See [presentation-widgets.md](presentation-widgets.md). 12. **MUST** keep typed GoRouter routes as the navigation SSOT. Route definitions live in the router package boundary, and app code navigates with generated route helpers such as `SomeRoute(...).go(context)` / `.push<T>(context)`. Local sheets/dialogs use local semantic helpers and `Navigator.pop` for dismissal. Mixin vs interface vs extension: see [mixins.md](mixins.md). ## Full Directory Structure **SSOT.** Canonical layout. Other refs link here, no redefine. - `features/<x>/data/` — datasources, models, repo impls - `features/<x>/domain/` — entities, `IRepository` ifaces (pure Dart) - `features/<x>/repositories/` — concrete repo wiring (sibling = loud boundary, see [Repository Layer](#repository-layer)) - `features/<x>/presentation/notifiers/` — notifiers, mutations - `features/<x>/presentation/screens/` — pages - `features/<x>/presentation/widgets/` — feature atoms..organisms (see [atomic-design.md](atomic-design.md)) ``` lib/ ├── core/ │ ├── config/ │ │ └── app_config.dart # Environment variables, API URLs │ ├── constants/ │ │ ├── api_paths.dart # ApiPaths — request paths │ │ └── storage_keys.dart # StorageKeys — persisted/storage keys │ ├── data/ │ │ └── app_error_mapper.dart # Exception → AppError mapping │ ├── domain/ │ │ └── errors/ │ │ └── app_error.dart # Shared error types │ ├── extensions/ │ │ ├── extensions.dart # Barrel export for all extensions │ │ ├── context_extensions.dart # Theme, media, breakpoints, feedback │ │ ├── string_extensions.dart # capitalize, truncate, initials │ │ ├── date_time_extensions.dart # timeAgo, isToday, startOfDay │ │ ├── iterable_extensions.dart # firstWhereOrNull, groupBy │ │ └── widget_extensions.dart # separatedBy │ ├── mixins/ │ │ └── connectivity_mixin.dart # Cross-cutting behavior mixins │ ├── router/ │ │ ├── app_routes.dart # Typed GoRouter route classes │ │ ├── app_routes.g.dart # Generated route helpers │ │ └── app_router.dart # GoRouter provider with auth redirect │ ├── services/ │ │ ├── http_service.dart # HTTP client wrapper │ │ ├── storage_service.dart # Local persistence │ │ └── database_service.dart │ ├── testing/ │ │ └── app_widget_keys.dart # AppWidgetKeys — widget/E2E keys │ ├── theme/ │ │ ├── app_colors.dart │ │ ├── breakpoints.dart # Window-size-class widths │ │ ├── spacing.dart # Spacing constants │ │ ├── radii.dart # BorderRadius constants │ │ └── icon_sizes.dart │ ├── utils/ │ │ ├── batch_utils.dart # Parallel batch processing │ │ ├── debouncer.dart # Timer-based debouncer │ │ ├── snack_bar_utils.dart # Centralized SnackBarUtils (context-free) │ │ ├── validators.dart # Form validation functions │ │ └── date_formatter.dart │ └── widgets/ │ ├── atoms/ # Buttons, badges, indicators │ ├── molecules/ # Avatar tiles, stat cards │ ├── organisms/ # Data grids, navigation headers │ └── templates/ # Dashboard layouts, list-detail ├── features/ │ ├── auth/ │ │ ├── data/ │ │ │ ├── datasources/ │ │ │ │ └── auth_remote_datasource.dart │ │ │ └── models/ │ │ │ └── auth_model.dart │ │ ├── domain/ │ │ │ └── entities/ │ │ │ └── user.dart │ │ ├── repositories/ │ │ │ └── auth_repository.dart │ │ └── presentation/ │ │ ├── notifiers/ │ │ │ └── auth_notifier.dart │ │ ├── screens/ │ │ │ └── login_screen.dart │ │ └── widgets/ │ │ └── login_form.dart │ ├── products/ │ │ ├── data/ │ │ │ ├── datasources/ │ │ │ │ ├── product_remote_datasource.dart │ │ │ │ └── product_local_datasource.dart │ │ │ └── models/ │ │ │ └── product_model.dart │ │ ├── domain/ │ │ │ └── entities/ │ │ │ └── product.dart │ │ ├── repositories/ │ │ │ └── product_repository.dart │ │ └── presentation/ │ │ ├── notifiers/ │ │ │ └── product_notifier.dart │ │ ├── screens/ │ │ │ ├── product_list_screen.dart │ │ │ └── product_detail_screen.dart │ │ └── widgets/ │ │ ├── product_card.dart │ │ └── product_filter.dart │ └── home/ │ └── presentation/ │ ├── notifiers/ │ │ └── home_notifier.dart │ ├── screens/ │ │ └── home_screen.dart │ └── widgets/ │ └── home_section.dart └── main.dart ``` ### Key Registries **Rule.** Persisted/storage keys and API paths are contracts. Define each once in `lib/core/constants/`, never as inline strings or local `static const` in notifiers, repositories, or datasources. Widget/E2E keys follow the same rule in `AppWidgetKeys` ([testing.md](testing.md#widget-key-registry)). ```dart // lib/core/constants/storage_keys.dart abstract final class StorageKeys { static const todos = 'todos'; static const syncDateExercises = 'sync_date_exercises'; } ``` ```dart // lib/core/constants/api_paths.dart abstract final class ApiPaths { static const products = '/products'; } ``` ## Layer Responsibilities ### Domain Layer **MUST be pure Dart.** Allowed imports: `freezed_annotation` + `/domain/` paths only. NEVER `core/extensions/`, `package:flutter`, `dart:ui`. Enforced by `arch_domain_import` (ERROR). Models own behavior derived from own fields (see [freezed-sealed.md](freezed-sealed.md#rich-models)). Boundary integrity (all ERROR — see [value-objects.md](value-objects.md)): - `vo_public_raw_constructor` — VO raw redirects must be private (`._meters`/`._raw`); only validated factories public. - `domain_entity_primitive_factory` — `@freezed` entities outside `/domain/values/` must not own named factories. Convert primitives in data/notifier/import boundaries only. - `domain_custom_copy_with` — no hand-written `copyWith` in `/domain/`; let Freezed generate it. Domain derivations: - 1 entity, 1 derivation → entity getter - Same primitive in 2+ entities → Value Object in `/domain/values/` (see [value-objects.md](value-objects.md)) - Never `core/extensions/` from domain — outer dep, Dependency Rule - Required text and unit/currency numbers are VOs (`ProductId`, `DisplayName`, `Money`); models keep primitives and `toEntity()` converts ```dart // features/products/domain/entities/product.dart @freezed sealed class Product with _$Product { const Product._(); const factory Product({ required ProductId id, required DisplayName name, required Money price, @Default(0) int quantity, @Default(true) bool isActive, }) = _Product; Money get totalValue => Money(cents: price.cents * quantity, currency: price.currency); bool get inStock => quantity > 0; } ``` Entities NEVER contain `fromJson`/`toJson`. Serialization = Data layer. ### Data Layer Models mirror entities, add serialization. Models own formatting: `toEntity()`, `toNameOnlyRequestBody()`, etc. MUST define `abstract interface class` for every datasource. Provider MUST return interface type, NEVER concrete class. Backend identity contract rule: - Never assume domain `id` == backend row/document key. - If backend uses internal transport IDs, datasource `update/delete` resolves backend key first (query stable business key), then writes with transport ID. ```dart // features/products/data/models/product_model.dart @freezed sealed class ProductModel with _$ProductModel { const factory ProductModel({ required String id, required String name, required double price, @Default(0) int quantity, @JsonKey(name: 'is_active') @Default(true) bool isActive, }) = _ProductModel; factory ProductModel.fromJson(Map<String, dynamic> json) => _$ProductModelFromJson(json); const ProductModel._(); /// Map to domain entity Product toEntity() => Product( id: ProductId(id), name: DisplayName(name), price: .usd(price), quantity: quantity, isActive: isActive, ); /// Map to API request body with only name (for example) Map<String, dynamic> toNameOnlyRequestBody() => { 'id': id, 'name': name, }; } ``` ```dart // features/products/data/datasources/product_remote_datasource.dart /// Interface contract — depend on this, not the concrete class abstract interface class IProductRemoteDatasource { Future<List<ProductModel>> fetchAll(); Future<ProductModel> fetchById(String id); Future<void> create(ProductModel model); } @Riverpod(keepAlive: true) IProductRemoteDatasource productRemoteDatasource(Ref ref) { return ProductRemoteDatasource(ref.read(httpServiceProvider)); } class ProductRemoteDatasource implements IProductRemoteDatasource { ProductRemoteDatasource(this._http); final HttpService _http; @override Future<List<ProductModel>> fetchAll() async { final response = await _http.get(ApiPaths.products); return switch (response) { List<Object?> items => [ for (final item in items) ProductModel.fromJson(item as Map<String, dynamic>), ], _ => throw const FormatException('Expected product list payload'), }; } @override Future<ProductModel> fetchById(String id) async { final json = await _http.get('${ApiPaths.products}/$id'); return .fromJson(json as Map<String, dynamic>); } @override Future<void> create(ProductModel model) async { await _http.post(ApiPaths.products, body: model.toJson()); } } ``` ### Repository Layer MUST define `abstract interface class`. Constructor MUST take datasource interfaces, NEVER concrete types. Provider MUST return interface type. ```dart // features/products/repositories/product_repository.dart /// Interface contract — notifiers depend on this, not the concrete class abstract interface class IProductRepository { Future<List<Product>> fetchAll(); Future<Product> fetchById(String id); } @Riverpod(keepAlive: true) IProductRepository productRepository(Ref ref) { return ProductRepository( ref.read(productRemoteDatasourceProvider), ref.read(productLocalDatasourceProvider), ); } class ProductRepository implements IProductRepository { ProductRepository(this._remote, this._local); final IProductRemoteDatasource _remote; final IProductLocalDatasource _local; @override Future<List<Product>> fetchAll() async { try { final models = await _remote.fetchAll(); await _local.cacheAll(models); return models.map((m) => m.toEntity()).toList(); } catch (_) { // Fallback to cache final cached = await _local.getAll(); return cached.map((m) => m.toEntity()).toList(); } } @override Future<Product> fetchById(String id) async { final model = await _remote.fetchById(id); return model.toEntity(); } } ``` Exception ownership = [state-management-lifecycle.md](state-management-lifecycle.md#exception-ownership). ### Presentation Layer Screens bind providers and map domain state to immutable view data. Reusable widgets render those inputs and emit typed callbacks. Full boundary = [presentation-widgets.md](presentation-widgets.md). Notifier structure = [state-management/notifier-structure.md](state-management/notifier-structure.md). Async mutations = [state-management/async-mutations.md](state-management/async-mutations.md). ```dart // features/products/presentation/notifiers/product_notifier.dart @freezed sealed class ProductState with _$ProductState { const factory ProductState({ @Default([]) List<Product> items, @Default(false) bool isLoading, AppError? error, }) = _ProductState; } @Riverpod(keepAlive: true) class ProductNotifier extends _$ProductNotifier { @override ProductState build() { unawaited(.microtask(_load)); // Defer — see notifier-structure.md "Sync notifier init trap" return const ProductState(isLoading: true); } // ... see notifier-structure.md and async-mutations.md. } ``` ```dart // features/products/presentation/screens/product_list_screen.dart class ProductListScreen extends ConsumerWidget { const ProductListScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final isLoading = ref.watch( productProvider.select((s) => s.isLoading), ); final items = ref.watch( productProvider.select((s) => s.items), ); if (isLoading) return const Center(child: CircularProgressIndicator()); return ProductListView( items: UnmodifiableListView([ for (final item in items) ProductItemViewData(id: item.id, name: item.name), ]), onItemTap: (item) => ProductRoute(id: item.id).push<void>(context), ); } } ``` ```dart // features/products/presentation/widgets/product_list_view.dart class ProductListView extends StatelessWidget { const ProductListView({ required this.items, required this.onItemTap, super.key, }); final UnmodifiableListView<ProductItemViewData> items; final ValueChanged<ProductItemViewData> onItemTap; @override Widget build(BuildContext context) { return ListView.builder( itemCount: items.length, itemBuilder: (context, index) => ProductCard( item: items[index], onTap: () => onItemTap(items[index]), ), ); } } ``` ## Navigation SSOT GoRouter is infrastructure; generated typed route classes are the call-site API. Define typed routes in `core/router/app_routes.dart`, create `GoRouter` in the router provider, and navigate from app code with generated helpers: ```dart ProductDetailRoute(id: productId).go(context); final created = await const ProductCreateRoute().push<bool>(context); ``` Allowed raw router boundary: - generated `*.g.dart` route helpers - shared test router helpers Every other widget/notifier/service/feature file uses generated typed routes. Dialogs and sheets stay local semantic helpers; dismiss them with `Navigator.pop` inside the modal widget. ## Complexity Tiers | Tier | Data | Auth | Example | Implementation | |------|------|------|---------|----------------| | 1 | Simple, no PII | None | To-do lists, notes | Single repo, no datasources, Hive | | 2 | Public data | Basic | Social, catalogs | Remote + local datasources, HTTP | | 3 | PII, financial | Full | Banking, health | Full arch, domain errors | Default Tier 2. Drop to Tier 1 only for trivial apps. Tier 3 for regulated industries. ## Design Tokens NEVER hardcode spacing, colors, radii, icon sizes. See [atomic-design.md](atomic-design.md) for all tokens (`Spacing`, `Radii`, `IconSizes`, typography, `ColorScheme`, semantic colors). ```dart // Usage final l10n = context.l10n; Padding(padding: const EdgeInsets.all(Spacing.s16)) Text(l10n.productsTitle, style: Theme.of(context).textTheme.titleMedium) Container(color: Theme.of(context).colorScheme.primary) ``` ## Atomic Design for Widgets Shared widgets in `core/widgets/` follow atomic design: tokens → atoms → molecules → organisms → templates → pages. See [atomic-design.md](atomic-design.md) for rules, examples, placement. Feature widgets go in `features/x/presentation/widgets/`, not `core/widgets/`. -
atomic-design.md 13.5 KB
# Atomic Design ## Read first 1. Use tokens for spacing/colors/radii/type/icon sizes. No raw literals/styles. 2. Atoms/molecules/organisms/templates: immutable view inputs + typed callbacks; provider access only screens/subscreens. 3. Use `context.textTheme`/`context.colors`, not raw `TextStyle()`/`Color()`. 4. Feature widgets stay in feature; shared widgets move to `core/widgets/` after 2+ feature uses. ## Trigger Signals: atomic design, atoms, molecules, organisms, design tokens, widget hierarchy ## Rules — NEVER Violate 1. **MUST** use design tokens for ALL measurements — NEVER hardcode spacing, colors, radii, font sizes, icon sizes. 2. **MUST** use `const` constructors on all atoms and molecules. 3. **NEVER** use `ref.watch` or `ref.read` in atoms, molecules, organisms, or templates — provider access ONLY in screens/subscreens. 4. **MUST** use `context.textTheme` and `context.colors` — NEVER raw `TextStyle()` or `Color()`. 5. **MUST** place shared widgets in `core/widgets/`, feature-specific in `features/x/presentation/widgets/`. 6. **MUST** promote widget to `core/widgets/` when 2+ features use it w/ no feature-specific logic. ## Hierarchy ``` Tokens → Raw values: colors, spacing, radii, typography Atoms → Single-purpose widgets: buttons, badges, text fields Molecules → Combine atoms: avatar tiles, stat cards, search bars Organisms → Business-aware groups: data grids, navigation headers Templates → Page structures with slots for organisms Pages → Screens that compose templates and connect state ``` ## Tokens NEVER hardcode color, spacing, radius, font size, icon size. MUST use token classes. ### Spacing ```dart // core/theme/spacing.dart abstract final class Spacing { static const double s4 = 4; static const double s8 = 8; static const double s12 = 12; static const double s16 = 16; static const double s24 = 24; static const double s32 = 32; static const double s48 = 48; static const double s64 = 64; } ``` ### Radii ```dart // core/theme/radii.dart abstract final class Radii { static const double r8 = 8; static const double r12 = 12; static const double r16 = 16; static const double full = 999; static const rounded8 = BorderRadius.all(.circular(r8)); static const rounded12 = BorderRadius.all(.circular(r12)); static const rounded16 = BorderRadius.all(.circular(r16)); static const roundedFull = BorderRadius.all(.circular(full)); } ``` ### Icon Sizes ```dart // core/theme/icon_sizes.dart abstract final class IconSizes { static const double s16 = 16; static const double s20 = 20; static const double s24 = 24; static const double s32 = 32; static const double s48 = 48; } ``` ### Typography Extend Material `TextTheme`: ```dart // core/theme/app_theme.dart ThemeData buildAppTheme() { return ThemeData( colorScheme: .fromSeed(seedColor: Colors.indigo), textTheme: const TextTheme( headlineLarge: TextStyle(fontSize: 32, fontWeight: .bold), titleMedium: TextStyle(fontSize: 16, fontWeight: .w600), bodyMedium: TextStyle(fontSize: 14), labelSmall: TextStyle(fontSize: 11, letterSpacing: 0.5), ), ); } ``` Access via `context.textTheme.titleMedium` (see [extensions/context-ui.md](extensions/context-ui.md)), NEVER raw `TextStyle(fontSize: 16)`. ### Colors Use `ColorScheme` from Material 3 via `context.colors`: ```dart final colors = context.colors; final l10n = context.l10n; Container( color: colors.primaryContainer, child: Text( l10n.productsTitle, style: context.textTheme.bodyMedium?.copyWith(color: colors.onPrimaryContainer), ), ) ``` Semantic constants (status, charts): ```dart // core/theme/semantic_colors.dart abstract final class SemanticColors { static const success = Color(0xFF2E7D32); static const warning = Color(0xFFF9A825); static const error = Color(0xFFC62828); static const info = Color(0xFF1565C0); } ``` ## Atoms ### Rules - MUST be one visual element per atom - MUST accept data via constructor params only - NEVER use `ref.watch` or `ref.read` - MUST have `const` constructor - MUST use tokens for all measurements ### Example ```dart // core/widgets/atoms/app_badge.dart class AppBadge extends StatelessWidget { const AppBadge({super.key, required this.label, this.color}); final String label; final Color? color; @override Widget build(BuildContext context) { final scheme = context.colors; return Container( padding: const EdgeInsets.symmetric( horizontal: Spacing.s8, vertical: Spacing.s4, ), decoration: BoxDecoration( color: color ?? scheme.primaryContainer, borderRadius: Radii.roundedFull, ), child: Text( label, style: context.textTheme.labelSmall?.copyWith( color: scheme.onPrimaryContainer, ), ), ); } } ``` Common atoms: `AppBadge`, `AppIconButton`, `AppTextField`, `LoadingIndicator`, `AppDivider`, `AppAvatar`. ## Molecules ### Rules - MUST compose atoms + basic layout/Material widgets (`ListTile`, `Card`, `Column`, `Row`) - MUST wrap frequently restyled Material components as atoms first - MUST NOT instantiate raw `Material(...)`, `Ink(...)`, or `InkWell(...)`; surface/ink/tap policy belongs in atoms, app shell, or a dedicated surface primitive. Lint: `widget_material_boundary` - NEVER use `ref.watch` or `ref.read` - MUST accept data via constructor ### Examples ```dart // core/widgets/molecules/user_tile.dart class UserTile extends StatelessWidget { const UserTile({ super.key, required this.name, required this.subtitle, this.avatarUrl, this.onTap, }); final String name; final String subtitle; final String? avatarUrl; final VoidCallback? onTap; @override Widget build(BuildContext context) { return ListTile( leading: AppAvatar(url: avatarUrl, fallback: name[0]), title: Text(name, style: context.textTheme.titleMedium), subtitle: Text(subtitle), onTap: onTap, ); } } ``` ```dart // core/widgets/molecules/stat_card.dart class StatCard extends StatelessWidget { const StatCard({ super.key, required this.label, required this.value, this.icon, this.trend, }); final String label; final String value; final IconData? icon; final double? trend; @override Widget build(BuildContext context) { final colors = context.colors; final l10n = context.l10n; return Card( child: Padding( padding: const EdgeInsets.all(Spacing.s16), child: Column( crossAxisAlignment: .start, children: [ Row( children: [ if (icon != null) ...[ Icon(icon, size: IconSizes.s20, color: colors.primary), const SizedBox(width: Spacing.s8), ], Text(label, style: context.textTheme.labelSmall), ], ), const SizedBox(height: Spacing.s8), Text(value, style: context.textTheme.headlineLarge), if (trend case final trendValue?) ...[ const SizedBox(height: Spacing.s4), AppBadge( label: trendValue.asSignedPercent(l10n), color: trendValue >= 0 ? SemanticColors.success : SemanticColors.error, ), ], ], ), ), ); } } ``` ## Organisms ### Rules - MUST accept immutable view inputs + typed callbacks; no provider reads - Compose molecules + atoms - MUST NOT instantiate raw `Material(...)`, `Ink(...)`, or `InkWell(...)`; compose an owned atom/surface primitive instead - Represent distinct page section (header, grid, comment list) - Feature-specific: `features/x/presentation/widgets/` - Shared: `core/widgets/organisms/` ### Examples ```dart // core/widgets/organisms/stats_row.dart class StatsRow extends StatelessWidget { const StatsRow({super.key, required this.stats}); final List<StatViewData> stats; @override Widget build(BuildContext context) { return Row( children: [ for (final stat in stats) Expanded( child: StatCard( label: stat.label, value: stat.value, icon: stat.icon, trend: stat.trend, ), ), ], ); } } ``` ```dart // features/products/presentation/widgets/product_grid.dart class ProductGrid extends StatelessWidget { const ProductGrid({required this.items, required this.onItemTap, super.key}); final List<ProductCardViewData> items; final ValueChanged<ProductCardViewData> onItemTap; @override Widget build(BuildContext context) { return GridView.builder( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: Spacing.s12, crossAxisSpacing: Spacing.s12, childAspectRatio: 0.75, ), itemCount: items.length, itemBuilder: (context, index) => ProductCard( data: items[index], onTap: () => onItemTap(items[index]), ), ); } } ``` ## Templates ### Rules - NEVER use `ref.watch` or `ref.read` in templates - MUST accept widgets via constructor (slots) - MUST handle responsive breakpoints here ### Provider boundary Screens/subscreens = provider binding + domain-to-view-data mapping. Atoms/molecules/organisms/templates = immutable view inputs + typed callbacks; no provider reads or `ProviderScope` in widget tests. Full boundary = [presentation-widgets.md](presentation-widgets.md). ### Examples ```dart // core/widgets/templates/list_detail_template.dart class ListDetailTemplate extends StatelessWidget { const ListDetailTemplate({ super.key, required this.list, required this.detail, this.listFlex = 1, this.detailFlex = 2, }); final Widget list; final Widget detail; final int listFlex; final int detailFlex; @override Widget build(BuildContext context) { if (context.isExpanded) { return Row( children: [ Expanded(flex: listFlex, child: list), const VerticalDivider(width: 1), Expanded(flex: detailFlex, child: detail), ], ); } return list; } } ``` ```dart // core/widgets/templates/dashboard_template.dart class DashboardTemplate extends StatelessWidget { const DashboardTemplate({ super.key, required this.header, required this.stats, required this.body, }); final PreferredSizeWidget header; final Widget stats; final Widget body; @override Widget build(BuildContext context) { return Scaffold( appBar: header, body: Column( children: [ Padding( padding: const EdgeInsets.all(Spacing.s16), child: stats, ), Expanded(child: body), ], ), ); } } ``` ## Pages Pages compose templates w/ organisms. Connect state to layout here. ### Rules - MUST be `ConsumerWidget` or `ConsumerStatefulWidget` - MUST watch providers via `ref.watch` w/ `.select()` - MUST compose templates + organisms — NEVER raw layout - MUST have one screen per route ### Example ```dart // features/products/presentation/screens/product_dashboard_screen.dart class ProductDashboardScreen extends ConsumerWidget { const ProductDashboardScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; final isLoading = ref.watch( productProvider.select((s) => s.isLoading), ); final items = ref.watch( productProvider.select((s) => s.productCardViewData), ); if (isLoading) { return const Scaffold(body: Center(child: LoadingIndicator())); } return DashboardTemplate( header: AppHeader( title: l10n.productsTitle, actions: [ AppIconButton( icon: Icons.add, onPressed: () => const ProductCreateRoute().push<void>(context), tooltip: l10n.addProductTooltip, ), ], ), stats: const ProductStatsRow(), body: ProductGrid( items: items, onItemTap: (item) => ProductRoute(id: item.id).push<void>(context), ), ); } } ``` ## Placement Rules | Level | Location | Provider Access | |-------|----------|----------------| | Tokens | `core/theme/` | No | | Atoms | `core/widgets/atoms/` | No | | Molecules | `core/widgets/molecules/` | No | | Organisms (shared) | `core/widgets/organisms/` | No | | Organisms (feature) | `features/<feature>/presentation/widgets/` | No | | Templates | `core/widgets/templates/` | No | | Pages | `features/<feature>/presentation/screens/` | Yes | Folder layout SSOT: [architecture.md → Full Directory Structure](architecture.md#full-directory-structure). Other refs defer. ## Promotion Rules - Move to `core/widgets/` when 2+ features use widget w/ no feature-specific logic - Extract new atom when same styled element repeats across molecules - Split organism exceeding ~150 lines or handling two unrelated concerns ## Accessibility For `Semantics` wrappers, `MergeSemantics`, localized tooltips/semantic labels, 48x48 tap targets, contrast ratios, and text-scale proof, see Accessibility section in [flutter-optimizations.md](flutter-optimizations.md#accessibility). ## Theming Every widget MUST read from theme. NEVER raw constants: ```dart // WRONG Text('Title', style: TextStyle(fontSize: 16, fontWeight: .bold)) // RIGHT Text(l10n.productsTitle, style: context.textTheme.titleMedium) // WRONG Container(color: Color(0xFF1565C0)) // RIGHT Container(color: context.colors.primary) ``` Exceptions: `SemanticColors` + `Spacing` tokens — static constants independent of theme mode. -
build-reproducibility.md 3.5 KB
# Build Reproducibility ## Read first 1. External contract = installed version + official primary docs; memory + cached runner state = no proof. 2. Build proof = tracked-only clean checkout on the target OS; developer cache + ignored outputs = no proof. 3. Workflow replacement = preserve every applicable step from the last green path + assert step presence/order per consuming lane. 4. Windows installer/updater work = read the directly routed `windows-installer-pipeline.md`; this reference owns cross-platform build reproducibility only. ## Generated source - Discover owners = `pubspec.yaml` + lockfile + `build.yaml` + `l10n.yaml` + `part` directives + `.gitignore`. - Classify each output = tracked | ignored | generated in lane; `git diff` cannot prove ignored output completeness. - Clean proof = ephemeral clone/archive containing tracked files only. - Required order per consuming lane: 1. resolve pinned Flutter/Dart + dependencies; 2. apply source-changing version materialization; 3. `dart run build_runner build`; 4. `flutter gen-l10n` when configured; 5. package-root analyze/test; 6. target-native build. - Current command SSOT = [Core Stack](core-stack.md); active `-d` + `--delete-conflicting-output` + `--delete-conflicting-outputs` commands are forbidden. - Codegen in another job = unavailable unless outputs are tracked or transferred as an explicit verified artifact. - Regression proof = clean checkout starts without ignored outputs → generation materializes expected owners → analyze + target build pass. ## Workflow migration - Before edit = inventory last green workflow: checkout/auth → toolchain → dependency restore → version materialization → codegen → native build → runtime bundle → installer → scans → preservation → artifact/publish. - After edit = map every retained/replaced step to each diagnostic + production lane. - Contract = fail when an applicable step is absent, duplicated, reordered across its dependency, or moved to a job without output transfer. - Prerequisite failure = cancel downstream paid work; no concurrent duplicate retry. - Independent cheap preparation/gates = parallel; dependent/native/external mutation = sequential. ## Apple boundary - Toolchain identity = resolved `DEVELOPER_DIR`/`xcode-select` path + Xcode build + Flutter/Dart version before any native generation/build command. - Flutter SwiftPM integration = generated package target + `Run Prepare Flutter Framework Script` pre-action + exact flavor scheme; project migration bytes are tracked, `ios/Flutter/ephemeral/` bytes are generated. - Command that rewrites generated Apple package metadata = isolate + verify post-command package products; regenerate immediately before device build/run under the same resolved Xcode environment. - Device proof = same flavor/entrypoint/toolchain that produced the native package graph; a prior IDE cache or different Xcode selection = no proof. - Tracked Xcode project change vs generated build side effect = classify before restore; never restore/commit one as the other. ## Platform golden - Platform golden = strict OS-specific baseline + actual-media review; tolerance relaxation cannot hide rasterization drift. ## Sources - [Dart build_runner](https://dart.dev/tools/build_runner) - [build_runner changelog](https://pub.dev/packages/build_runner/changelog) - [Flutter Swift Package Manager](https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-app-developers) - [Flutter iOS toolchain setup](https://docs.flutter.dev/platform-integration/ios/setup) -
common-patterns.md 6.6 KB
# Common Patterns ## Read first 1. Typed GoRouter helpers only. No raw route strings at call sites. 2. `ref.mounted` after every notifier await; `context.mounted` after widget await. 3. GoRouter redirect uses `ref.listen` + `refreshListenable`, never `ref.watch`; loading returns `null`. 4. Route-id lookups in `build()` are nullable + fallback UI; never throw. 5. Modals pop results only. Teardown lives in notifier. High-frequency boundaries debounce/gate/batch. 6. Splash/cover routing does not wait for background initial sync; route to the shell once auth/setup are known. 7. Provider-derived caches have one SSOT: computed provider, notifier/repo state, or memoized service/repo/datasource cache. 8. Widgets dispatch only. No `try/catch`, awaited notifier-result branching, top-level/global helper functions, `*Data` collection helper namespaces, or private collection derivation helpers. 9. Route-current guards use `context.isCurrentModalRoute`; never inline raw `ModalRoute` current-route checks. ## Trigger Signals: pagination, search debounce, form validation, GoRouter redirect, typed routes ## Rules — NEVER Violate 1. **MUST** use generated typed GoRouter route helpers as the navigation SSOT. Call `SomeRoute(...).go(context)` / `.push<T>(context)` directly. Route definitions own paths and params. Local sheet/dialog helpers own modal presentation and dismissal. 2. **NEVER** use `ref.watch()` inside GoRouter `redirect` — recreates router every state change. 3. **MUST** guard `if (!ref.mounted) return;` after EVERY `await` in notifiers (pagination, search, forms, sync). 4. **MUST** use `ref.listen()` + `refreshListenable` for GoRouter redirect triggers — NEVER `ref.watch()`. 5. **MUST** debounce search inputs (<=150ms) — NEVER call API on every keystroke. 6. **During loading, stay put.** Return `null` from redirect — NEVER bounce to splash on web refresh. 7. **MUST** guard page back with a typed fallback route for deep-link/resume safety. 8. **NEVER** keep splash/cover routes mounted while initial sync runs. After auth/setup state resolves, route to the shell and let sync hydrate local data in the background. Lint: `router_splash_waits_for_initial_sync`. 9. **Route-id lookups in widget `build()` MUST be nullable.** Use by-id provider + fallback UI. Never throw in `build()`. 10. **Wizard/deep-link mutation order MUST be:** persist write → targeted parent sync → navigate. 11. **Repo mounted rule:** keep `context.mounted` in widget async flows. Never swap to `mounted` to silence lint; refactor flow instead. 12. **NEVER** hand-wire zone/framework/dispatcher error handlers. Keep one startup owner: `await Crash.init(appRunner: () => runApp(...))`. See [error-reporting.md](error-reporting.md). 13. **MUST** keep the app shell declarative. The widget that returns `MaterialApp`, `CupertinoApp`, or `WidgetsApp` owns shell config only. Put root bootstrap listeners in a sibling/dedicated `ConsumerWidget` under `ProviderScope`; use `ref.watch` for eager initialization and `ref.listen` for UI side effects. Lint: `app_shell_bootstrap_side_effects`. 14. **NEVER** put controller logic or provider-derived caches in widgets. No widget `try/catch`, no `final ok = await ref.read(xProvider.notifier).save(); if (ok) ...`, no local `_isSaving` / `_isSubmitting` flags beside provider mutations, no top-level/global widget helper functions, no `ProviderSubscription` fields, no `ref.listenManual`, no `*Data` helper namespaces that filter/map/sort/index collections, and no private widget filtering/sorting/cache helpers. Cache/index/snapshot/mutation state belongs to one provider/notifier/repo/service SSOT. Lints: `riverpod_consumer_state_derived_cache`, `riverpod_consumer_state_provider_subscription`, `riverpod_listen_manual_forbidden`, `widget_top_level_function_boundary`, `widget_try_catch_boundary`, `widget_awaits_notifier_result`, `widget_local_mutation_flag`, `widget_derived_collection_logic`. 15. **MUST** use `context.isCurrentModalRoute` for route-current guards. Do not inline `ModalRoute.of(context).isCurrent`, local `route.isCurrent`, or `ModalRoute.isCurrentOf(context)` outside `core/extensions/context_extensions.dart`. Lint: `use_context_is_current_modal_route`. ## Pattern Sections - [Navigation Flow](common-patterns/navigation-flow.md) - [Lists, Forms, and Workflows](common-patterns/lists-forms-workflows.md) - [Routing and App Shell](common-patterns/routing-app-shell.md) - [Delta Sync](common-patterns/delta-sync.md) - [Modals and Navigation](common-patterns/modals-navigation.md) - [Debounce, Gate, and Batch](common-patterns/debounce-gate-batch.md) ## Route-Param Safety + Wizard Sequencing Read [Navigation Flow](common-patterns/navigation-flow.md#route-param-safety--wizard-sequencing). ## Pagination Read [Lists, Forms, and Workflows](common-patterns/lists-forms-workflows.md#pagination). ## Search with Debounce Read [Lists, Forms, and Workflows](common-patterns/lists-forms-workflows.md#search-with-debounce). ## Local Filter (No API Call) Read [Lists, Forms, and Workflows](common-patterns/lists-forms-workflows.md#local-filter-no-api-call). ## Form Validation Read [Lists, Forms, and Workflows](common-patterns/lists-forms-workflows.md#form-validation). ## Batch Processing Read [Lists, Forms, and Workflows](common-patterns/lists-forms-workflows.md#batch-processing). ## Pull-to-Refresh Read [Lists, Forms, and Workflows](common-patterns/lists-forms-workflows.md#pull-to-refresh). ## Typed GoRouter Route SSOT Read [Routing and App Shell](common-patterns/routing-app-shell.md#typed-gorouter-route-ssot). ## Long-Running Sync/Auth Cancellation Read [Routing and App Shell](common-patterns/routing-app-shell.md#long-running-syncauth-cancellation). ## App Shell + Bootstrap Boundary Read [Routing and App Shell](common-patterns/routing-app-shell.md#app-shell--bootstrap-boundary). ## Delta Sync (Incremental Remote Pull) Read [Delta Sync](common-patterns/delta-sync.md#delta-sync-incremental-remote-pull). ## Modal Snapshot Pattern Read [Modals and Navigation](common-patterns/modals-navigation.md#modal-snapshot-pattern). ## Dismiss Modal → Push Route (Bottom Sheet Navigation) Read [Modals and Navigation](common-patterns/modals-navigation.md#dismiss-modal--push-route-bottom-sheet-navigation). ## Pop Fallback Helpers Check Navigator Stacks Read [Modals and Navigation](common-patterns/modals-navigation.md#pop-fallback-helpers-check-navigator-stacks). ## Debounce, Gate, and Batch Read [Debounce, Gate, and Batch](common-patterns/debounce-gate-batch.md#debounce-gate-and-batch). ## Remote Functions + destructive reconciliation Read [Debounce, Gate, and Batch](common-patterns/debounce-gate-batch.md#remote-functions--destructive-reconciliation). -
core-stack.md 4.3 KB
# Core Stack ## Read first 1. Package constraints = this file only. 2. Constraint change → `flutter pub get` → `dart run build_runner build` → `dart analyze` → `flutter test`. 3. Run a normal, flag-free code-generation command first. Clean only after that command fails. ## Verified application family The compatibility fixture requires Dart `>=3.13.0 <4.0.0` and Flutter `>=3.47.0`. It solves this application and generator family together: This is a tested floor, not a floating "latest" policy. Projects below it must upgrade their SDK constraint, CI image, and local toolchain together; do not add legacy branches. Re-run the compatibility fixture before raising the floor. | Package | Constraint | Purpose | |---|---:|---| | `flutter_riverpod` | `3.4.3` | State management | | `riverpod_annotation` | `4.0.7` | Provider annotations | | `riverpod_generator` | `4.0.9` | Provider generation | | `freezed_annotation` | `3.1.0` | Immutable models | | `freezed` | `4.0.2` | Immutable-model generation | | `json_annotation` | `^4.12.0` | JSON annotations | | `json_serializable` | `6.14.1` | JSON generation | | `go_router` | `^18.0.1` | Routing | | `go_router_builder` | `4.5.0` | Typed-route generation | | `hive_ce` | `^2.20.0` | Local persistence | | `hive_ce_flutter` | `^2.3.4` | Flutter persistence integration | | `hive_ce_generator` | `1.11.3` | Hive adapter generation | | `build_runner` | `2.16.1` | Build orchestration | | `analyzer` | `14.4.0` | Analyzer | | `flutter_lints` | `6.0.0` | Base Flutter lints | ## Analyzer plugins Flutter/Riverpod packages use both plugins in the root `analysis_options.yaml`. They are analyzer-plugin configuration, not `pubspec.yaml` dependencies. ```yaml plugins: riverpod_lint: ^3.1.9 flutter_skill_lints: ^0.13.0 ``` `flutter_skill_lints ^0.13.0` is built against analyzer `^14.4.0`, `analyzer_plugin ^0.14.17`, and `analysis_server_plugin ^0.3.23`. `riverpod_lint ^3.1.9` shares the analyzer-plugin configuration above. Pure-Dart CLI packages keep their native Dart analysis profile and do not add these Flutter/Riverpod plugins. ## Compatibility proof `tool/run_compatibility_fixture.py` resolves the application family, generates Riverpod, Freezed, Hive, JSON, and typed-route code, confirms analyzer `14.4.0`, then runs the analyzer with both plugins. Its deliberate probe requires a `flutter_skill_lints` `avoid_null_bang` diagnostic and a `riverpod_lint` `missing_provider_scope` diagnostic while rejecting `server.pluginError`. It removes the probe before the Flutter test and web build. The fixture uses the top-level hosted plugin configuration without a local path or dependency override. Its analyzer probe proves that both plugins load and report their expected diagnostics without `server.pluginError`. Before publishing a lint update, set `FLUTTER_SKILL_LINTS_PATH` to its local package directory; after publishing, run the default hosted check. Re-run the fixture after any package, Flutter, Dart, analyzer, or plugin configuration upgrade. Do not use dependency overrides as compatibility proof. ## Package sources - [Dart package dependencies](https://dart.dev/tools/pub/dependencies) - [Dart analyzer plugins](https://dart.dev/tools/analyzer-plugins) - [analyzer](https://pub.dev/packages/analyzer) - [analyzer_plugin](https://pub.dev/packages/analyzer_plugin) - [analysis_server_plugin](https://pub.dev/packages/analysis_server_plugin) - [riverpod_lint](https://pub.dev/packages/riverpod_lint) - [riverpod_generator](https://pub.dev/packages/riverpod_generator) - [freezed](https://pub.dev/packages/freezed) - [hive_ce_generator](https://pub.dev/packages/hive_ce_generator) - [go_router_builder](https://pub.dev/packages/go_router_builder) - [build_runner](https://pub.dev/packages/build_runner) - [json_serializable](https://pub.dev/packages/json_serializable) ## Code generation ```bash dart run build_runner build ``` - Use the flag-free command above. - Run `dart run build_runner clean` only as a separate recovery step after a failed normal build, then run the flag-free build again. - `-d`, `--delete-conflicting-output`, and `--delete-conflicting-outputs` are forbidden in active guidance, scripts, fixtures, and examples. - Version change → installed command help + [official changelog](https://pub.dev/packages/build_runner/changelog). -
dart-decimate.md 1.3 KB
# Dart Decimate ## Read first - Runtime owner = the project; guidance owner = this reference. - Package root = requested `pubspec.yaml`; run a standalone scan from its Git root. - Hard Eng install → `python3 .hooks/hard-eng.py check`; its configured native check owns scope and report validation. - Another project → use its established Dart Decimate check. If it has none, run `pnpm dlx --config.ignore-scripts=false --allow-build=dart-decimate dart-decimate@latest check . --threshold 0 --format json` from its Git root. - The project workflow schedules its integrated check; reuse a valid same-scope result instead of launching another full runner after a focused edit check. - Do not add a wrapper, dependency, binary copy, package-root `tool/` bundle, or global coordinator solely for this skill. - Dart Decimate + `dart analyze` = complementary required gates. - A finding or nonzero exit is a failure: inspect within the requested workspace, fix its owner, and rerun the exact gate. Auto-fix remains preview-only until mutation approval. ## Git pre-push - Existing project hook or gate → preserve its owner and run its established check. - This skill does not install, replace, or require a Git hook, and it does not change `core.hooksPath`. - For a project without an established check, run the standalone native command above before push. -
dart-mcp-e2e-testing.md 22 KB
# Flutter Runtime and E2E Testing ## Read first 1. Use available Dart MCP for development operations; use compatible Dart MCP or Marionette for live app interaction. Durable regression tests remain in the project runner. Use native commands when MCP is unavailable or cannot perform the operation. 2. Use requested device class, app entrypoint, env, actors, accounts, data. 3. Select by semantics/text/tooltips/central `ValueKey`; no coordinate-tap primary selectors. 4. Wait on observable UI/backend state, never blind sleep. 5. Verify source-of-truth via admin/API/CLI/read model for remote/shared state. 6. Keep the target app and host driver separate. The target may use Flutter and app code. The host driver must stay in its host-side API and Dart code. 7. Label every run `clean` or `preserved` and record that state in its receipt. ## Trigger Signals: E2E testing, Dart MCP, Marionette MCP, flutter_driver, integration_test, source-of-truth verification https://docs.flutter.dev/ai/mcp-server Runtime E2E means real app behavior on a real simulator/device. Static review, screenshots without interactions, widget tests, or one-device happy paths do not prove sync/collaboration/cloud behavior. ## Rules — NEVER Violate 1. MUST select an available tool compatible with the requested operation and app mode; an MCP connection does not replace durable regression tests or prove a release artifact. 2. MUST run on the asked device class. iOS request means iOS simulator/device; Android request means Android emulator/device. 3. MUST test the requested app entrypoint/config. Do not switch environments. 4. MUST define actors, devices, accounts, and test data before running remote/shared-state flows. 5. MUST use stable text, semantics, tooltips, or `ValueKey` selectors from the widget tree. NEVER coordinate-tap as the primary selector. 6. MUST wait for semantic UI state or backend/source-of-truth state. NEVER rely on blind sleeps. 7. MUST capture logs after each major flow segment and after every failure. 8. MUST run fail -> fix -> restart/relaunch -> rerun failed segment -> rerun downstream impacted segments. 9. MUST verify source-of-truth state with an admin/API/CLI/read model when remote data is involved. 10. MUST stop app processes and clean test data at end. 11. MUST verify a central widget key registry exists before adding E2E selectors. Default: `lib/core/testing/app_widget_keys.dart` or existing project equivalent. 12. MUST use a deterministic E2E entrypoint when the app needs runtime overrides or Flutter Driver/MCP connectivity. Default: `lib/main_dev.dart` or existing project equivalent. 13. MUST reject an unknown scenario before executing any valid journey; silent fallback to a default scenario is forbidden. 14. MUST fail the run when asserted logs contain a critical error, even if the driver/process exits 0. 15. MUST capture evidence while the app is running on the asserted screen; a screenshot after exit, crash, or navigation away proves nothing. 16. MUST prove the host driver separately with analysis and a host-side compile check before the full target run. 17. MUST wait for a closed modal's old key to be absent before opening another modal that reuses that key. 18. MUST treat native prompts as platform UI. Flutter widget-tree state alone cannot prove a file picker, permission prompt, keyboard, or share sheet completed. 19. MUST write one terminal receipt per scenario. A timeout, lost process, missing receipt, pending timer, pending future, subscription, or callback is a failed or incomplete run. ## Choose the execution tool | Need | Route | |---|---| | Analyze, format, test, launch or inspect development state | Available official Dart and Flutter MCP tools; otherwise the project's native commands. | | Explore or smoke-test a running Flutter UI | Existing compatible Dart MCP UI support, or Marionette when its app binding and server are configured. | | Repeatable regression or CI proof | Existing `integration_test`/device runner and fixtures. Save the discovered failure there rather than relying on an agent's interaction transcript. | | Native OS surface or installed release | Platform automation and the exact requested artifact; Flutter widget inspection alone is insufficient. | ### Optional Marionette runtime interaction [Marionette](https://github.com/leancodepl/marionette_mcp) drives a running Flutter app, not a pure-Dart CLI or backend. Treat it as optional tooling, not a default application dependency or global MCP installation. - Before setup, check the project's Flutter/package compatibility and align the `marionette_flutter` binding with the MCP server version. Use the existing development bootstrap; initialize Marionette only for the intended debug run, before another binding claims the process. Keep widget/integration-test binding initialization separate. See the [single-binding setup](https://github.com/leancodepl/marionette_mcp/blob/main/docs/flutter-setup.md). - Discover the actual available tool schema. Connect to the launched app's VM service URI, inspect `get_interactive_elements`, then target actions by stable key or semantics identifier. Reinspect changed screens and assert the visible response after each action. See the [tool reference](https://github.com/leancodepl/marionette_mcp/blob/main/docs/mcp-tools.md). - Custom controls may require widget/text configuration; `get_logs` requires a configured collector. Missing elements or logs are capability gaps, not proof of absent UI or errors. See [configuration](https://github.com/leancodepl/marionette_mcp/blob/main/docs/configuration.md). - Marionette depends on the VM service and does not run against release builds. Its gestures can vary with platform and custom controls. Preserve the requested release/native proof through the appropriate runner; do not silently substitute a debug session. See [limitations](https://github.com/leancodepl/marionette_mcp/blob/main/docs/troubleshooting.md). ## Dart MCP Tool Map Tool names depend on the connected server/client; discover its actual schemas. These existing Dart MCP operations are not Marionette tool names. | Goal | Tool | |------|------| | Set project roots | `mcp_dart_add_roots` | | Analyze code | `mcp_dart_analyze_files` | | Auto-fix analyzable issues | `mcp_dart_dart_fix` | | Format Dart | `mcp_dart_dart_format` | | List devices | `mcp_dart_list_devices` | | Launch app | `mcp_dart_launch_app` | | Run tests | `mcp_dart_run_tests` | | Hot restart | `mcp_dart_hot_restart` | | List running apps | `mcp_dart_list_running_apps` | | Fetch app logs | `mcp_dart_get_app_logs` | | Inspect widget tree | `mcp_dart_get_widget_tree` | | Get selected widget | `mcp_dart_get_selected_widget` | | Pick widget in app | `mcp_dart_set_widget_selection_mode` | | Stop app | `mcp_dart_stop_app` | | Remove roots | `mcp_dart_remove_roots` | ## Planning Matrix Choose the smallest matrix that proves the feature: | Feature kind | Required runtime proof | |---|---| | Local-only UI/form | One app instance, create/edit/delete/error/empty, relaunch if persisted | | Remote CRUD | One app instance plus source-of-truth verification after create/update/delete | | Sync/realtime/cache invalidation | Writer app + observer app, observer updates without manual refresh | | Collaboration/team/chat/shared document | Two actors or two app instances minimum; ownership/member/removal/destructive paths | | Invite/code/link/token/slug/order generated remotely | Verify generated value in source of truth, then UI shows same value after mutation | | Permissions/auth gates | Allowed actor succeeds, denied/revoked actor sees blocked or fallback state | | Offline/retry/persistence | Disconnect or simulate failure when feasible, relaunch, retry, and verify no duplicate writes | ## End-to-End Loop 1. Establish the project root and selected runtime connection once; for Marionette, connect after the configured app is launched. 2. Analyze before launch. Fix clear compile/analyzer issues first. 3. List devices and pick the requested simulator/device class. 4. Launch every app instance needed for the matrix. 5. Get widget tree before interacting on each screen. Select by stable text/semantics/key. 6. Start from a known state: signed-out/signed-in actor, clean route, known backend/source-of-truth data. 7. Run one segment at a time: setup, create, observe, update, observe, destructive/remove, relaunch, cleanup. 8. After each segment, verify UI state, logs, and remote/source-of-truth state when applicable. 9. On failure, capture logs, patch smallest failing area, hot restart/relaunch, rerun failed + impacted segments. 10. After all runtime flows pass, run relevant unit/widget tests. 11. Clean test data, sign out if needed, stop all app processes. ## E2E Entrypoint Use the production app bootstrap, but make test-only startup explicit: ```dart import 'package:flutter_driver/driver_extension.dart'; import 'package:my_app/main.dart' as app; const forceSignedOut = bool.fromEnvironment('E2E_FORCE_SIGNED_OUT'); Future<void> main() async { enableFlutterDriverExtension(); await app.runAppRoot(overrides: [ if (forceSignedOut) authProvider.overrideWith(SignedOutAuthNotifier.new), ]); } ``` Rules: - Keep test-only overrides out of `main.dart`. - Prefer `--dart-define` flags for known app states: signed out, onboarding incomplete, update required, disabled notifications. - Do not mock the feature under test in the E2E entrypoint. - Launch the target file explicitly: `flutter run -t lib/main_dev.dart`. Flutter's current integration-test documentation keeps the app test under `integration_test/`. When `flutter drive` is used, the host adapter belongs in `test_driver/`, and the target and host files are separate processes. See the [integration test guide](https://docs.flutter.dev/testing/integration-tests) and the [Flutter drive command source](https://github.com/flutter/flutter/blob/master/packages/flutter_tools/lib/src/commands/drive.dart). ## App and host-driver boundary The app target may import `dart:ui`, Flutter widgets, rendering, material, the app package, and feature code. The host driver may import `dart:async`, `dart:io`, `package:test`, `package:flutter_driver` host APIs, or the official `integration_test_driver` adapter. Its own code and helper closure MUST NOT import `dart:ui`, `package:flutter/rendering.dart`, `package:flutter/widgets.dart`, `package:flutter/material.dart`, or the app package. Do not place app widgets, providers, repositories, or platform plugin code in `test_driver/`. Prove the boundary before running the device flow. Run analysis from the package root: a folder argument skips analyzer plugins, including `avoid_flutter_host_driver_imports`. ```text dart analyze --fatal-infos dart compile exe test_driver/<scenario>_test.dart flutter drive --driver=test_driver/<scenario>_test.dart \ --target=integration_test/<scenario>_test.dart ``` The compile step must use the project's resolved package configuration. If a driver needs a Flutter-only helper, move that helper into the target `integration_test/` file and send only serializable results to the host. ## Clean and preserved restart states These are different tests and must never be inferred from a screenshot. `clean` means the exact app installation and its local data were removed by the platform tool before launch. Verify the app process is gone, the app container or data directory is gone, and no old test record is present before launch. A new install must create the container again. Record the platform command and the verification result. Do not use a generic cache clear when the requirement is a clean install. `preserved` means the same installation and local data remain. Do not uninstall, clear storage, reset the simulator, or recreate the test account between the write and relaunch phases. Assert the saved record before and after relaunch, and record an app/data identity at both points. The current `flutter drive` contract includes `--keep-app-running` and `--use-existing-app`. The first controls whether Flutter stops the app after the driver finishes. The second connects to an already running VM service. They do not, by themselves, prove that app data was preserved. The current command does not expose `--no-uninstall-first`, so do not add that flag to a command. Use the platform's documented uninstall or data-reset command only for the explicit `clean` phase. Capture the exact Flutter tool version and command in the receipt. ## Gestures, modals, and native prompts - Scroll a keyed or semantically named scrollable. Wait until the target item is visible before the gesture, and assert the target text or state after it. - Dismiss the keyboard, snackbar, sheet, and old dialog before the next phase. After closing a dialog, issue `waitForAbsent(oldDialogKey)` before reusing its key for a new dialog. This prevents a stale route from satisfying the next `waitFor`. - Use the selected driver's or runtime MCP's gesture API with an explicit duration and pointer update frequency when it supports them. For an API that exposes a frequency for a stationary long press or drag, use about 60 Hz. The official [`FlutterDriver.scroll`](https://api.flutter.dev/flutter/flutter_driver/FlutterDriver/scroll.html) API defaults to 60 Hz, while `FlutterDriver` does not expose a long-press frequency argument. Do not invent an unsupported flag or make a coordinate and timing guess the primary proof. - A file chooser, permission prompt, keyboard, share sheet, or other native surface is outside the Flutter widget tree. Use the platform automation path, then assert the returned file or permission state in the app. File filters must contain a real extension, MIME type, or platform UTI. An empty filter group is not an accepted file type. ## Scenario receipts and cleanup Keep one machine-readable manifest for the run. Give each scenario a stable id, a unique run id, and an explicit parent or child link. Store the requested state, expected outcome, direct assertion, actual outcome, source-of-truth check, cleanup result, and terminal status. Compute totals from the manifest and terminal child receipts, not from handwritten totals. Write a child receipt only after its direct assertion passes. Aggregate only after every child scenario has a terminal receipt. Unknown scenario ids fail before execution. ```json { "runId": "run-<unique-id>", "scenario": "shared-item", "restartState": "preserved", "steps": [ {"id": "create-parent", "parentId": null, "status": "passed"}, {"id": "create-child", "parentId": "create-parent", "status": "passed"} ], "totals": {"expected": 2, "completed": 2}, "status": "passed", "cleanup": "passed" } ``` Validate unique ids, parent-child links, computed totals, and terminal status before writing the receipt. A UI counter, a screenshot, or a wrapper exit code of zero is not enough to prove a scenario completed. Keep complete app, native, driver, and backend logs for the run. A process exit without a terminal receipt, a lost receipt, an incomplete log, or an unexplained pending async handle is `incomplete` and fails the run. Critical Flutter, Dart, native, backend, plugin, Riverpod, and driver errors fail the run even when the process exits successfully. Cancel or await timers, futures, stream subscriptions, and callbacks that belong to the scenario before cleanup. ## Journey Checklist Template Copy this per feature. - Entry route opens from cold launch - Auth/account state is known - Empty/loading/error states render - Primary create path works - Source of truth contains created data - Observer sees create without manual refresh when shared/sync applies - Update/edit path works - Observer sees update without manual refresh when shared/sync applies - Delete/remove/leave/revoke path works - Observer or revoked actor sees correct fallback/empty/blocked state - Generated values shown in UI equal source-of-truth values - Back/deep-link/reopen path works - Persisted data survives app restart when persistence applies - No new critical logs, assertions, unhandled exceptions, or permission errors - Test data cleaned ## Multi-Actor / Sync Protocol Use this for teams, squads, chat, shared documents, shared lists, invitations, realtime dashboards, multiplayer, collaborative editing, or any remote state expected to appear elsewhere. 1. Actor A creates parent resource. 2. Verify parent in source of truth. 3. Actor B joins/gets access. 4. Verify membership/access in source of truth. 5. Actor A creates child/shared item. 6. Actor B sees item without manual refresh. 7. Actor A updates/renames/reorders/status-changes item. 8. Actor B sees exact update without manual refresh. 9. Actor B performs allowed mutation. 10. Actor A sees it without manual refresh. 11. Actor A removes Actor B or deletes child/parent. 12. Actor B sees blocked/empty/fallback state without stale detail screen. 13. Relaunch both apps and verify final state still correct. 14. Clean all created data. ## Source-of-Truth Verification Use the project's real backend/admin read path, local database inspector, CLI, emulator API, or service SDK. Keep this generic; do not bake one vendor into the skill. Verify: - IDs used by the app match transport/source-of-truth IDs where required. - Generated fields, counters, order indexes, invites, slugs, and membership rows are current. - Deleted/revoked records are gone or marked inactive as designed. - Observer permissions match final state. - No duplicate records were created by retry/relaunch. ## Harness Quality - Every phase starts from a known screen and account. - Close overlays, dialogs, keyboards, snackbars, and sheets before the next phase. - Prefer `ValueKey`, semantics labels, exact visible text, or tooltip selectors. - Add deterministic keys from the central key registry when a real user-visible selector is ambiguous. - No inline string `ValueKey`s in widgets/tests/E2E harnesses. - Record screenshots only as evidence after behavior checks; screenshots are not the test by themselves. - Logs are part of assertions: check for critical errors even when UI looks correct. - Test data names include a run id/timestamp so cleanup is safe. - Sensitivity probe = run one invalid scenario and prove a non-zero/test failure before trusting valid cases. - Selector proof = assert the selected element/state before and after the gesture; a completed tap command alone is insufficient. - Screenshot subject = exact tested screen + expected state + current app process; wrong/blank/home-screen media = `FAIL`. - Runner success + critical Flutter/native/backend log = `FAIL`; parse and assert logs before final exit. ## Native integrations that build but fail on device Bind the reproduction to the requested physical device or simulator, OS version, account/environment, package or bundle ID, flavor, entrypoint, build mode, version/build and source revision. Verify the installed artifact matches the tested build; a local APK does not prove behavior of an AAB-derived or distributed build. Use the existing scenario receipt for this evidence. Trace only the boundaries involved in the reported failure: | Boundary | Evidence to obtain | |---|---| | Packaged configuration | Required native declaration, entitlement or resource exists in the built artifact. | | Permission and platform service | Read back the available OS state and perform the requested capability/data operation; a completed permission request alone is insufficient. | | Plugin and app owner | Initialization and the smallest relevant call return a value or exact error; trace that result through app state to the UI. | | Registration and transport | Current app-instance registration belongs to the expected account/environment; read back the actual provider result. | | Device outcome | Native receipt, suppression or rejection, followed by the requested presentation, data change or tap/deep-link behavior. | Stop at the first failing or unknown prerequisite. Choose one check that distinguishes the remaining explanations before resending, rebuilding or reinstalling. Bound a hanging observation and capture its terminal result; a diagnostic timeout does not establish the correct product timeout or fallback. Check the installed plugin version and current platform/provider documentation before relying on platform-specific behavior. Provider acceptance is not OS receipt or visible delivery. Prove requested foreground, background, terminated, denied/revoked and tap states independently where applicable. For scheduled work, check both pending state and actual firing; for device data, prove the requested type/time range before investigating UI mapping. An unavailable OS readback remains a gap, not a successful receipt. After rebuild/update, verify installed-build identity again. After reinstall or data reset, re-establish permission, session, installation and token/registration evidence instead of reusing the previous instance's results. With an authorized fix, rerun the original reproduction and affected downstream states; report any unavailable requested device, artifact or boundary explicitly. ## Failure Triage - Assertion in logs: fix state/lifecycle first. - Backend write fail: check datasource id contract, auth/permission, environment, and payload schema. - Observer stale: check event contract, exact subscriptions, source-of-truth refetch, provider invalidation/sync, and actor permissions. - Widget not tappable: inspect tree, add deterministic key/semantics, retest. - Generated value stale: force source-of-truth refresh after mutation and before navigation/success. - Revoked actor still sees detail: clear selected state, route fallback, invalidate/refetch affected providers. - Duplicate data after retry/relaunch: fix idempotency, mutation pending state, and cleanup. ## Exit Criteria 1. All target journeys pass on the requested device class. 2. Remote/shared flows pass with writer + observer app instances when applicable. 3. Source-of-truth verification matches UI. 4. Create/update/delete/remove/relaunch paths pass. 5. No new critical log errors. 6. Relevant tests pass after final runtime fix pass. 7. Test data cleaned. 8. All app processes stopped. 9. Invalid-scenario sensitivity probe failed as expected. 10. Every delivered screenshot/recording was opened and matched to the asserted screen and state. -
dart-patterns-records.md 5.1 KB
# Dart Patterns & Records ## Read first 1. Multiple return values → Records, not `Map<String, dynamic>` or parallel lists. 2. Multiple ID types → extension types, not raw `String`. 3. Pattern null-bind with `if (value case final v?)`; never `value!`. 4. Switch cases use guard clauses; do not nest if/else in case bodies. 5. On Dart 3.13+, use primary/concise constructors and dot shorthand when the surrounding context makes the type unambiguous. ## Class modifiers | Modifier | Extend outside lib | Implement outside lib | Instantiate | Mixin | |---|:---:|:---:|:---:|:---:| | `abstract class` | yes | yes | no | no | | `abstract interface class` | no | yes | no | no | | `abstract final class` | no | no | no | no | | `sealed class` | no | no | no | no | | `base class` | yes | no | yes | no | | `interface class` | no | yes | yes | no | | `final class` | no | no | yes | no | | `mixin class` | yes | yes | yes | yes | Contract = `abstract interface class`; Freezed union = `sealed class`; helper namespace = `abstract final class`. ## Trigger Signals: Records, pattern matching, extension types, destructuring, sealed class switch, primary constructors, concise constructors, dot shorthand, `@RecordUse` ## Dart 3.13 conciseness Use a primary constructor when it removes repeated field declarations: ```dart class Point(final int x, final int y); ``` Inside a class, use concise constructor names: `new()`, `new named()`, `factory()`, and `factory named()`. Preserve `const`, initializers, redirects, and bodies; only the repeated class name disappears. Use dot shorthand whenever an existing context supplies the exact namespace: ```dart mainAxisAlignment: .center, padding: const .all(16), MainAxisAlignment alignment = .center; ``` Keep the type name when there is no contextual type (`final axis = Axis.horizontal`) or when it is a separate namespace (`Color color = Colors.red`). `@RecordUse` is only for `dart:ffi`/Code Assets bindings whose native linker uses `package:record_use`; normal Flutter application code does not add it. ## Records (Dart 3.0) Use for multiple return values. ```dart // Positional (String, int) userInfo() => ('Alice', 30); final (name, age) = userInfo(); // Named — prefer when 3+ fields or field names add clarity ({String name, int age, String role}) getProfile() => (name: 'Alice', age: 30, role: 'admin'); final (:name, :age, :role) = getProfile(); ``` Repository pagination: ```dart abstract interface class IProductRepository { Future<({List<Product> items, bool hasMore})> fetchPage(int page); } // Usage final (:items, :hasMore) = await repo.fetchPage(1); ``` ## Extension Types (Dart 3.3) Compile-time wrapper; runtime = underlying type. ```dart extension type UserId(String value) { bool get isValid => value.isNotEmpty; } extension type ProductId(String value) {} void deleteProduct(ProductId id) { /* ... */ } void onDelete(UserId userId, ProductId productId) { deleteProduct(userId); // compile-time ERROR — wrong type deleteProduct(productId); // OK } ``` Use for: entity IDs, units (Meters, Grams), currencies (USD, EUR). NEVER raw `String`/`int` IDs when multiple distinct ID types in same feature. ## Patterns ### if-case ```dart // Null-check and bind — preferred over null assertion ! if (user case final u?) { return ProfileScreen(user: u); } // Type test and bind if (event case AuthEvent(:final userId)) { handleAuth(userId); } ``` Private `final` fields auto-promote after null checks (Dart 3.2) — no ! needed: ```dart class Repo { const Repo(this._token); final String? _token; bool get isAuthorized { if (_token != null) { return _token.isNotEmpty; // promoted, no ! required } return false; } } ``` ### Guards ```dart const _premiumPriceThreshold = 1000; return switch (product) { Product(:final price) when price > _premiumPriceThreshold => const PremiumBadge(), Product(:final stock) when stock == 0 => const OutOfStockBadge(), _ => const DefaultBadge(), }; ``` ### Logical-or patterns ```dart return switch (state) { Loading() || Refreshing() => const Shimmer(), Error(:final message) => ErrorView(message: message), Loaded(:final items) => ProductList(items: items), }; ``` ### Object & list destructuring ```dart // Object pattern with guard switch (auth) { case Authenticated(:final user, :final expiresAt) when expiresAt.isAfter(now): return AuthenticatedUser(user); case _: return const LoginScreen(); } // List pattern final [first, ...rest] = sortedProducts; final [_, second] = topTwo; // _ discards first ``` ## Wildcard Variables (Dart 3.7) `_` non-binding — declare many times, no collision: ```dart // Discard positional values in destructuring final (_, price, _) = (id, 9.99, sku); // Ignore callback parameters Timer.periodic(const Duration(seconds: 1), (_) => onTick()); ``` ## Null-aware Collection Elements (Dart 3.8) `?expr` inserts only when non-null. `...?list` spreads only when non-null: ```dart final children = [ const HeaderWidget(), ?optionalBanner, // skipped if null ...?itemsByGroup[groupId], // spread skipped if null const FooterWidget(), ]; ``` -
deep-linking.md 6.8 KB
# Deep Linking ## Read first 1. Typed routes + generated helpers for every in-app nav call. 2. Redirect policy lives in a pure resolver; matrix-test auth/setup/location states. 3. Validate params at route boundary; missing/stale resources render fallback/typed redirect, never throw in `build()`. 4. App Links/Universal Links and custom schemes require platform registration + Flutter delivery + cold/warm signed-state E2E. 5. Deep links still pass auth/setup/update gates. ## Trigger Signals: deep linking, Universal Links, App Links, custom URI scheme, native extension/activity link, GoRouter redirect, assetlinks.json, apple-app-site-association ## Rules 1. **MUST** use typed routes (`go_router_builder`) for in-app navigation. 2. **MUST** keep redirect decisions in a pure resolver and matrix-test them. 3. **MUST** validate route parameters before using them. Missing or invalid IDs render fallback UI or redirect to a typed fallback. 4. **MUST** configure Android App Links and iOS Universal Links when URLs should open the installed app. 5. **MUST** test cold-start, warm-start, signed-out, signed-in, setup-incomplete, and stale-link paths. 6. **MUST** navigate with generated route helpers such as `SomeRoute(...).go(context)` / `.push<T>(context)`. 7. **MUST NOT** assume deep links bypass auth/setup/update gates. The resolver owns that policy. 8. **MUST** keep scheme, host, path, and parameter names identical across link producer, Android/iOS registration, Flutter delivery, and typed router parsing. ## Web URL Strategy Use path URLs for web apps that need shareable links. ```dart import 'package:flutter_web_plugins/url_strategy.dart'; Future<void> main() async { usePathUrlStrategy(); WidgetsFlutterBinding.ensureInitialized(); await Crash.init( appRunner: () => runApp(const ProviderScope(child: AppRoot())), ); } ``` ## Route Parameter Safety Never throw from widget `build()` for a missing route ID. Parse at the route boundary, then use nullable by-id providers and fallback UI. ```dart class ProductRoute extends GoRouteData { const ProductRoute({required this.id}); final String id; @override Widget build(BuildContext context, GoRouterState state) { if (id.isEmpty) { return const ProductMissingScreen(); } return ProductDetailScreen(productId: id); } } ``` ## Redirect Resolver Keep the closure thin. ```dart String? resolveAppRedirect({ required String location, required AuthStatus authStatus, required SetupStatus setupStatus, }) { if (authStatus == .loading) { return null; } if (authStatus == .signedOut) { return isPublicLocation(location) ? null : const LoginRoute().location; } if (setupStatus == .incomplete) { return location == const SetupRoute().location ? null : const SetupRoute().location; } return null; } ``` Test the resolver matrix before shipping any auth/setup/deep-link route change. ## Route Boundary Deep links enter through GoRouter, and app code navigates through the generated typed route helpers. Keep route definitions, redirect resolver wiring, and generated-route `.location` usage in the router boundary. ```dart ProductDetailRoute(id: productId).go(context); ``` ## Android App Links Add an intent filter inside the main activity: ```xml <intent-filter android:autoVerify="true"> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="https" android:host="example.com" /> </intent-filter> ``` Host `https://example.com/.well-known/assetlinks.json`: ```json [ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.example.app", "sha256_cert_fingerprints": ["YOUR_SHA256_CERT_FINGERPRINT"] } } ] ``` Validation: ```bash adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://example.com/products/123" com.example.app ``` ## iOS Universal Links Enable associated domains: ```xml <key>com.apple.developer.associated-domains</key> <array> <string>applinks:example.com</string> </array> ``` If using Flutter default deep linking: ```xml <key>FlutterDeepLinkingEnabled</key> <true/> ``` If a third-party deep-link plugin owns links, set Flutter default handling off to avoid duplicate dispatch. Host `https://example.com/.well-known/apple-app-site-association` without a file extension: ```json { "applinks": { "apps": [], "details": [ { "appIDs": ["TEAMID.com.example.app"], "components": [ { "/": "/products/*" }, { "/": "/invite/*" } ] } ] } } ``` Validation: ```bash xcrun simctl openurl booted https://example.com/products/123 ``` ## Custom schemes and native producers - URI contract SSOT = scheme + host + path + required parameters + encoding rules. - Native producer = share extension, Live Activity, widget, notification, shortcut, or platform intent that emits the URI. - Android = matching intent filter; iOS = matching `CFBundleURLTypes` or accepted native delivery owner. - Flutter = platform event reaches one link ingress, then typed router parsing; no second ad hoc parser. - Contract fixture = producer URI parses to the same typed route on Android and iOS. - Device proof = cold app + warm app + signed out + signed in + stale/invalid parameter. - A platform launch with no asserted Flutter route = failure; a router unit test alone proves no native delivery. ## E2E Matrix | Case | Expected proof | |---|---| | Cold start signed out | Link opens login or public page, then resumes intended path after sign-in if supported | | Cold start signed in | Link opens target screen after loading gates settle | | Setup incomplete | Resolver sends user to setup without losing intended target when required | | Missing resource | Fallback/empty screen, no build throw | | Revoked permission | Blocked or fallback state; no stale detail data | | Web refresh | Current path survives loading state | ## Checklist - [ ] Typed routes exist for link targets and call sites use generated route helpers. - [ ] Redirect resolver is pure and matrix-tested. - [ ] Android App Links or iOS Universal Links are configured when required. - [ ] Custom/native URI scheme, host, path, parameters, and encoding match producer + platform registration + Flutter ingress + typed route. - [ ] Hosted association files match app IDs, bundle IDs, package names, and SHA fingerprints. - [ ] Cold/warm, signed-in/signed-out, setup, stale, and permission-denied paths are E2E tested. - [ ] Missing route params or missing resources do not throw from widget `build()`. - [ ] Cold/warm native delivery reaches the asserted Flutter screen on the target device; signed-state gates still apply. -
error-reporting.md 6.7 KB
# Crash Reporting ## Read first 1. Existing provider + accepted telemetry/privacy contract = preserve. 2. Adding/replacing/dual-running a provider = material external-data change → explicit approval + `security-review`. 3. Accepted/present provider only → `Crash` = one tiny provider-neutral facade; public API = `init`, `log`, `error`. 4. SDK imports/calls = `crash_service.dart` only; feature code calls `Crash`. 5. Startup = one owner; SDK-managed error integration replaces hand-wired zone/framework/dispatcher handlers. 6. Event payloads, breadcrumbs, tags, extras, screenshots, view hierarchy, replay, request capture, and user identity = no PII by default. 7. Current SDK behavior = installed lockfile/API + official package docs; memory/example-version copying = no proof. ## Trigger Signals = Crashlytics + FirebaseCrashlytics + Sentry + sentry_flutter + DSN + `Crash.error` + symbolication + crash reporting. ## Provider branch | Repository evidence | Action | |---|---| | Firebase Crashlytics only | Keep direct Firebase calls inside `Crash`; initialize Firebase once. | | Sentry only | Initialize with `SentryFlutter.init(..., appRunner: ...)`; `Crash.error` calls `Sentry.captureException`. | | Explicitly accepted dual reporting | Initialize Firebase before Sentry; Sentry owns `appRunner`; each manual event reaches each provider once. | | No provider + no accepted telemetry decision | Stop at provider/data/environment decision; do not install a default. | Dual reporting = requested migration/coverage only; redundancy alone does not justify two providers. ## Facade ```dart abstract final class Crash { static Future<void> init({ required FutureOr<void> Function() appRunner, }) async { // Provider branch below; an init failure still reaches appRunner. await appRunner(); } static void log(String message, {Map<String, Object?> extras = const {}}) { // Provider breadcrumb. } static void error( Object error, StackTrace stackTrace, { String? reason, bool fatal = false, Map<String, Object?> extras = const {}, }) { // Provider capture: Crashlytics recordError or Sentry.captureException. } } ``` ```dart Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Crash.init( appRunner: () => runApp(const ProviderScope(child: App())), ); } ``` - Crashlytics branch = initialize Firebase + enable collection + call `appRunner`. - Sentry branch = configure current installed `sentry_flutter` API + pass `appRunner` to `SentryFlutter.init`. - Init failure before `appRunner` = local diagnostic + run app without remote telemetry; startup must not brick. - Send failure = contained fire-and-forget diagnostic; never recurse into `Crash.error`. - Unexpected provider/transport failure = preserve original error + stack in `Crash.error` before mapping to a user-safe/domain error; never report only the sanitized replacement. - Expected cancellation/auth/input outcome = typed local state + no remote noise; classification must be explicit and tested. - One failed operation = one manual incident owner. Lower layers may return/rethrow typed context, but parent wrappers/partial-sync aggregators must not report the same cause again. - Operation identity = stable local token attached to propagation/aggregation only; never a user ID, request body, credential, or remote secret. - Do not add backend interfaces + runtime backend setters + fake/debug implementations + feature constants to the facade. ## Sentry contract - DSN = public client destination embedded in the app; one environment-aware config owner + empty/disabled test default. - `SENTRY_AUTH_TOKEN` = build-only secret; never source + app config + runtime bundle + command argument/output. - `sendDefaultPii = false`; add an event scrubber for app-owned context because this option cannot sanitize custom values. - Screenshots + view hierarchy + replay + user identity + request bodies/headers + performance/profile sampling = disabled until individually accepted. - Breadcrumb/extras keys = allowlist; values = bounded non-identity diagnostics. - Automatic framework/native capture = SDK integration only; do not stack custom global handlers around it. - Runtime issue inventory/root cause/resolve = global `sentry` skill; this reference owns Flutter wiring only. ## Release + symbols - Build revision + Sentry release/dist + deployed artifact = exact identity. - Obfuscated or split-debug-info build = retain matching debug output + upload before/with release through build-only credentials. - Upload success alone = insufficient; approved controlled event on installed artifact must resolve to expected project/environment/release and show symbolicated in-app frames. - Missing upload token/config = fail release observability gate; never silently publish an unsymbolicatable release when Sentry is required. ## Proof - DSN absent/test mode → app starts + no external envelope. - Init → app runner exactly once on provider success/failure. - `log`/`error` → no throw; selected provider receives each manual event once. - Error translation fixture → unexpected raw cause + original stack reported before safe UI error; expected outcomes remain unreported. - Propagation fixture → datasource failure + repository rethrow + notifier/partial-sync handling produces one manual incident, not one per layer. - Scrubber fixtures → identity + auth + request-body/header values removed; allowed diagnostics preserved. - Dual branch → no duplicate call within either provider. - Approved live proof → controlled event + exact release + symbolicated frame read back through `sentry`. ## Checklist - [ ] No accepted/present provider → no SDK/facade/config change; remaining items = N/A - [ ] Accepted provider(s) + environments + data categories recorded - [ ] `crash_service.dart` = only SDK owner - [ ] Public API = `init` + `error` + `log` - [ ] SDK-managed startup integration = one owner - [ ] DSN/config centralized + auth token build-only - [ ] PII and opt-in capture surfaces disabled/scrubbed - [ ] Tests prove no-DSN + init failure + once-only send + scrubber - [ ] One operation reported at most once across rethrow, retry wrapper, and aggregate/partial-sync handling - [ ] Release/symbol identity proven when production reporting is required ## Sources - [Sentry Flutter package](https://pub.dev/packages/sentry_flutter) - [Sentry Flutter options API](https://pub.dev/documentation/sentry_flutter/latest/sentry_flutter/SentryFlutterOptions-class.html) - [Sentry core options API](https://pub.dev/documentation/sentry/latest/sentry/SentryOptions-class.html) - [Sentry Dart symbol uploader](https://pub.dev/packages/sentry_dart_plugin) - [Sentry client keys](https://docs.sentry.io/api/organizations/list-an-organizations-client-keys/) -
flutter-optimizations.md 11.3 KB
# Flutter Optimizations ## Read first 1. Never use `shrinkWrap` to fix layout; constrain or use slivers. 2. Prefer `FadeTransition` over `Opacity` for animations; avoid `saveLayer()` churn. 3. Dispose every controller/ticker/subscription via `dispose()` or `ref.onDispose()`. 4. Stable keys for dynamic/reordered lists; avoid `UniqueKey` except forced recreation. 5. Move CPU-heavy parse/work off UI isolate when it can miss frame budget. ## Trigger Signals: shrinkWrap, FadeTransition, Sliver, RepaintBoundary, Impeller, Isolate.run, AnimationController ## Keys | Situation | Key Type | Example | |-----------|----------|---------| | Reorderable list | `ValueKey` | `ValueKey(item.id)` | | Heterogeneous children | `ValueKey` | Items with different types | | Multiple similar siblings | `ObjectKey` | `ObjectKey(item)` | | Force widget recreation | `UniqueKey` | `UniqueKey()` | | Access widget state from parent | `GlobalKey` | Form validation | ```dart ListView.builder( itemCount: items.length, itemBuilder: (context, index) => ProductCard( key: ValueKey(items[index].id), product: items[index], ), ) ``` Rules: - Never make key inside `build()` — defeat purpose - Key only when state preservation matter - Prefer `ValueKey` > `ObjectKey` > `UniqueKey` > `GlobalKey` ## Slivers Use `CustomScrollView`, not `ListView` in `SingleChildScrollView`. ```dart CustomScrollView( slivers: [ SliverAppBar( expandedHeight: 200, pinned: true, flexibleSpace: FlexibleSpaceBar( title: Text(l10n.productsTitle), background: Image.network( url, fit: .cover, semanticLabel: l10n.productsHeaderImageLabel, ), ), ), SliverPadding( padding: const EdgeInsets.all(Spacing.s16), sliver: SliverGrid( delegate: SliverChildBuilderDelegate( (context, index) => ProductCard(product: items[index]), childCount: items.length, ), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: Spacing.s12, crossAxisSpacing: Spacing.s12, ), ), ), ], ) ``` `SliverList` for multi scroll section. `ListView.builder` for simple standalone list. Same-height items → `SliverFixedExtentList` skip layout calc. ## Avoid `shrinkWrap: true` `shrinkWrap: true` on `ListView`/`GridView` kill lazy load. Measure all children upfront — slow big lists. | Parent Context | Fix | |----------------|-----| | Column/Row | Wrap in `Expanded` or fixed-height `SizedBox` | | Bottom sheet | `DraggableScrollableSheet` with scroll controller | | Nested scroll | `CustomScrollView` + `SliverList` (see [Slivers](#slivers)) | ```dart // WRONG — measures all children at once. ListView.builder( shrinkWrap: true, ) ``` ```dart Column( children: [ const Header(), Expanded( child: ListView.builder( itemBuilder: (context, index) => const SizedBox.shrink(), ), ), ], ) ``` ```dart DraggableScrollableSheet( builder: (context, scrollController) => ListView.builder( controller: scrollController, itemBuilder: (context, index) => const SizedBox.shrink(), ), ) ``` ## Animations ### Implicit vs Explicit ``` Does the animation repeat or need manual control? → No: Implicit (AnimatedContainer, AnimatedOpacity, AnimatedSwitcher) → Yes: Explicit (AnimationController + AnimatedBuilder) ``` ```dart AnimatedContainer( duration: const Duration(milliseconds: 120), curve: Curves.easeInOut, padding: EdgeInsets.all(isExpanded ? Spacing.s24 : Spacing.s8), decoration: BoxDecoration( color: isSelected ? colors.primaryContainer : colors.surface, borderRadius: Radii.rounded12, ), child: child, ) ``` ### AnimatedBuilder — Pass Child Pass static subtree as `child`, not `builder`. Builder run every frame: ```dart AnimatedBuilder( animation: _controller, child: const Icon(Icons.refresh, size: IconSizes.s48), // built once builder: (context, child) { return Transform.rotate( angle: _controller.value * 2 * pi, child: child, // reused every frame ); }, ) ``` ### Opacity — Avoid the Widget `Opacity` trigger `saveLayer()`. Use `FadeTransition` or semi-transparent color: ```dart // WRONG — calls saveLayer() Opacity(opacity: 0.5, child: Container(color: Colors.blue)) // RIGHT — no saveLayer Container(color: context.colors.primary.withValues(alpha: 0.5)) // RIGHT — for animated opacity FadeTransition(opacity: _animation, child: child) ``` ### AnimationController Disposal Always dispose. `SingleTickerProviderStateMixin` one controller, `TickerProviderStateMixin` many: ```dart class _MyWidgetState extends State<MyWidget> with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 120), vsync: this, ); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return FadeTransition(opacity: _controller, child: const FlutterLogo()); } } ``` ## Rendering Costs ### saveLayer() Triggers | Widget | Trigger | |--------|---------| | `Opacity` | Always | | `ShaderMask` | Always | | `ColorFilter` | Always | | `Chip` | When `disabledColorAlpha != 0xff` | | `Text` | When using `overflowShader` | ### Clipping Use `borderRadius` on `Container`, not `ClipRRect` wrap. Avoid `Clip.antiAliasWithSaveLayer` — allocate off-screen buffer. ### Intrinsic Layout Passes Avoid `IntrinsicWidth`/`IntrinsicHeight`; use fixed height or `ConstrainedBox`: ```dart // EXPENSIVE — double layout pass IntrinsicHeight(child: Row(children: [/* many children */])) // BETTER — fixed height SizedBox(height: Spacing.s64, child: Row(children: [/* children */])) ``` ## Isolates Move heavy compute off main thread. UI thread render frame <16ms (60fps) or <8ms (120fps). Use `Isolate.run` for one-shot heavy work: ```dart final products = await Isolate.run( () => switch (jsonDecode(jsonString)) { List<Object?> items => [ for (final item in items) ProductModel.fromJson(item as Map<String, dynamic>).toEntity(), ], _ => throw const FormatException('Expected product list payload'), }, ); ``` | Task | Use Isolate? | |------|-------------| | Parse <100 items | No | | Parse 1000+ items | Yes | | Image processing | Yes | | Cryptographic hashing | Yes | | Simple math / File I/O | No | Isolate no access `ref`, providers, Flutter widgets. Pass only simple or serializable objects. ## App Size ### --split-debug-info Strip debug symbols. Cut size 30–50%: ```bash flutter build apk --split-debug-info=build/debug-info --obfuscate flutter build ipa --split-debug-info=build/debug-info --obfuscate ``` Keep debug info dir for crash symbolication. ### Tree Shaking Help Dart compiler drop dead code: - Import specific files, not barrel export - Use `show` to import only needed symbols - Remove unused deps from `pubspec.yaml` ### Deferred Loading Split big features into separate download unit: ```dart import 'heavy_feature.dart' deferred as heavy; Future<void> loadFeature() async { await heavy.loadLibrary(); // Now safe to use heavy.HeavyWidget() } ``` ### Platform-Specific Assets Exclude assets from platforms not needing: ```yaml flutter: assets: - path: assets/logo.png - path: assets/web_worker.js platforms: [web] - path: assets/desktop_icon.png platforms: [windows, linux, macos] ``` ### Analyze Build Size ```bash flutter build apk --analyze-size ``` Open output JSON in DevTools > App Size tool for per-package breakdown. ## Accessibility ### Semantics ```dart Semantics( label: l10n.deleteProductSemantics(product.name), button: true, child: IconButton( tooltip: l10n.deleteProductTooltip, icon: const Icon(Icons.delete), onPressed: () => onDelete(product.id), ), ) ``` Hide decorative: `ExcludeSemantics(child: decorativeWidget)`. Tooltips, semantic labels, form labels, and visible accessibility copy come from `AppLocalizations`, never hardcoded widget strings. ### Checklist | Requirement | Target | |-------------|--------| | Tap targets | Min 48x48 logical pixels | | Contrast ratio | Min 4.5:1 (text vs background) | | Color dependence | Never rely on color alone | | Screen reader | Every interactive widget has a localized label/tooltip | | Scale factors | UI legible at 200% text scale | Test with TalkBack (Android) + VoiceOver (iOS) on real devices. ## Adaptive & Responsive ### MediaQuery.sizeOf Use `MediaQuery.sizeOf`, not `MediaQuery.of` — rebuild only on size change. With context extensions (see [extensions/context-ui.md](extensions/context-ui.md)): ```dart @override Widget build(BuildContext context) { if (context.isExpanded) { return const TabletLayout(); } return const PhoneLayout(); } ``` ### LayoutBuilder Use when sizing depend on parent constraints, not full window: ```dart LayoutBuilder( builder: (context, constraints) { final crossAxisCount = constraints.maxWidth >= Breakpoints.medium ? 3 : 2; return GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, ), itemBuilder: (context, index) => ItemCard(items[index]), itemCount: items.length, ); }, ) ``` ### Breakpoints Follow Material 3 window size classes: | Class | Width | UI | |-------|-------|-----| | Compact | < 600 | Single column, bottom nav | | Medium | 600–839 | Two columns, rail nav | | Expanded | 840+ | Multi-pane, permanent nav | Name the thresholds once as tokens; never compare widths to raw numbers: ```dart // core/theme/breakpoints.dart abstract final class Breakpoints { static const double medium = 600; static const double expanded = 840; } ``` ## Build Modes | Mode | Use | Optimizations | |------|-----|---------------| | Debug | Development | Hot reload, asserts, no tree shaking | | Profile | Performance testing | Optimized + profiling | | Release | Production | Full AOT, tree shaking, no asserts | Profile in **profile mode**, not debug. ## Impeller Flutter render engine (default iOS 3.29+, Android API 29+ in 3.27+). Pre-compile shaders at build time, kill shader compile jank. No setup. ## Frame Budget | Display | Budget | Build + Render | |---------|--------|----------------| | 60Hz | 16ms | 8ms + 8ms | | 120Hz | 8ms | 4ms + 4ms | Frame exceed budget: profile DevTools, check rebuild count, look for intrinsic pass + `saveLayer()`, move heavy work to isolate. ## RepaintBoundary Wrap only custom paint/chart/map/independent animation subtrees. Do not wrap simple widgets. ```dart RepaintBoundary(child: ComplexChart(data: chartData)) ``` ## Preserving Tab State Keep tab content alive on switch: ```dart class _ProductTabState extends State<ProductTab> with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); // required return const ProductGrid(); } } ``` ## Post-Frame Callbacks Defer work til after current frame render: ```dart @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; ref.read(welcomeProvider.notifier).maybeShowWelcomeMessage(); }); } ``` -
freezed-sealed.md 11.9 KB
# Freezed 3.x Sealed Classes ## Read first 1. `@freezed` classes use `sealed class`, never `abstract class`. 2. Add `const X._()` before methods/getters. 3. `build.yaml` owns `explicit_to_json: true`; no per-class `@JsonSerializable(explicitToJson: true)`. 4. Match unions with Dart `switch`; no `.when()`/`.map()`. 5. JSON belongs on data models only. Domain entities have no `fromJson`/`toJson`. 6. One Freezed declaration per Dart file. 7. Do not use `@immutable` or `@unfreezed`; use Freezed instead. ## Trigger Signals: Freezed, sealed class, @freezed, build.yaml, explicit_to_json, copyWith, union types ## Rules — NEVER Violate 1. **MUST** use `sealed class` with `@freezed` — NEVER `abstract class`. 2. **MUST** add `const Entity._()` private constructor when adding methods or getters. 3. **MUST** use `build.yaml` with `explicit_to_json: true` — NEVER per-class `@JsonSerializable(explicitToJson: true)`. 4. **MUST** use Dart native `switch` expressions — NEVER Freezed `when`/`map`. 5. **NEVER** use `@Freezed(toJson: true)` when `fromJson` factory exists — Freezed auto-generates `toJson`. 6. **MUST** put `fromJson`/`toJson` ONLY on data models — NEVER on domain entities. 7. **MUST** put Rich Model methods deriving from own fields on model — NEVER in repository. 8. **MUST** put each Freezed declaration in its own Dart source file — NEVER define two `@freezed`/`@Freezed` classes in one file. 9. **NEVER** use `@immutable` or `@unfreezed` for value/state classes — use `@freezed sealed class` instead. ## Setup ```yaml # pubspec.yaml — see core-stack.md for canonical versions environment: sdk: '>=3.8.0 <4.0.0' # freezed requires Dart >= 3.8 dependencies: freezed_annotation: <version> json_annotation: <version> dev_dependencies: build_runner: <version> freezed: <version> json_serializable: <version> ``` Pin `json_serializable` to the core-stack exact version. Do not raise it or switch to a caret range until a real project pub solve plus `dart analyze` proves the Riverpod/Freezed/Hive generator stack is compatible. Run `dart pub deps -s compact | rg analyzer` before changing the pin. Every Freezed source file owns exactly one Freezed declaration and needs: ```dart import 'package:freezed_annotation/freezed_annotation.dart'; part 'my_file.freezed.dart'; part 'my_file.g.dart'; // only if using fromJson/toJson ``` If a feature needs `User`, `UserState`, and `UserFilters`, create `user.dart`, `user_state.dart`, and `user_filters.dart`; do not group the three Freezed declarations in one file. ## Simple Data Classes Single constructor with `sealed class`: ```dart @freezed sealed class Product with _$Product { const factory Product({ required String id, required String name, required double price, @Default(0) int quantity, @Default(true) bool isActive, }) = _Product; factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json); } ``` Generated: `copyWith`, `toString`, `==`, `hashCode`, `toJson`. ## Adding Methods and Getters Add private constructor first: ```dart @freezed sealed class Product with _$Product { const Product._(); const factory Product({ required String id, required String name, required double price, @Default(0) int quantity, }) = _Product; factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json); double get totalValue => price * quantity; bool get inStock => quantity > 0; } ``` ## Union Types Multiple constructors create tagged unions with exhaustive matching: ```dart @freezed sealed class AuthState with _$AuthState { const factory AuthState.authenticated(User user) = Authenticated; const factory AuthState.unauthenticated() = Unauthenticated; const factory AuthState.loading() = AuthLoading; } // Exhaustive switch — compiler enforces all cases Widget build(BuildContext context, WidgetRef ref) { final auth = ref.watch(authProvider); return switch (auth) { Authenticated(:final user) => HomeScreen(user: user), Unauthenticated() => const LoginScreen(), AuthLoading() => const LoadingScreen(), }; } ``` Use Dart native `switch` expressions. Never use Freezed `when`/`map`. ## AsyncValue Pattern Matching Riverpod `AsyncValue` also sealed: ```dart final asyncData = ref.watch(myAsyncProvider); final l10n = context.l10n; return switch (asyncData) { AsyncData(:final value) => Text(value.toString()), AsyncError() => Text(l10n.loadFailed), AsyncLoading() => const ShimmerPlaceholder(), // Prefer skeleton/shimmer over bare CircularProgressIndicator }; ``` `AsyncLoading(progress: 0.5)` report loading progress. `AsyncValue.isFromCache` flag offline-persisted data. ## Feature State Combine state class with union for multi-state features: ```dart @freezed sealed class ProductState with _$ProductState { const factory ProductState({ @Default([]) List<Product> items, @Default(false) bool isLoading, @Default(false) bool isLoadingMore, @Default(false) bool hasMore, @Default(0) int page, AppError? error, @Default('') String searchQuery, }) = _ProductState; } ``` Use `copyWith` to update fields: ```dart state = state.copyWith(isLoading: true); state = state.copyWith(items: newItems, isLoading: false); state = state.copyWith(error: null); // clear error ``` ## Deep Copy When Freezed classes nest other Freezed classes, use deep copy. One Freezed class per file: ```dart // features/org/domain/entities/company.dart @freezed sealed class Company with _$Company { const factory Company({ String? name, required Director director, }) = _Company; } ``` ```dart // features/org/domain/entities/director.dart @freezed sealed class Director with _$Director { const factory Director({ String? name, Assistant? assistant, }) = _Director; } ``` ```dart // features/org/domain/entities/assistant.dart @freezed sealed class Assistant with _$Assistant { const factory Assistant({String? name}) = _Assistant; } ``` ```dart // Deep copy syntax Company renameDirector(Company company) => company.copyWith.director(name: 'Jane Doe'); // Null-safe deep copy (nullable nested field) Company? renameAssistant(Company company) => company.copyWith.director.assistant?.call(name: 'John'); ``` ## JSON Serialization ### Single Constructor ```dart @freezed sealed class UserModel with _$UserModel { const factory UserModel({ required String id, @JsonKey(name: 'full_name') required String fullName, @JsonKey(name: 'created_at') required DateTime createdAt, }) = _UserModel; factory UserModel.fromJson(Map<String, dynamic> json) => _$UserModelFromJson(json); const UserModel._(); User toEntity() => User( id: UserId(id), fullName: DisplayName(fullName), createdAt: createdAt, ); } ``` ### Union Type Serialization Uses `runtimeType` discriminator by default: ```dart @freezed sealed class ApiResponse with _$ApiResponse { const factory ApiResponse.success(Object? data) = ApiSuccess; const factory ApiResponse.error(String message) = ApiError; factory ApiResponse.fromJson(Map<String, dynamic> json) => _$ApiResponseFromJson(json); } ``` Customize discriminator key: ```dart @Freezed(unionKey: 'type', unionValueCase: .pascal) sealed class ApiResponse with _$ApiResponse { const factory ApiResponse.success(Object? data) = ApiSuccess; @FreezedUnionValue('ServerError') const factory ApiResponse.error(String message) = ApiError; factory ApiResponse.fromJson(Map<String, dynamic> json) => _$ApiResponseFromJson(json); } ``` ### Generic Serialization ```dart @Freezed(genericArgumentFactories: true) sealed class Paginated<T> with _$Paginated<T> { const factory Paginated({ required List<T> items, required int total, required int page, }) = _Paginated; factory Paginated.fromJson( Map<String, dynamic> json, T Function(Object?) fromJsonT, ) => _$PaginatedFromJson(json, fromJsonT); } ``` ## Non-Constant Default Values Use private constructor. Take the clock from `DateTimeX.nowUtc()` ([primitive-formatting.md](extensions/primitive-formatting.md#datetime)), never raw `DateTime.now()`: ```dart @freezed sealed class Event with _$Event { Event._({DateTime? createdAt}) : createdAt = createdAt ?? DateTimeX.nowUtc(); factory Event({required String title, DateTime? createdAt}) = _Event; final DateTime createdAt; } ``` ## Inheritance ```dart class BaseEntity { const BaseEntity(this.id); final String id; } @freezed sealed class Product extends BaseEntity with _$Product { const Product._(super.id) : super(); const factory Product(String id, String name) = _Product; } ``` ## Manual Immutable Classes Do not use `@immutable` or `@unfreezed` for app value/state classes. `@immutable` only checks fields, and `@unfreezed` creates a second mutability model. Freezed is the single project pattern for value/state classes because it owns equality, `copyWith`, unions, serialization, and generated implementation boundaries. Wrong: ```dart @immutable sealed class FormData with _$FormData { const FormData({ required String name, required String email, }); } ``` Right: ```dart @freezed sealed class FormData with _$FormData { const factory FormData({ required String name, required String email, }) = _FormData; } ``` ## Configuration Per-class: ```dart @Freezed(copyWith: false, equal: false, toStringOverride: false) sealed class Minimal with _$Minimal { const factory Minimal(int value) = _Minimal; } ``` Project-wide via `build.yaml`: ```yaml targets: $default: builders: freezed: options: format: false # faster builds copy_with: true equal: true union_key: type union_value_case: pascal ``` ## Linting Use the canonical analyzer config from [analysis_options.yaml](analysis_options.yaml). Annotation diagnostics stay enabled, so a generator mismatch is fixed through the verified package matrix rather than hidden by an analyzer ignore. ## Rich Models **Rule: If method reads only own fields, MUST belong on model.** MUST add `const Entity._()` to enable getters and methods on Freezed classes: ```dart @freezed sealed class Order with _$Order { const Order._(); const factory Order({ required OrderId id, required List<OrderItem> items, required DateTime createdAt, }) = _Order; double get total => items.fold(0, (sum, i) => sum + i.price * i.quantity); int get itemCount => items.length; OrderSummary toSummary() => OrderSummary(id: id, itemCount: itemCount, total: total); } ``` | Belongs on model | Goes elsewhere | |-----------------|---------------| | Computed getters (`total`, `isExpired`) | Needs external deps (API, DB) → Repository | | Boolean checks (`inStock`, `isOverdue`) | Reads multiple unrelated models → Repository | | Collection flattening (`allResults`) | Needs Ref/providers → Notifier | | Transform to another type (`toClaims()`) | Side effects (HTTP, I/O) → Datasource | | Format for API (`toFormFields()`) | | Data models follow same rule: `toEntity()`, `toRequestBody()`, `toCsvRow()` all belong on model. ## Deep Serialization Enable nested JSON once in `build.yaml`. Generated `toJson()` must call nested `.toJson()` via `explicit_to_json: true`. `@Freezed(toJson: true)` is redundant when `fromJson` exists. ### build.yaml Set `explicit_to_json: true`: ```yaml # build.yaml (project root) targets: $default: builders: json_serializable: options: explicit_to_json: true ``` ### Quick Reference | Setting | What it does | When needed | |---|---|---| | `@freezed` + `fromJson` factory | Generates both `fromJson` and `toJson` | Always — standard Freezed pattern | | `build.yaml explicit_to_json` | Calls `.toJson()` on nested objects | Always — set once, forget | | `@Freezed(toJson: true)` | Forces `toJson` generation | Only if class has NO `fromJson` factory (rare) | | `@JsonSerializable(explicitToJson: true)` | Per-class deep serialization | NEVER — use build.yaml | -
hive-persistence.md 15.7 KB
# Hive CE Persistence ## Read first 1. TypeIds + HiveField indexes are permanent after release. Never reuse/reorder; append/retire only. 2. `hive_adapters.g.yaml` is disk-format SSOT. Commit it; edit manually on rename. 3. Changing field type is unsupported. Retire old field, add new field. 4. Domain is Hive-free. Hive models stay in data layer with primitives; mapper bridges VOs/entities. 5. Never change ctor param order/types for shipped `@GenerateAdapters` models. 6. Storage calls stay in local datasources behind repositories. Production Flutter code imports Hive through `hive_ce_flutter`. 7. Persisted maps are checked for map shape and string keys, copied into a typed map, and rejected safely when corrupt. JSON decoding stays separate. ## Trigger Signals: hive_ce, hive_ce_flutter, TypeAdapter, @GenerateAdapters, IsolatedHive, HiveField ## Core Stack `hive_ce`, `hive_ce_flutter`, `hive_ce_generator`. Constraints: see [core-stack.md](core-stack.md). Flutter app source imports Hive through `hive_ce_flutter`, not `hive_ce` directly. `hive_ce_flutter` re-exports the core Hive API and adds Flutter integration helpers; using it keeps the Flutter package surface visible even when code uses core types such as `Box`, `Hive`, `AdapterSpec`, or `GenerateAdapters`. ## Setup ```yaml # pubspec.yaml — see core-stack.md for canonical versions dependencies: hive_ce: <version> hive_ce_flutter: <version> dev_dependencies: build_runner: <version> hive_ce_generator: <version> ``` ## Validate persisted maps Hive values are runtime data. Never cast a stored value directly to `Map<String, dynamic>`. A corrupt box, an old adapter, or a non-string key must be rejected at the datasource boundary. ```dart Map<String, dynamic> normalizePersistedMap(Object? raw) { if (raw is! Map) { throw const FormatException('Expected a persisted map'); } final normalized = <String, dynamic>{}; for (final entry in raw.entries) { final key = entry.key; if (key is! String) { throw const FormatException('Expected persisted map keys to be strings'); } normalized[key] = entry.value; } return normalized; } ``` Keep JSON decoding separate. A JSON decoder owns its JSON boundary and its schema checks. Do not reuse a persistence normalizer to make an unchecked JSON cast look safe. When normalization fails, catch the typed format error in the local datasource, record a safe diagnostic without user data, and return the datasource's defined empty or recovery state. Remove only the corrupt cache entry or box after the failure is understood. Preserve migration markers and unrelated boxes. Do not purge all local data as a generic startup fix. The regression test must use a real temporary Hive directory and the real box lifecycle: ```dart test('restores valid nested data after close and reopen', () async { final directory = await HiveTestHelper.initialize('persisted_map'); try { final box = await Hive.openBox<Object?>('settings'); await box.put('settings', <Object?, Object?>{ 'profile': <Object?, Object?>{'theme': 'dark'}, }); await Hive.close(); Hive.init(directory.path); final reopened = await Hive.openBox<Object?>('settings'); final value = normalizePersistedMap(reopened.get('settings')); expect(value['profile'], <Object?, Object?>{'theme': 'dark'}); expect(() => normalizePersistedMap(<Object?, Object?>{1: 'bad'}), throwsFormatException); } finally { await HiveTestHelper.cleanup(directory); } }); ``` The test proves write, close, reopen, nested-value restoration, malformed-key rejection, and cleanup. Add a separate corrupt-box recovery test when startup has a repair path. ## @GenerateAdapters Pattern Gen TypeAdapters for Freezed classes sans @HiveType. ### Step 1: Create Adapter Specification ```dart // lib/core/hive/hive_adapters.dart import 'package:hive_ce_flutter/hive_ce_flutter.dart'; import 'package:my_app/features/user/data/models/user_model.dart'; import 'package:my_app/features/order/data/models/order_model.dart'; part 'hive_adapters.g.dart'; /// TypeId allocation: /// 0 - CacheEntry (reserved for @HiveType) /// 1 - UserModel /// 2 - OrderModel /// 3 - OrderItemModel @GenerateAdapters([ AdapterSpec<UserModel>(), AdapterSpec<OrderModel>(), AdapterSpec<OrderItemModel>(), ], firstTypeId: 1, reservedTypeIds: {0}) void _hiveAdapters() {} ``` `AdapterSpec<T>()` always names a persistence-layer `Model` from `/data/models/`, never a `/domain/entities/` class. Domain entities stay Hive-free. The mapper bridges (see [Repository Pattern](#repository-pattern) and [VO Interop](#vo-interop)). ### Step 2: Generate Adapters ```bash dart run build_runner build ``` Generates: - `hive_adapters.g.dart` — TypeAdapter implementations - `hive_registrar.g.dart` — Extension method for registration ### Step 3: Register Adapters ```dart import 'package:hive_ce_flutter/hive_ce_flutter.dart'; import 'package:my_app/core/hive/hive_registrar.g.dart'; Future<void> initializeStorage() async { await Hive.initFlutter('my_app'); Hive.registerAdapters(); // One call registers all adapters } ``` ## TypeId Management TypeIds unique + stable. Change TypeId = break existing data. ``` // Allocation strategy: Reserve ranges per feature // 0-9: Core (AppState, Settings, Cache) // 10-19: User feature // 20-29: Orders feature ``` ## Mixing @HiveType and @GenerateAdapters @HiveType for non-Freezed. @GenerateAdapters for Freezed. ```dart // Non-Freezed class with @HiveType @HiveType(typeId: 0) class CacheEntry { @HiveField(0) final String key; @HiveField(1) final String value; CacheEntry({required this.key, required this.value}); } // Freezed classes use @GenerateAdapters @GenerateAdapters([ AdapterSpec<User>(), // typeId: 1 ], firstTypeId: 1, reservedTypeIds: {0}) ``` ## IsolatedHive (background-isolate) Hive CE 2.19+ ships `IsolatedHive` — box on background isolate, no UI block on big I/O. Use only when profiling shows main-isolate jank from Hive on a hot path. Standard `Hive` fine for typical key/value. ```dart final box = await IsolatedHive.openBox<OrderModel>('orders'); await box.put(order.id, .fromDomain(order)); final all = await box.values; // async — crosses isolate boundary ``` Caveats: - TypeAdapter register on isolate. Registrar same; call from spawn callback per package docs. - All reads/writes async — no sync `get`. Update repo signatures. - `box.watch()` works, events on port — debounce before rebuild. ## Repository Pattern Hive = persistence detail. Canonical chain: `HiveOrderDatasource` → `HiveOrderRepository implements IOrderRepository` → `OrderNotifier`. Domain `Order` Hive-free. Persistence-only `OrderModel` carries `@HiveField`. Provider returns iface — tests override w/ fake. ```dart // features/orders/domain/entities/order.dart — pure domain, no Hive imports @freezed sealed class Order with _$Order { const factory Order({ required OrderId id, required List<OrderItem> items, required OrderStatus status, }) = _Order; } ``` ```dart // features/orders/data/models/order_model.dart — Hive persistence model @GenerateAdapters([ AdapterSpec<OrderModel>(), AdapterSpec<OrderItemModel>(), ], firstTypeId: 20) @freezed sealed class OrderModel with _$OrderModel { const OrderModel._(); const factory OrderModel({ required String id, required List<OrderItemModel> items, required String status, // OrderStatus.name — domain enum stays Hive-free }) = _OrderModel; factory OrderModel.fromDomain(Order o) => OrderModel( id: o.id.value, items: o.items.map(OrderItemModel.fromDomain).toList(), status: o.status.name, ); Order toDomain() => Order( id: OrderId(id), items: items.map((m) => m.toDomain()).toList(), status: .values.byName(status), ); } ``` ```dart // features/orders/data/datasources/hive_order_datasource.dart abstract interface class IOrderLocalDatasource { Future<void> save(OrderModel model); OrderModel? get(String id); List<OrderModel> getAll(); Future<void> delete(String id); } class HiveOrderDatasource implements IOrderLocalDatasource { HiveOrderDatasource(this._box); final Box<OrderModel> _box; @override Future<void> save(OrderModel model) => _box.put(model.id, model); @override OrderModel? get(String id) => _box.get(id); @override List<OrderModel> getAll() => _box.values.toList(); @override Future<void> delete(String id) => _box.delete(id); } @Riverpod(keepAlive: true) Future<IOrderLocalDatasource> orderLocalDatasource(Ref ref) async { final box = await Hive.openBox<OrderModel>('orders'); ref.onDispose(box.close); return HiveOrderDatasource(box); } ``` ```dart // features/orders/domain/repositories/i_order_repository.dart abstract interface class IOrderRepository { Future<void> save(Order order); Order? get(String id); List<Order> getAll(); Future<void> delete(String id); } ``` ```dart // features/orders/data/repositories/hive_order_repository.dart class HiveOrderRepository implements IOrderRepository { HiveOrderRepository(this._datasource); final IOrderLocalDatasource _datasource; @override Future<void> save(Order order) => _datasource.save(.fromDomain(order)); @override Order? get(String id) => _datasource.get(id)?.toDomain(); @override List<Order> getAll() => _datasource.getAll().map((m) => m.toDomain()).toList(); @override Future<void> delete(String id) => _datasource.delete(id); } @Riverpod(keepAlive: true) Future<IOrderRepository> orderRepository(Ref ref) async { final datasource = await ref.read(orderLocalDatasourceProvider.future); return HiveOrderRepository(datasource); } ``` Notifier consumes `IOrderRepository` only — never touches Hive. Tests override `orderRepositoryProvider` w/ `MockIOrderRepository`, no Hive init. See [architecture.md](architecture.md) for layer chain, [testing.md](testing.md) for override pattern. ## Testing with TypeAdapters ```dart // test/shared/hive_test_helper.dart abstract final class HiveTestHelper { static Future<Directory> initialize(String testName) async { final tempDir = Directory('${Directory.current.path}/test_hive_$testName'); if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); tempDir.createSync(); Hive.init(tempDir.path); _registerAdapters(); return tempDir; } static Future<void> cleanup(Directory tempDir) async { await Hive.close(); if (tempDir.existsSync()) tempDir.deleteSync(recursive: true); } } /// Idempotent adapter registration. void _registerAdapters() { if (!Hive.isAdapterRegistered(0)) { Hive.registerAdapter(CacheEntryAdapter()); } if (!Hive.isAdapterRegistered(1)) { Hive.registerAdapter(UserAdapter()); } } ``` ## Storage Location ```dart import 'package:hive_ce_flutter/hive_ce_flutter.dart'; // Standard Flutter setup: Documents directory + optional subdirectory. await Hive.initFlutter('my_app'); // Custom path setup: keep this explicit when preserving an existing data path, // e.g. Application Support. Still import through hive_ce_flutter. final path = (await getApplicationSupportDirectory()).path; Hive.init(path); ``` ## VO Interop `hive_adapters.g.yaml` = disk-format SSOT. **Commit it**. After release, never delete it. Ctor param order/type is append-only. Field rename requires manual `hive_adapters.g.yaml` edit. New non-nullable fields need defaults. Field type change is unsupported; add a new field. **Rule:** `/data/models/` = primitives. `/domain/entities/` = VOs. Mapper bridges. ```dart // /data/models/workout_set_model.dart @freezed sealed class WorkoutSetModel with _$WorkoutSetModel { const factory WorkoutSetModel({ /// HiveField(0) required String id, /// HiveField(1) required double distanceMeters, /// HiveField(2) required int durationSeconds, }) = _WorkoutSetModel; } // /core/hive/hive_adapters.dart → AdapterSpec<WorkoutSetModel>() // /domain/entities/workout_set.dart @freezed sealed class WorkoutSet with _$WorkoutSet { const factory WorkoutSet({required WorkoutSetId id, required Distance distance, required Duration duration}) = _WorkoutSet; } // /data/mappers/workout_set_mapper.dart extension WorkoutSetMapper on WorkoutSetModel { WorkoutSet toEntity() => WorkoutSet(id: WorkoutSetId(id), distance: .fromMeters(distanceMeters), duration: Duration(seconds: durationSeconds)); } extension WorkoutSetToModel on WorkoutSet { WorkoutSetModel toModel() => WorkoutSetModel(id: id.value, distanceMeters: distance.inMeters, durationSeconds: duration.inSeconds); } ``` **Shipped domain class:** keep primitive ctor slots; expose VOs via getters. Do not change disk shape. **Forbidden:** - Reorder ctor params on `@GenerateAdapters` class (silent slot shift). - Change param type at existing position. - Renumber/reuse `HiveField(N)`. - Reuse retired `typeId`. Constructor signature = append-only schema. **Lint (ERROR):** - `use_hive_ce_flutter_import` — production Flutter `lib/` files import Hive through `package:hive_ce_flutter/hive_ce_flutter.dart`, not `package:hive_ce/hive_ce.dart`. Test helpers may use manual temp-dir setup. - `hive_field_no_vo_type` — `/data/models/` `@freezed` ctor: no VO types. Hard-coded set: `Distance`/`Money`/`Email`/`Slug`/`PhoneNumber`/`HeartRate`/`Weight`/`Pace`/`Username`. Auto-extends w/ types imported from `*/domain/values/<name>.dart` (PascalCase filename heuristic) + `show` clause names. ## Retiring entities Delete class = retire typeId. Never reuse for successor. Add retired id to `reservedTypeIds`. New class gets fresh id. ```dart // WRONG — Program deleted, Routine reused typeId 10 // Old user data written as Program at id 10 → new RoutineAdapter reads it // → cryptic type-cast crash on boot // RIGHT @GenerateAdapters([ AdapterSpec<Routine>(), // new id 12 (next free) AdapterSpec<RoutineDay>(), // new id 13 ], firstTypeId: 1, reservedTypeIds: {0, 9, 10, 11}) // 9/10/11 retired ``` Field retirement same rule: remove field from class + keep index in `nextIndex` accounting, never reassign. ## Failure signatures | Error | Fix | |-------|-----| | `type 'String' is not a subtype of type 'List<dynamic>'` | Check field index/typeId reuse | | `HiveError: Cannot read, unknown typeId: N` | Register/retire adapter id correctly | | `RangeError: value not in range` on enum | Do not reorder/remove encoded enum cases | Upgrade-only failure = binary incompat. Check typeId / HiveField changes. ## Evolution cheat sheet | Change | Safe? | How | |--------|-------|-----| | Add new field | ✅ | New ctor param at end; nullable OR default value (required for non-nullable) | | Remove field | ✅ | Delete ctor param; retired index recorded in `hive_adapters.g.yaml` | | Rename class | ⚠️ | Manually edit `hive_adapters.g.yaml` | | Rename field | ⚠️ | Manually edit field key in `hive_adapters.g.yaml` (per official docs) | | Change field type | ❌ | Per official docs: not supported. Retire old field, add new with new type | | Reorder ctor params | ❌ | Append only | | Delete class | ✅ | Retire typeId into `reservedTypeIds` | | Replace class (rename + restructure) | ❌ (if typeId reused) | New typeId, retire old | | Reorder enum cases | ❌ | Enum encoded by index — retire adapter, new one | ## File Structure ``` lib/core/hive/ ├── hive_adapters.dart # @GenerateAdapters annotation ├── hive_adapters.g.dart # Generated adapters └── hive_registrar.g.dart # Generated registrar test/shared/ └── hive_test_helper.dart ``` ## Adding New Entities 1. Create Freezed entity 2. Add `AdapterSpec<Entity>()` to @GenerateAdapters list 3. Run `dart run build_runner build` 4. Update test helper if needed ## References - [Hive CE Documentation](https://docs.hivedb.dev/) - [hive_ce on pub.dev](https://pub.dev/packages/hive_ce) - [hive_ce_flutter on pub.dev](https://pub.dev/packages/hive_ce_flutter) -
layout-diagnostics.md 5.3 KB
# Layout Diagnostics ## Read first 1. Fix the first layout exception; later errors are usually cascades. 2. Constraints go down, sizes go up, parent sets position. 3. Never use `shrinkWrap: true` to silence unbounded height; add constraints or slivers. 4. `Expanded`/`Flexible` only under `Row`/`Column`/`Flex`; `Positioned` only under `Stack`. 5. Adapt via `LayoutBuilder`/`MediaQuery.sizeOf`, not device type/orientation. ## Trigger Signals: layout exception, unbounded height, viewport, LayoutBuilder, Expanded, Flexible ## Core Rule Flutter layout is constraints-first: constraints go down, sizes go up, parent sets position. ## Error Map | Error | Check | Fix | |---|---|---| | `Vertical viewport was given unbounded height` | `ListView`/`GridView` inside unconstrained `Column` | Use `Expanded`, `Flexible`, fixed constraints, or slivers | | `InputDecorator cannot have an unbounded width` | `TextField` inside unconstrained `Row` | Wrap field in `Expanded` or `Flexible` | | `RenderFlex overflowed` | Row/Column child wider/taller than available space | Constrain the child, allow wrapping, or change layout at breakpoint | | `Incorrect use of ParentDataWidget` | `Expanded`, `Flexible`, or `Positioned` under wrong parent | Move it directly under `Row`/`Column`/`Flex` or `Stack` | | `RenderBox was not laid out` | Earlier constraint error | Fix first layout error in logs | ## List in Column ```dart class ProductListPanel extends StatelessWidget { const ProductListPanel({super.key, required this.products}); final List<Product> products; @override Widget build(BuildContext context) { return Column( children: [ const ProductListHeader(), Expanded( child: ListView.builder( itemCount: products.length, itemBuilder: (context, index) { return ProductTile(product: products[index]); }, ), ), ], ); } } ``` Do not reach for `shrinkWrap` to silence the error. It changes scroll performance and usually hides the wrong parent constraint. ## Text Field in Row ```dart class ProductSearchBar extends StatelessWidget { const ProductSearchBar({super.key, required this.controller}); final TextEditingController controller; @override Widget build(BuildContext context) { return Row( children: [ const Icon(Icons.search), const SizedBox(width: Spacing.s8), Expanded( child: TextField( controller: controller, ), ), ], ); } } ``` ## Long Text in Row ```dart class ProductStatusRow extends StatelessWidget { const ProductStatusRow({super.key, required this.message}); final String message; @override Widget build(BuildContext context) { return Row( children: [ const Icon(Icons.info), const SizedBox(width: Spacing.s8), Expanded( child: Text(message), ), ], ); } } ``` ## ParentDataWidget Check `Expanded` and `Flexible` must be direct children of `Row`, `Column`, or `Flex`. `Positioned` must be a direct child of `Stack`. ```dart class CorrectExpandedPlacement extends StatelessWidget { const CorrectExpandedPlacement({super.key}); @override Widget build(BuildContext context) { return Column( children: const [ HeaderBar(), Expanded(child: ProductListView()), ], ); } } ``` ## Adaptive Decisions Base layout on available space, not hardware class or orientation. ```dart class ProductHomeLayout extends StatelessWidget { const ProductHomeLayout({super.key}); @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth >= Breakpoints.expanded) { return const ProductExpandedLayout(); } if (constraints.maxWidth >= Breakpoints.medium) { return const ProductMediumLayout(); } return const ProductCompactLayout(); }, ); } } ``` Material 3 width classes: | Class | Width | Common pattern | |---|---:|---| | Compact | `< 600` | Single column, bottom navigation | | Medium | `600-839` | Two columns, navigation rail | | Expanded | `>= 840` | Multi-pane, permanent navigation | ## Keyboard and Insets - Use `MediaQuery.viewInsetsOf(context)` for keyboard insets. - Prefer scrollable form bodies over clipping fixed-height forms. - Keep submit actions reachable with keyboard open. - Do not globally clamp text scale to fix overflow. Fix the local layout. ## Debug Workflow 1. Read the first layout exception in logs. Ignore cascading errors below it. 2. Identify the nearest unbounded axis: height for vertical scrollables, width for rows/text fields. 3. Add the smallest correct constraint. 4. Resize the app window across compact, medium, and expanded widths. 5. Test large text scale and keyboard-open state. 6. Run widget tests for the changed layout branch when practical. ## Checklist - [ ] First layout error was fixed. - [ ] Scrollables inside columns have real constraints. - [ ] Text fields and long text inside rows are constrained. - [ ] ParentDataWidgets are direct children of the required parent. - [ ] Layout decisions use `LayoutBuilder` or `MediaQuery.sizeOf`, not device type. - [ ] Compact, medium, expanded, keyboard-open, and large text-scale states were checked. -
localization.md 5.6 KB
# Localization ## Read first 1. All user-visible production copy uses gen-l10n ARB, never ad hoc UI strings. 2. Add keys to template ARB first; include descriptions, placeholders, plural/select where needed. 3. Widgets bind `final l10n = context.l10n;`; no `AppLocalizations.of(context)!` or chained `context.l10n.key`. 4. Domain/repos/datasources/notifiers expose semantic state, not localized copy. 5. Configure ARB/generated paths explicitly; import generated `app_localizations.dart` from that path. ## Trigger Signals: gen-l10n, ARB, AppLocalizations, plural, select, l10n.yaml ## Rules 1. **MUST** use Flutter gen-l10n for user-visible copy in production UI. 2. **MUST** add every user-facing key to the template ARB first, then update supported locales. 3. **MUST** include descriptions for ARB keys that are not obvious. 4. **MUST** use placeholders for runtime values. Do not concatenate localized strings. 5. **MUST** use plural/select syntax for counts, gender, roles, or status choices. 6. **MUST NOT** use `AppLocalizations.of(context)!`. Prefer `nullable-getter: false`; if an existing project uses nullable getters, handle null once in a context extension. 7. **MUST NOT** store localized copy in domain entities, repositories, datasources, or notifiers. Store semantic state there; render copy in UI. 8. **Configure gen-l10n paths explicitly.** Put ARB files in `arb-dir` (`lib/l10n` by default). Generated Dart is written to `${arb-dir}/${output-localization-file}` unless `output-dir` is set, then it is written to `${output-dir}/${output-localization-file}`. ## Setup Add dependencies: ```bash flutter pub add flutter_localizations --sdk=flutter flutter pub add intl:any ``` Enable generation: ```yaml flutter: generate: true ``` Create `l10n.yaml`: ```yaml arb-dir: lib/l10n template-arb-file: app_en.arb output-localization-file: app_localizations.dart nullable-getter: false use-escaping: true ``` With the config above, generated output is `lib/l10n/app_localizations.dart`; import: ```dart import 'package:my_app/l10n/app_localizations.dart'; ``` ## App Wiring ```dart import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:my_app/l10n/app_localizations.dart'; class AppRoot extends StatelessWidget { const AppRoot({super.key}); @override Widget build(BuildContext context) { return MaterialApp.router( routerConfig: appRouter, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, ); } } ``` Add a single extension: ```dart import 'package:flutter/widgets.dart'; import 'package:my_app/l10n/app_localizations.dart'; extension LocalizationContext on BuildContext { AppLocalizations get l10n => .of(this); } ``` Use it in widgets by binding localizations once at the top of `build`: ```dart class ProductEmptyState extends StatelessWidget { const ProductEmptyState({super.key}); @override Widget build(BuildContext context) { final l10n = context.l10n; return Text(l10n.productsEmptyTitle); } } ``` ## ARB Patterns Base template: ```json { "productsEmptyTitle": "No products yet", "@productsEmptyTitle": { "description": "Empty-state title on the product list screen" } } ``` Placeholder: ```json { "welcomeUser": "Welcome, {name}", "@welcomeUser": { "description": "Greeting shown after sign in", "placeholders": { "name": { "type": "String", "example": "Amira" } } } } ``` Plural: ```json { "cartItemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}", "@cartItemCount": { "description": "Number of items in the cart", "placeholders": { "count": { "type": "num", "format": "compact" } } } } ``` Select: ```json { "inviteStatus": "{status, select, pending{Pending} accepted{Accepted} declined{Declined} other{Unknown}}", "@inviteStatus": { "description": "Invite status label", "placeholders": { "status": { "type": "String" } } } } ``` ## Notifier Boundary Notifiers should expose semantic state, not translated copy. ```dart @freezed sealed class InviteState with _$InviteState { const factory InviteState({ required InviteStatus status, }) = _InviteState; } class InviteStatusLabel extends StatelessWidget { const InviteStatusLabel({super.key, required this.status}); final InviteStatus status; @override Widget build(BuildContext context) { final l10n = context.l10n; return switch (status) { .pending => Text(l10n.invitePending), .accepted => Text(l10n.inviteAccepted), .declined => Text(l10n.inviteDeclined), }; } } ``` ## Testing - Widget tests should wrap widgets in the app localization delegates. - Snapshot/preview data should include longest expected localized copy. - Router/deep-link tests should not assert localized text unless the route is locale-specific. ## Checklist - [ ] `l10n.yaml` exists and gen-l10n is enabled. - [ ] Generated l10n files are in `arb-dir` or `output-dir`, and imports use that path. - [ ] `AppLocalizations` is wired at `MaterialApp`/`CupertinoApp`. - [ ] Widgets bind `final l10n = context.l10n;` before localized key reads. - [ ] User-facing strings are in ARB files. - [ ] Placeholders/plurals/selects are used instead of string concatenation. - [ ] Notifiers expose semantic state, not translated copy. - [ ] Widget tests/previews include localization delegates or app preview shell. -
mixins.md 6.5 KB
# Mixin vs Interface vs Extension ## Read first 1. Same behavior in 2+ classes → `mixin XxxMixin on Y`; no copy-paste sharing. 2. Interfaces define contracts; mixins add capabilities; extensions add methods to external types. 3. Keep mixins small, single-purpose, suffixed `Mixin`. 4. Use `on` when mixin needs `super`/host API. 5. No mutable state fields in mixins. ## Trigger Signals: mixin, ConnectivityMixin, RetryMixin, on ConsumerState, abstract interface class ## Rules — NEVER Violate 0. **MUST extract shared behavior to a mixin the moment it appears in 2+ classes.** When the same code shows up in two notifiers, two widgets, or two services, stop copy-pasting and write a mixin. `mixin XxxMixin on Y` with the right `on` constraint. Suffix `Mixin`. Copy-paste sharing across notifiers / widgets / services is forbidden. 1. **MUST** use `mixin` for reusable behavior across unrelated classes. NEVER inherit to share behavior without "is-a". 2. **MUST** use `abstract interface class` for contracts (what class must do). MUST use `mixin` for capabilities (what class can do). 3. **MUST** keep mixins small, focused — one capability per mixin (SRP). 4. **MUST** suffix mixin names with `Mixin` (e.g., `LoggingMixin`, `ConnectivityMixin`). 5. **MUST** use `on` clause when mixin needs `super` access or must restrict users (e.g., `mixin RouteAwareMixin on State`). 6. **MUST NEVER** put mutable state fields in mixins — hidden side effects across unrelated classes. Pass state via ctor or method args. 7. **MUST NEVER** use `mixin class` unless type needs both direct instantiation AND mixing in. Prefer pure `mixin`. 8. **MUST** use `extension` for adding methods to types you don't own (e.g., `String`, `BuildContext`). NEVER mixin for this. ## Quick Reference | Tool | Keyword | Purpose | Multiple? | Constructors? | |------|---------|---------|-----------|---------------| | Mixin | `mixin` | Add capabilities ("can do") | Yes — `with A, B, C` | No | | Interface | `abstract interface class` | Define contract ("must do") | Yes — `implements A, B` | Yes | | Extension | `extension on Type` | Add methods to existing types | N/A | N/A | | Abstract class | `abstract class` | Base impl ("is-a") | No — single `extends` | Yes | | Mixin class | `mixin class` | Both class and mixin (rare) | One `with`, one `extends` | Limited | ## Common Flutter Mixins | Mixin | `on` Constraint | Use Case | |-------|----------------|----------| | `SingleTickerProviderStateMixin` | `State` | One `AnimationController` — provides `vsync` | | `TickerProviderStateMixin` | `State` | Multiple `AnimationController`s | | `AutomaticKeepAliveClientMixin` | `State` | Keep tab/page alive in `PageView`/`TabBarView` | | `WidgetsBindingObserver` | — | App lifecycle events (`didChangeAppLifecycleState`) | ## Custom Mixin Example ```dart // core/mixins/connectivity_mixin.dart /// Adds connectivity check capability to any notifier. /// Keeps the mixin stateless — calls an injected service. mixin ConnectivityMixin { bool checkConnectivity(ConnectivityService service) { return service.isConnected; } } // Usage in a notifier class ProductNotifier extends _$ProductNotifier with ConnectivityMixin { @override ProductState build() { unawaited(.microtask(_load)); // Defer — see notifier-structure.md "Sync notifier init trap" return const ProductState(); } Future<void> _load() async { if (!ref.mounted) return; final connected = checkConnectivity( ref.read(connectivityServiceProvider), ); if (!connected) { state = state.copyWith(error: 'No connection'); return; } // ...fetch from remote } } ``` ## Mixin with `on` Clause (Restricted) ```dart // core/mixins/route_aware_mixin.dart /// Restricts this mixin to State subclasses only. mixin RouteAwareMixin on State { void didPushRoute() { // `on State` exposes context/widget/lifecycle. } void disposeRouteAware() { /* cleanup */ } } ``` ## Retry-With-Backoff Helper (Data-Layer Mixin) Bulk-I/O mixin (e.g. `AppwritePaginationMixin.saveAllRows`) → pair w/ **module-level** `retryWithBackoff<T>()` + typed exception. Free-standing, not mixin method — non-mixin sites reuse. ```dart // core/services/appwrite_pagination_mixin.dart import 'dart:io'; import 'dart:math' as math; final _retryRng = math.Random(); // hoisted — no per-call alloc class SaveRowResult<T> { const SaveRowResult.ok(this.item) : error = null, stackTrace = null; const SaveRowResult.err(this.item, this.error, this.stackTrace); final T item; final Object? error; final StackTrace? stackTrace; bool get isOk => error == null; } class SaveAllRowsException implements Exception { const SaveAllRowsException(this.failures); final List<SaveRowResult<Object?>> failures; @override String toString() => switch (failures) { [SaveRowResult(:final error?), ...] => 'SaveAllRowsException: ${failures.length} item(s) failed; first=$error', _ => 'SaveAllRowsException: ${failures.length} item(s) failed', }; } /// Retries [fn] on transient failures: Appwrite 429/503, [SocketException], /// [HttpException]. Non-retryable errors rethrow immediately. /// Base 200ms, doubled each retry, ±50ms jitter. Default 3 attempts. bool _defaultShouldRetry(Object e) { if (e is SocketException || e is HttpException) return true; if (e is AppwriteException) return e.code == 429 || e.code == 503; return false; } Future<T> retryWithBackoff<T>( Future<T> Function() fn, { int maxAttempts = 3, Duration baseDelay = const Duration(milliseconds: 200), bool Function(Object)? shouldRetry, }) async { final retryable = shouldRetry ?? _defaultShouldRetry; int attempt = 0; while (true) { try { return await fn(); } on Object catch (e) { attempt++; if (attempt >= maxAttempts || !retryable(e)) rethrow; final backoff = baseDelay * (1 << (attempt - 1)); final jitterMs = _retryRng.nextInt(101) - 50; // [-50, +50] ms await Future<void>.delayed(backoff + Duration(milliseconds: jitterMs)); } } } ``` Rules: - **Module-level RNG**: hoist once. No `math.Random()` per call. - **Retryable set explicit**: 429/503 + network IO only. Validation/4xx rethrow immediate. - **Partial-failure**: bulk op collects `SaveRowResult` per item. `throwOnPartialFailure` → `SaveAllRowsException` w/ failure list. No silent drop. - **Concurrency cap**: bulk default 4 (avoid 429 bucket swamp). Each item in `retryWithBackoff`. - **Not in widgets**: retry = infra. Notifier → datasource → mixin. -
networking.md 8.6 KB
# Networking ## Read first 1. HTTP calls live only in datasources/infra services. Widgets/notifiers never call clients. 2. Datasources depend on `IHttpService`/token interfaces, not concrete clients. 3. Data layer parses JSON → models; repos map models → domain. 4. Failures throw typed errors. Never return `null`/empty fallback for failed network ops. 5. Mutations refresh source of truth when backend can generate/normalize/reorder/derive. 6. Long remote work async-starts, then reconciles with bounded polling/realtime/fetch. 7. Every datasource operation declares which response statuses are accepted, retryable, absent, or terminal. ## Trigger Signals: IHttpService, datasource, HTTP, auth token, background parsing, Isolate.run ## Rules 1. **MUST** keep all HTTP calls in datasources or infrastructure services. Widgets and notifiers never call HTTP clients directly. 2. **MUST** inject an interface (`IHttpService`, `IAuthTokenProvider`) into datasources. Constructors take interfaces, not concrete clients. 3. **MUST** parse JSON into data models in the data layer, then map to domain entities in repositories. 4. **MUST** throw typed exceptions or `AppException` from infrastructure boundaries. Do not return `null` for failed network operations. 5. **MUST** refresh from source of truth after mutations when the backend can generate, normalize, reorder, or derive fields. 6. **MUST** move large JSON parsing off the UI isolate when it can exceed a frame budget. 7. **MUST NOT** put auth tokens, base URLs, or secrets in widget code. 8. **MUST** async-start long-running remote work (delete/sync/import/export/migrate/generate), then reconcile source-of-truth state with bounded polling, realtime, or a canonical fetch. Do not block the client request waiting for backend completion. 9. **MUST** classify response status once at the infrastructure boundary. Keep accepted, retryable, absent, and terminal status sets in one shared HTTP classifier instead of repeating them in widgets or notifiers. 10. **MUST** treat expected absence as a typed result, not a generic error. An expected `401` without a session or expected `404` for a missing record must return an explicit absent or signed-out result and must not create a Sentry event. An unexpected response is a typed failure and is reported once by the owning layer after any required reconcile. 11. **MUST** retry only bounded, safe cases such as `429`, selected `5xx`, and transport failures. Do not retry a non-idempotent write unless the API has an idempotency key or the operation is otherwise proven safe. 12. **MUST NOT** catch raw HTTP failures in widgets or notifiers. They render typed results; the datasource or repository owns classification and the single incident report. ## Platform Setup Android internet permission: ```xml <uses-permission android:name="android.permission.INTERNET" /> ``` macOS network entitlement for debug/profile and release: ```xml <key>com.apple.security.network.client</key> <true/> ``` Keep platform setup in app templates or project docs; do not hide it inside a feature module. ## Service Contract Use one small interface first. Add streaming/upload/download methods only when a feature needs them. ```dart abstract interface class IHttpService { Future<Object?> getJson( Uri uri, { Map<String, String> headers = const <String, String>{}, }); Future<Object?> postJson( Uri uri, { required Object body, Map<String, String> headers = const <String, String>{}, }); } ``` Provider returns the interface: ```dart @Riverpod(keepAlive: true) IHttpService httpService(Ref ref) { return HttpService( baseUri: ref.read(appConfigProvider).apiBaseUri, tokenProvider: ref.read(authTokenProvider), ); } ``` ## Datasource Pattern ```dart abstract interface class IProductRemoteDatasource { Future<List<ProductModel>> fetchAll(); Future<ProductModel> create(ProductModel model); } class ProductRemoteDatasource implements IProductRemoteDatasource { const ProductRemoteDatasource(this._http); final IHttpService _http; @override Future<List<ProductModel>> fetchAll() async { final payload = await _http.getJson(Uri(path: ApiPaths.products)); return switch (payload) { List<Object?> items => [ for (final item in items) ProductModel.fromJson(item as Map<String, dynamic>), ], _ => throw const FormatException('Expected product list payload'), }; } @override Future<ProductModel> create(ProductModel model) async { final payload = await _http.postJson( Uri(path: ApiPaths.products), body: model.toJson(), ); return switch (payload) { Map<String, dynamic> json => .fromJson(json), _ => throw const FormatException('Expected product payload'), }; } } ``` ## Repository Source-of-Truth Refresh If a mutation response can be stale, partial, generated, normalized, or derived, refresh before the UI claims success. ```dart class ProductRepository implements IProductRepository { const ProductRepository(this._remote); final IProductRemoteDatasource _remote; @override Future<Product> create(Product draft) async { final created = await _remote.create(.fromEntity(draft)); final canonical = await _remote.fetchById(created.id); return canonical.toEntity(); } } ``` ## Long-Running Remote Work A destructive or batch operation can complete after the client request times out. Treat the initial call as a start acknowledgement, then reconcile the source of truth. ```dart // WRONG — client waits for backend completion (`appwrite_blocking_function_execution_in_client`). final result = await remote.deleteAccount(userId, waitForCompletion: true); ``` ```dart // WRONG — reports the failure before reconcile (`destructive_failure_logged_before_reconcile`). class AccountRepository implements IAccountRepository { AccountRepository(this._remote); final IAccountRemoteDatasource _remote; @override Future<DeleteResult> deleteAccount(String userId) async { try { return await _remote.startDeleteAccount(userId); } on Exception catch (e, s) { Crash.error(e, s, reason: 'deleteAccount'); final deleted = await _remote.waitForAccountDeleted(userId, maxAttempts: 60); return deleted ? .ok() : .timedOut(); } } } ``` ```dart // RIGHT — async-start + bounded reconcile. final started = await remote.startDeleteAccount(userId); if (!started.ok) return started; final deleted = await remote.waitForAccountDeleted(userId, maxAttempts: 60); return deleted ? .ok() : .timedOut(); ``` Log/report destructive failures only after reconcile proves the entity still exists or the source of truth still disagrees. Lints: `appwrite_blocking_function_execution_in_client`, `destructive_failure_logged_before_reconcile`. ## Background Parsing Use `Isolate.run` or `compute` for large payloads. Keep parsing functions top-level or static and return model objects, not domain entities. ```dart import 'dart:convert'; List<ProductModel> parseProducts(String responseBody) { final Object? decoded = jsonDecode(responseBody); return switch (decoded) { List<Object?> items => [ for (final item in items) ProductModel.fromJson(item as Map<String, dynamic>), ], _ => throw const FormatException('Expected product list payload'), }; } ``` ## Tests - Unit-test `HttpService` status-code and malformed-body handling. - Unit-test the shared status classifier for every operation: accepted success, expected `401` or `404` absence, bounded `429` or `5xx` retry, and terminal failure. - Unit-test every datasource success and failure payload shape. - Repository tests mock datasource interfaces and verify model-to-entity mapping. - Notifier tests mock repositories, not HTTP. - Remote/shared-state features still need source-of-truth and observer E2E from [dart-mcp-e2e-testing.md](dart-mcp-e2e-testing.md). ## Checklist - [ ] Platform network permissions/entitlements are present for target platforms. - [ ] Datasources depend on `IHttpService` or project equivalent. - [ ] JSON is parsed into data models only. - [ ] Failures throw typed errors; no silent `null` or empty fallback. - [ ] Mutations refresh source-of-truth when backend values can differ. - [ ] Long-running remote functions async-start, then reconcile with bounded polling/realtime/fetch. - [ ] Each datasource operation has an accepted-status and retry-status policy. - [ ] Expected absent or signed-out responses do not create error reports. - [ ] Destructive catch blocks reconcile before Crash/Sentry/Firebase reporting. - [ ] Large payload parsing uses isolate/compute path when needed. - [ ] Datasource, repository, notifier, and E2E coverage match the risk. -
performance.md 15.5 KB
# Performance ## Read first 1. Bind providers in the smallest screen/subscreen boundary; `.select()` only the field needed. Never use `.select((value) => value)` (`riverpod_select_identity_forbidden`); use a field/record select, or watch a generated computed projection provider directly when the entire provider value is already the render projection. 2. High-frequency values (`seconds`, `progress`, `isRunning`) stay in the smallest screen-owned Consumer boundary; reusable widgets receive immutable values. 3. Extract widget classes; no `_buildXxx()` helpers. Use `const` where possible. 4. Dynamic lists use builders/slivers; no eager `ListView(children: [...])`. 5. No expensive sort/filter/map in `build()`; use computed providers/cached indexes. 6. Cache/index/snapshot values need one SSOT: computed provider, notifier/repo state, non-const instance `late final` derived field, or memoized service/repo/datasource cache; never repeat in widget state and never use top-level/global `Expando` side tables. Const Freezed state/entities cannot own `late final` caches. 7. No top-level/global widget helper functions, no `*Data` helper namespaces that filter/map/sort/index collections, and no private widget helpers that derive collections (`where`/`map`/`sort`/lookups); use computed providers/notifiers or non-widget service/model classes. 8. Persist changed rows only after subset mutation; no full-collection rewrite hot paths. 9. Reorderable lists use `onReorderItem` semantics directly: insert at `newIndex`; no deprecated framework `onReorder`, no inverse adapter math, no downstream legacy `newIndex -= 1`. Lint: `use_on_reorder_item_index_semantics`. 10. Foreground waits stay tiny: search/realtime debounce <=150ms, visual animation <=120ms, persistence/hard waits <=50ms. Retry/backoff, rest timers, reminders, and sync/backfill settle timers are background/domain concerns, not tap latency. Lint: `user_visible_duration_too_long`. ## Trigger Signals: ref.watch, Consumer boundary, .select(), ListView.builder, computed provider, ref.onDispose Flutter rendering/animations/slivers/isolates/app-size → see [flutter-optimizations.md](flutter-optimizations.md). ## Rules — NEVER Violate 1. **MUST** bind providers in the smallest screen/subscreen Consumer boundary and pass minimal immutable view data to reusable widgets. 2. **MUST** use `.select()` for specific fields at provider-binding boundaries. 3. **MUST** extract widget classes — NEVER helper methods (`_buildXxx()`). 4. **MUST** use `const` constructors where possible. 5. **MUST** use `ListView.builder` — NEVER `ListView(children: [...])` for dynamic lists. 6. **NEVER** expensive ops (sort/filter/map) in `build()` — use computed providers. 7. **NEVER** declare top-level/global helper functions in widget/screen files. Put behavior on widget classes, `abstract final class` namespaces, computed providers, or notifiers. Lint: `widget_top_level_function_boundary`. 8. **NEVER** put derived collection logic in widget-file `*Data` namespaces or private widget helpers (`List<T> _filtered...`, `Map<K, V> _itemsBy...`). Use computed providers/notifiers. Lint: `widget_derived_collection_logic`. 9. **NEVER** duplicate provider-derived caches/indexes/snapshots in `ConsumerState`; one provider/notifier/repo/service is the SSOT. No provider-family arg wrappers (`config`/`args`/`params`) or `ProviderSubscription` fields in widget state. No `ref.listenManual`. Lints: `riverpod_consumer_state_derived_cache`, `riverpod_widget_provider_arg_wrapper`, `riverpod_consumer_state_provider_subscription`, `riverpod_listen_manual_forbidden`. 10. **MUST** dispose timers/controllers/subscriptions via `ref.onDispose()`. 11. **NEVER** hold raw API responses in state — extract needed fields only. 12. **NEVER** clamp text scaling at app root. Fix local responsive layout/overflow instead. 13. **NEVER** watch timer/ticker/progress fields in a broad modal/sheet parent. Extract the smallest screen-owned Consumer boundary for the ticking controls. 14. **NEVER** allocate Map/List/Set in getters used from `build()` / `.select()` / hot notifier paths. Use computed providers/service caches, or non-const instance-owned `late final` immutable indexes; do not use top-level/global `Expando` side tables. 15. **NEVER** do repeated id lookups with `firstWhere` / `indexWhere` / `for` loops in hot paths. Pre-index by id with `Map`. 16. **NEVER** persist full collections after changing a subset. Write changed rows via `mergeAll` / `saveMany`; debounce draft persistence with a real `Timer` / `Debouncer` at <=50ms. 17. **NEVER** block splash/cover routes on background sync, table backfill, or local merge work. Route to the shell when auth/setup is known, then hydrate data behind local state. Lint: `router_splash_waits_for_initial_sync`. 18. **NEVER** add foreground hard sleeps or slow debounces. Search/realtime debounce <=150ms, visual animation <=120ms, persistence/hard waits <=50ms. Lint: `user_visible_duration_too_long`. 19. **MUST** use `onReorderItem` semantics directly for `ReorderableListView`, `SliverReorderableList`, and `ReorderableList`: `newIndex` is already post-removal. Insert at `newIndex`; do not use deprecated framework `onReorder`, `newIndex > oldIndex ? newIndex + 1 : newIndex`, or `if (oldIndex < newIndex) newIndex -= 1`. Lint: `use_on_reorder_item_index_semantics`. ## Widget Rebuild Rules ### Text Scaling Do not disable user accessibility globally: ```dart // WRONG — app-wide clamp hides layout bugs and blocks accessibility. MaterialApp( builder: (context, child) => MediaQuery.withClampedTextScaling( maxScaleFactor: 1, child: child!, ), ); ``` Fix the widget: - allow wrapping - use `Flexible`/`Expanded` - avoid fixed heights around text - use shorter labels - make compact controls icon-first - test large text sizes on small screens ### Bind Providers at Screen Boundaries Bind in the smallest screen/subscreen Consumer boundary. Reusable widgets receive minimal immutable view inputs: ```dart // WRONG — broad state watch rebuilds the whole screen subtree. class UserScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final userState = ref.watch(userProvider); return UserSummary(user: userState.user); } } // RIGHT — screen selects the render projection; widget stays provider-free. class UserSummaryScreen extends ConsumerWidget { const UserSummaryScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final (:name, :email) = ref.watch( userProvider.select((s) => (name: s.name, email: s.email)), ); return UserSummary(name: name, email: email); } } class UserSummary extends StatelessWidget { const UserSummary({required this.name, required this.email, super.key}); final String name; final String email; @override Widget build(BuildContext context) => Column( children: [Text(name), Text(email)], ); } ``` ### Use .select() to Watch Specific Fields `select` skips rebuilds when unrelated fields change. `.select()` is necessary but not sufficient: if the selected field changes every second, the binding boundary still rebuilds every second. Put timer/ticker/progress watches in the smallest screen-owned Consumer boundary. ```dart // Rebuilds only when items change, not when isLoading or error change final items = ref.watch( productProvider.select((s) => s.items), ); // Watch multiple fields with a record final (:isLoading, :error) = ref.watch( productProvider.select((s) => (isLoading: s.isLoading, error: s.error)), ); ``` ### Extract Widget Classes, Not Helper Methods ```dart // WRONG — helper methods hide rebuild boundaries. Widget _buildHeader() => Container(...); ``` ```dart class HeaderWidget extends StatelessWidget { const HeaderWidget({super.key}); @override Widget build(BuildContext context) => const SizedBox.shrink(); } ``` ### Use const Constructors ```dart // WRONG — allocates new object on every parent rebuild return Padding(padding: const EdgeInsets.all(16), child: child); // RIGHT — reuses existing object. `const` requires every argument be const, // so the child must be a concrete const widget (here: SizedBox.shrink()). // You CANNOT pass a runtime `child` variable into a const constructor. return const Padding(padding: EdgeInsets.all(Spacing.s16), child: SizedBox.shrink()); ``` ## Provider Lifecycle ### keepAlive vs Auto-Dispose | `@Riverpod(keepAlive: true)` | `@riverpod` | |------------------------------|-------------| | Repositories, datasources, services | Computed values, derived data | | Feature notifiers | One-time fetches | | Computed providers whose **all** deps are keepAlive | Computed providers with mixed dep lifecycles | | Lives until app terminates | Disposes when no widget watches | Auto-dispose in all-keepAlive chain can break pause/resume subscription counting. Match lifecycle. Practical guardrails: - If all upstream deps are `keepAlive`, keep downstream computed providers `keepAlive`. - Do not stack computed hops in pause-sensitive paths (`computedA -> computedB -> familyC`). - Flatten: watch base state once, derive with pure helpers. ### Equality Filtering Riverpod 3.0 use `==` for notification filter. Freezed gen `==` auto. Override `updateShouldNotify` only when want reference-equality (`identical`) for perf-sensitive large state: ```dart @override bool updateShouldNotify(ProductState previous, ProductState next) { return !identical(previous, next); } ``` ## Memory Management ### Clean Up Resources ```dart @Riverpod(keepAlive: true) class StreamNotifier extends _$StreamNotifier { @override StreamState build() { final subscription = ref .read(streamServiceProvider) .stream .listen((data) { if (!ref.mounted) return; state = state.copyWith(data: data); }); ref.onDispose(() => subscription.cancel()); return const StreamState(); } } ``` ### Avoid Holding Large Objects ```dart // WRONG — holds full response in state state = state.copyWith(rawJson: hugeJsonMap); // RIGHT — extract only needed fields state = state.copyWith( items: parseItems(hugeJsonMap), total: parseTotal(hugeJsonMap), ); ``` ## ListView Optimization ### Use ListView.builder ```dart // WRONG — NEVER build all items at once ListView(children: items.map((i) => ItemWidget(i)).toList()) // RIGHT — MUST use builder for lazy loading ListView.builder( itemCount: items.length, itemBuilder: (context, index) => ItemWidget(items[index]), ) ``` ### Use itemExtent When Heights Are Fixed ```dart ListView.builder( itemExtent: 72.0, // fixed height — skips layout calculation itemCount: items.length, itemBuilder: (context, index) => ItemTile(items[index]), ) ``` ## Image Optimization ```dart // Cache network images Image.network( product.imageUrl, semanticLabel: l10n.productImageLabel(product.name), cacheWidth: 200, // decode at display size, not full resolution cacheHeight: 200, ) // Use FadeInImage for smooth loading FadeInImage.memoryNetwork( placeholder: kTransparentImage, image: url, ) ``` ## Avoid Expensive Operations in build() Build must be pure. Do not hide field/controller mutations in helper calls from `build()`. ```dart // WRONG — build calls helper that mutates fields/controllers. @override Widget build(BuildContext context) { final item = ref.watch(itemProvider); _syncInitialValues(item); // assigns _controller.text / _cached fields return const SizedBox.shrink(); } ``` ```dart // RIGHT — sync from lifecycle/event/provider, not build. @override void didUpdateWidget(covariant Editor oldWidget) { super.didUpdateWidget(oldWidget); _syncInitialValues(widget.initialItem); } ``` ```dart // WRONG — sorts on every rebuild @override Widget build(BuildContext context, WidgetRef ref) { final items = ref.watch(productProvider.select((s) => s.items)); final sorted = items.toList()..sort((a, b) => a.name.compareTo(b.name)); return ListView(...); } // RIGHT — compute in notifier or use a computed provider @riverpod List<Product> sortedProducts(Ref ref) { final items = ref.watch(productProvider.select((s) => s.items)); return items.toList()..sort((a, b) => a.name.compareTo(b.name)); } ``` ## Indexed Lookup and Persistence Hot Paths ### Cache immutable collection indexes ```dart // WRONG — fresh Map every getter access. Records/selects see new identity. Map<String, List<Item>> get itemsByGroup { final map = <String, List<Item>>{}; for (final item in items) { (map[item.groupId] ??= <Item>[]).add(item); } return map; } ``` ```dart // RIGHT — compute once on a non-const immutable instance. late final Map<String, List<Item>> itemsByGroup = _indexItemsByGroup(items); ``` ### Pre-index repeated id lookups ```dart // WRONG — O(n*m) for (final change in changes) { final index = items.indexWhere((item) => item.id == change.itemId); if (index >= 0) apply(change); } ``` ```dart // RIGHT — O(n+m) final itemsById = {for (final item in items) item.id: item}; for (final change in changes) { final item = itemsById[change.itemId]; if (item != null) apply(change); } ``` ### Debounce full-state persistence A queue or generation token prevents races; it does **not** coalesce writes. Draft persistence called after taps/reorders/expands needs cancel-and-restart `Timer` / `Debouncer` at <=50ms, plus an explicit `persistNow()` for lifecycle flush. Relevant lints: `modal_high_frequency_watch_not_leaf`, `build_calls_mutating_instance_method`, `collection_getter_allocates_each_access`, `expando_derived_cache_forbidden`, `linear_id_lookup_in_hot_path`, `nested_linear_lookup_by_id`, `save_all_full_collection_after_subset_mutation`, `notifier_persistence_no_debounce`, `user_visible_duration_too_long`, `appwrite_blocking_function_execution_in_client`, `destructive_failure_logged_before_reconcile`, `storage_clear_preserves_migration_state`. ## Checklist ### Widget Rebuilds - Bind providers in the smallest screen/subscreen Consumer boundary - Pass minimal immutable view data + typed callbacks to reusable widgets - Use `.select()` for specific fields - Extract widget classes, not helper methods - Use `const` constructors where possible - Never override `operator ==` on Widget — O(N²) rebuild check; use `const` + caching - Timer/ticker/progress provider watches live in the smallest screen-owned Consumer boundary, not broad sheet/dialog parents - `build()` does not call private helpers that assign fields/controllers or call `setState` - Collection getters used from UI/notifiers are cached or provider-derived, not rebuilt per access - Id lookups in hot paths use `Map` indexes, not `firstWhere` / `indexWhere` / manual loops ### State Management - `@Riverpod(keepAlive: true)` for repos, datasources, services, notifiers - `@riverpod` for computed values, one-time fetches - `if (!ref.mounted) return;` after every `await` ### Data Loading - Cache remote data locally (remote → local fallback) - Paginate large lists - Debounce search inputs at <=150ms - Debounce draft/full-state persistence at <=50ms with a real Timer/Debouncer; queues/generation tokens are not debounce - Persist changed subsets with `mergeAll` / `saveMany`; avoid full `saveAll` rewrites after subset mutation - Async-start long-running remote functions and reconcile source-of-truth state instead of blocking the client request - Reconcile destructive failures before Crash/Sentry/Firebase reporting - Do not preserve migration/version/install markers around reset/clear flows - Prevent duplicate fetches with boolean flags - Use `Future.wait()` for parallel ops ### Memory - Dispose timers/controllers/subscriptions in `ref.onDispose()` - No raw API responses in state - Auto-dispose for temporary state -
presentation-widgets.md 2.6 KB
# Presentation Widget Boundary ## Read first - Reusable widget = immutable view inputs + typed callback outputs. - Navigation/workflow/domain/infrastructure owner = screen + typed route + generated notifier/provider. ## Ownership | Owner | Contract | |---|---| | `presentation/widgets/` | Render immutable view inputs + emit typed callbacks + own UI lifecycle objects only. | | `presentation/screens/` | Bind providers + map domain state to view data + branch visible workflow + coordinate callbacks. | | Typed route | Own page navigation + route arguments + route results. | | Generated notifier/provider | Own domain selection + workflow/mutation state + repository/service calls. | Widget `State` allowed = text/scroll/page/tab/animation controllers + focus nodes + cancellable UI timers/debouncers. Widget `State` forbidden = domain entities + selected records + page/navigation stacks + workflow flags + provider-derived caches/snapshots/maps. Widget dependencies forbidden = GoRouter + page-route `Navigator.push*` + repositories + datasources + services + persistence/backend/HTTP SDKs + provider reads/mutations. Local modal dismissal allowed = `Navigator.pop(result)` with no work after pop; caller owns subsequent mutation/page navigation. Lints: `presentation_widget_navigation_forbidden`, `presentation_widget_controller_state`, `presentation_widget_infrastructure_dependency`. ## WRONG ```dart class _ContentViewState extends State<ContentView> { final List<Entity> _pageStack = []; Entity? _selected; Future<void> _open(Entity item) async { if (item.children.isNotEmpty) { setState(() => _pageStack.add(item)); } else { setState(() => _selected = item); await ref.read(contentRepositoryProvider).load(item.id); } } void _back() { if (_pageStack.isEmpty) context.pop(); } } ``` ## DO ```dart final class ContentView extends StatelessWidget { const ContentView({ required this.items, required this.onItemTap, required this.onBack, super.key, }); final UnmodifiableListView<ContentItemViewData> items; final ValueChanged<ContentItemViewData> onItemTap; final VoidCallback onBack; // Render items and invoke callbacks only. @override Widget build(BuildContext context) { return ListView.builder( itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; return ListTile( title: Text(item.title), onTap: () => onItemTap(item), ); }, ); } } ``` Screen callback → choose child-list/article view + call typed route. Notifier action → select domain record + call repository/service + expose workflow state. -
riverpod-codegen.md 16.2 KB
# Riverpod 3.x Codegen ## Read first 1. Every provider shape uses `@riverpod`/`@Riverpod(keepAlive: true)` codegen. No manual providers. 2. Never import `package:riverpod/legacy.dart` or use banned providers. 3. Generated provider names are SSOT; rename annotated fn/class, then regenerate. 4. No provider-derived caches in `ConsumerState`; use computed providers or build-local values. 5. Keep `keepAlive` for app-wide services/repos/nav-surviving state, not transient UI. ## Trigger Signals: @riverpod, @Riverpod(keepAlive), Notifier, AsyncNotifier, riverpod_annotation, riverpod_generator Generate all providers with annotations. Never write providers manually. Never import `package:riverpod/legacy.dart`. Hard rule: this applies to every provider shape: state, computed value, repository, datasource, service/client, family, future, stream, and notifier. Manual `Provider(...)`, `FutureProvider(...)`, `StreamProvider(...)`, `StateProvider(...)`, `NotifierProvider(...)`, `AsyncNotifierProvider(...)`, `StateNotifierProvider(...)`, and `ChangeNotifierProvider(...)` are banned. ## Setup ```yaml # pubspec.yaml — see core-stack.md for canonical versions dependencies: flutter_riverpod: <version> riverpod_annotation: <version> dev_dependencies: build_runner: <version> riverpod_generator: <version> ``` > **Forward note.** Riverpod 3.x changelog: *"4.0.0 quite possible."* Treat > 3.x as short-lived. Prefer codegen + `Notifier` shapes over deprecated > APIs — minimise 4.0 migration. Canonical [analysis_options.yaml](analysis_options.yaml): `flutter_skill_lints` + `riverpod_lint`. Apply [analysis-options.md](analysis-options.md#install) before `dart analyze` (use `dart analyze`, not `flutter analyze` — see [analysis-options.md](analysis-options.md#use-dart-analyze-not-flutter-analyze)). Every file with providers need these: ```dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'my_file.g.dart'; ``` ## Generated Provider Names Riverpod 3.x strip "Notifier" suffix from class-based provider names: | Class | Generated Provider | |---|---| | `CartNotifier` | `cartProvider` | | `ProductNotifier` | `productProvider` | | `AuthNotifier` | `authProvider` | Functional providers keep full name: | Function | Generated Provider | |---|---| | `productRepository(Ref)` | `productRepositoryProvider` | | `cartTotal(Ref)` | `cartTotalProvider` | Never write `xxxNotifierProvider` — not exist in codegen output. Never create an alias/manual provider to "simplify" a generated provider. Rename the annotated function/class instead, regenerate `.g.dart`, and update call sites. ## Provider Types ### keepAlive Providers (long-lived) For repositories, datasources, services, feature notifiers, and computed values whose deps are all keepAlive: ```dart // Functional provider — returns a value, lives forever @Riverpod(keepAlive: true) ProductRepository productRepository(Ref ref) { return ProductRepository( ref.read(productRemoteDatasourceProvider), ref.read(productLocalDatasourceProvider), ); } // Class-based notifier — manages mutable state, lives forever @Riverpod(keepAlive: true) class CartNotifier extends _$CartNotifier { @override CartState build() => const CartState(); void addItem(Product product) { state = state.copyWith( items: [...state.items, product], ); } } // Computed value — every dep is keepAlive, so it stays keepAlive too @Riverpod(keepAlive: true) int cartTotal(Ref ref) { final items = ref.watch(cartProvider.select((s) => s.items)); return items.fold(0, (sum, item) => sum + item.price.toInt()); } ``` ### Auto-dispose Providers (short-lived) For one-time fetches and computed values with any auto-dispose dep ([lifecycle match](performance.md#keepalive-vs-auto-dispose)): ```dart // Async fetch — disposes when unused @riverpod Future<Product> productDetail(Ref ref, String id) async { final repo = ref.read(productRepositoryProvider); return repo.fetchById(id); } ``` ### Family Providers (parameterized) Codegen handle family automatically via function parameters: ```dart // Parameters become family args — no FamilyNotifier needed @riverpod Future<List<Product>> productsByCategory(Ref ref, String category) async { final repo = ref.read(productRepositoryProvider); return repo.fetchByCategory(category); } // Class-based with parameters @riverpod class ProductEditorNotifier extends _$ProductEditorNotifier { @override ProductFormState build(String productId) { unawaited(.microtask(() => _loadProduct(productId))); return const ProductFormState(); } Future<void> _loadProduct(String id) async { final product = await ref.read(productRepositoryProvider).fetchById(id); if (!ref.mounted) return; state = state.copyWith(name: product.name, price: product.price); } } ``` ### Generic Providers (type parameters) Generated providers support generics: ```dart @riverpod T larger<T extends num>(Ref ref, T a, T b) { return a >= b ? a : b; } // Usage: watch inside build. class LargerValues extends ConsumerWidget { const LargerValues({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; final int integer = ref.watch(largerProvider<int>(2, 3)); final double decimal = ref.watch(largerProvider<double>(2.5, 3.5)); return Text(l10n.largerValues(integer, decimal)); } } ``` ## Provider-Derived UI Data `ConsumerState` may own lifecycle handles. It must not cache `ref.watch` data. ```dart // Wrong: provider data copied into State. class _HistoryCardState extends ConsumerState<HistoryCard> { List<Workout> _historyCache = const []; @override Widget build(BuildContext context) { _historyCache = ref.watch(historyProvider); return HistoryList(items: _historyCache); } } ``` Use a generated computed provider, or a local `build` value for cheap transforms: ```dart @riverpod List<Workout> visibleHistory(Ref ref) => ref.watch(historyProvider).where((item) => item.isVisible).toList(); class HistoryScreen extends ConsumerWidget { const HistoryScreen({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final visible = ref.watch(visibleHistoryProvider); return HistoryList(items: visible); } } ``` ## Unified Ref Use `Ref` for providers. Do not use `AutoDisposeRef`, `FutureProviderRef`, or generated `ExampleRef`. ```dart @riverpod String greeting(Ref ref) => 'Hello'; // NOT: String greeting(GreetingRef ref) ``` `Ref` and `WidgetRef` stay separate. `WidgetRef` for widgets only. ## Automatic Retry Providers that fail during init retry automatically with exponential backoff (200ms initial, double up to 6.4s). Customize globally: ```dart const _maxProviderRetries = 5; Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Crash.init( appRunner: () => runApp( ProviderScope( retry: (retryCount, error) { if (error is ProviderException) return null; // Don't retry dependency failures if (retryCount > _maxProviderRetries) return null; // Stop after max retries return Duration(seconds: retryCount * 2); }, child: const MyApp(), ), ), ); } ``` Customize per provider: ```dart @Riverpod(keepAlive: true, retry: myRetryLogic) Future<Config> appConfig(Ref ref) async { return await fetchConfig(); } ``` ## ProviderException Provider errors wrap in `ProviderException`. Distinguishes "provider failed" from "dependency of provider failed": ```dart try { ref.watch(failingProvider); } on ProviderException catch (e) { switch (e.exception) { case NetworkError(): // Handle network error default: rethrow; } } ``` ## Mutations (experimental) > API may change without major version bump. Mutations track side-effect state (idle, pending, success, error) separately from provider state. Prevent providers from being disposed while side-effect runs. ```dart // features/todos/presentation/screens/add_todo_screen.dart // // Mutations = **file scope** (top-level), not inside class. Same instance // shared across rebuilds + consumers. Matches Riverpod docs: one mutation = // one file-scope `final`, named `<verb><Noun>Mutation`. import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/experimental/mutation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:my_app/core/extensions/extensions.dart'; final addTodoMutation = Mutation<void>(); // experimental API — may change without major bump class AddTodoScreen extends ConsumerWidget { const AddTodoScreen({super.key}); Future<void> _addTodo(WidgetRef ref) => addTodoMutation.run(ref, (tsx) async { // tsx.get keeps the provider alive until mutation completes await tsx.get(todoListProvider.notifier).addTodo('New Todo'); }); @override Widget build(BuildContext context, WidgetRef ref) { final addTodo = ref.watch(addTodoMutation); final l10n = context.l10n; return switch (addTodo) { MutationIdle() => ElevatedButton( onPressed: () => unawaited(_addTodo(ref)), child: Text(l10n.addTodoSubmit), ), MutationPending() => const CircularProgressIndicator(), MutationError() => ElevatedButton( onPressed: () => unawaited(_addTodo(ref)), child: Text(l10n.addTodoRetry), ), MutationSuccess() => Text(l10n.addTodoDone), }; } } ``` Use `tsx.get` instead of `ref.read` inside mutations — keeps provider alive until mutation completes. `MutationState` exposes convenience flags (`isPending`, `isIdle`, `hasError`, `isSuccess`) for simple checks without full pattern matching. ## Offline Persistence (preview — not yet on pub.dev) > `persist(...)` API + `riverpod_sqflite` = **preview only**. No stable > release on pub.dev as of 2026-05-08. Do **not** add `riverpod_sqflite` to > `pubspec.yaml` until shipped. For local persistence today: Hive CE > ([hive-persistence.md](hive-persistence.md)). Snippet below = API preview, > not copy-paste. Providers will (eventually) persist via official `riverpod_sqflite` once published: ```dart @Riverpod(keepAlive: true) class TodosNotifier extends _$TodosNotifier { @override Future<List<Todo>> build() async { persist( ref.watch(storageProvider.future), key: StorageKeys.todos, encode: jsonEncode, decode: TodoListCodec.decode, ); return await fetchTodos(); } } ``` ```dart // features/todos/data/models/todo_list_codec.dart — parsing stays in the data layer abstract final class TodoListCodec { static List<Todo> decode(String json) => switch (jsonDecode(json)) { final List<Object?> items => [ for (final item in items) switch (item) { final Map<String, dynamic> map => Todo.fromJson(map), _ => throw const FormatException('Expected todo object'), }, ], _ => throw const FormatException('Expected todo list payload'), }; } ``` During `await`, persisted value shown. After network response, server state take precedence. ### Persistence Addendum Keep all points below when using persistence: Execution order: 1. Choose persistence owner (repo/data or notifier). 2. Define key + cache policy. 3. Choose startup mode (`persist(...)` or `await persist(...).future`). 4. Add in-memory persistence tests. 1. **One owner per feature state**: use repository/data persistence or notifier persistence, never both. 2. **Cache policy**: default retention 2 days; set `StorageOptions(cacheTime: ...)` when needed; use `unsafe_forever` only with cleanup. 3. **Persist keys**: keys must be unique, stable across restarts, include family params. 4. **Versioned reset**: use `StorageOptions(destroyKey: ...)` when cached provider state must be hard-reset after a shape change. 5. **Startup mode**: use `persist(...)` for network-first init, or `await persist(...).future` to hydrate state first. 6. **UI signal**: use `AsyncValue.isFromCache` for cached/offline indicators. 7. **Tests**: override storage with `Storage.inMemory()` in unit/widget tests. 8. **Codegen**: use `@JsonPersist()` when JSON-serializable models available. ## Pause/Resume Riverpod 3.0 pause providers when listeners not visible: - Widgets off-screen (based on `TickerMode`) pause their providers - If provider only used by paused providers, it pauses too - When provider rebuilds, previous subscriptions stay until rebuild completes Composition rule for pause-sensitive flows: - Avoid nested computed chains (computed watches computed, especially family). - Prefer one computed provider: watch base state directly, derive via pure helpers. - If Riverpod 3.2.x pause/resume assertion appears in offstage navigation: flatten hops first, lifecycle workaround later. Override pause behavior: ```dart TickerMode( enabled: false, // pause listeners child: Consumer( builder: (context, ref, child) { final value = ref.watch(myProvider); // paused return Text(value.toString()); }, ), ) ``` ## Weak Listeners Listen without preventing auto-dispose: ```dart ref.listen( anotherProvider, weak: true, (previous, next) { // Provider can still dispose even though we're listening }, ); ``` ## Lifecycle Listeners Return Unsubscribe Functions ```dart final removeListener = ref.onDispose(() => print('disposed')); // Call to remove: removeListener(); ``` ## Scoping (codegen only) Scoped providers declare `dependencies: []` and require override before use: ```dart @Riverpod(dependencies: []) Future<int> scopedValue(Ref ref) => throw UnimplementedError(); // Must override before use ProviderScope( overrides: [ scopedValueProvider.overrideWithValue(const .data(42)), ], child: const MyWidget(), ) ``` Use `@Dependencies([scopedValue])` on widgets consuming scoped providers. Lint rule catches missing overrides at compile time. ## Backend Client Providers External SDK clients (HTTP, database, auth, storage) follow **config → client → services** chain. Riverpod providers ARE dependency injection — no factory classes, service locators, or wrapper layers needed. ### Rules — NEVER Violate 1. **MUST** expose SDK types directly as providers. NEVER wrap in factory class or service locator. 2. **MUST** use `@Riverpod(keepAlive: true)` for SDK client/service providers that need `Ref`, config reactivity, disposal, overrides, returned data, or UI-observed state. Plain fire-and-forget SDK facades stay direct and boring; see [services-and-singletons.md](services-and-singletons.md). 3. **MUST** use `ref.read()` for stable service/repository/datasource/client/plugin wiring. Use `ref.watch()` only for the provider that intentionally owns reactivity, such as rebuilding a client from a live config provider. 4. **MUST** use destructuring for clean config access. 5. **NEVER** create `ServiceFactory`, `ServiceLocator`, or `BackendProvider` class — Riverpod providers replace these patterns entirely. ### Pattern ```dart // core/providers/backend_providers.dart /// 1. Config — reads from environment, lives forever @Riverpod(keepAlive: true) BackendConfig backendConfig(Ref ref) { return .fromEnvironment(); } /// 2. Client — depends on config, configured once @Riverpod(keepAlive: true) HttpClient backendClient(Ref ref) { final BackendConfig(:endpoint, :apiKey) = ref.watch(backendConfigProvider); return HttpClient() ..setEndpoint(endpoint) ..setApiKey(apiKey); } /// 3. Services — each depends on client, one provider per SDK service @Riverpod(keepAlive: true) AuthService authService(Ref ref) { return AuthService(ref.read(backendClientProvider)); } @Riverpod(keepAlive: true) DatabaseService databaseService(Ref ref) { return DatabaseService(ref.read(backendClientProvider)); } @Riverpod(keepAlive: true) StorageService storageService(Ref ref) { return StorageService(ref.read(backendClientProvider)); } ``` ## Provider Decision Tree Family providers default to `@riverpod`; avoid `@Riverpod(keepAlive: true)` with unbounded args. Avoid computed → computed chains on nav/offstage paths. Flatten in parent provider: - watch base state directly - derive via pure helpers - avoid provider → provider indirection If needed, use `keepAlive: true` with note: `// keepAlive: Riverpod #4709 workaround`. -
services-and-singletons.md 8.8 KB
# Services, Singletons, Fire-and-Forget ## Read first 1. Singletons and static service facades are allowed only for fire-and-forget infrastructure. 2. Plain singleton = private constructor + one `static final instance` (or private static final + trivial getter) + public methods that return only `void` / `Future<void>`. 3. Plain static facade = `abstract final class` with a tiny `void` / `Future<void>` API over an SDK singleton (`Crash`, analytics/logging wrappers). 4. Do not add backend interfaces, fake implementations, debug injection, service locators, or Riverpod wrappers only to test singleton wiring. 5. If the service returns data, exposes state, owns mutable resources, or needs replacement in tests, it is not a singleton. Use a repository/datasource or Riverpod provider. 6. Fire-and-forget uses `unawaited(foo())`; never `void async` callbacks. The callee catches internally. 7. Riverpod service/repository/datasource/client factories wire stable deps with `ref.read`. Use `ref.watch` only for the provider that intentionally owns reactivity. 8. For Android exact alarms with `flutter_local_notifications`, use `AndroidFlutterLocalNotificationsPlugin.canScheduleExactNotifications()` / `requestExactAlarmsPermission()`. Do not launch `android.settings.REQUEST_SCHEDULE_EXACT_ALARM` manually; the plugin owns the app-specific settings intent and permission re-check. 9. For platform-specific plugin APIs, assign `resolvePlatformSpecificImplementation<T>()` to a local variable or narrow helper getter before use. Explicitly handle `null`; do not chain directly into `?.method()`, `?.property`, or `!.method()`. ## Trigger Signals: abstract final class, singleton, unawaited, fire-and-forget, static facade ## Decision | Need | Use | |---|---| | Pure stateless helper | `abstract final class` static namespace | | Tiny fire-and-forget SDK/service facade | `abstract final class` with direct SDK calls and `void` / `Future<void>` API | | Fire-and-forget app singleton | `final class` + private constructor + `static final instance` + no returned data/state | | Non-fire-and-forget service needing `Ref`, config reactivity, dispose, state, returned data, or test override | `@Riverpod(keepAlive: true)` provider | | Async side effect | `unawaited(foo())` + internal catch | Default: boring code. Do not build indirection before the product needs it. ## 1. Static-only class (namespace or tiny facade) `abstract final class` with only `static` members. ```dart // Pure helper — no I/O, no SDK ref. abstract final class StringCasing { static String snake(String input) => input.trim().toLowerCase().replaceAll(' ', '_'); static String kebab(String input) => input.trim().toLowerCase().replaceAll(' ', '-'); } ``` Tiny infrastructure facades may call SDK singletons directly. Keep the public API small, purpose-specific, and fire-and-forget (`void` / `Future<void>` only). ```dart abstract final class AnalyticsLog { static FirebaseAnalytics get _analytics => .instance; static Future<void> event(String name, {Map<String, Object> params = const {}}) async { try { await _analytics.logEvent( name: name, parameters: params.isEmpty ? null : params, ); } on Exception catch (e, s) { Crash.error(e, s, reason: 'AnalyticsLog.event'); } } } ``` `Crash` exposes only `init`, `error`, and `log`; see [error-reporting.md](error-reporting.md). ### Do not add - `IAnalyticsBackend`, `FirebaseAnalyticsBackend`, `FakeAnalyticsBackend` - `debugUseBackend`, `debugReset`, `setClient`, `setInstance` - `ProviderScope`/Riverpod wrapper only to override the facade in tests - service locator / factory layer - broad public API (`init`, `log`, `error`, `setUser`, `setKey`, `classify`, ...) - manual `AndroidIntent(action: 'android.settings.REQUEST_SCHEDULE_EXACT_ALARM')` flows when `flutter_local_notifications` provides `requestExactAlarmsPermission()` - direct chains from `resolvePlatformSpecificImplementation<T>()` into nullable member calls/properties Lint: `use_local_notifications_exact_alarm_permission_api` flags manual exact-alarm settings intents. `resolve_platform_specific_implementation_before_use` flags direct platform-specific implementation member chains. `prefer-abstract-final-static-class` flags static-only classes missing `abstract final`. `service_static_side_effect` flags static facades that become wide or overbuilt. ### Testing Smoke test only: unsupported platform does not throw, methods return/catch. Do not fake the singleton/facade itself. Feature tests should fake the repository, datasource, or caller-owned boundary instead. ## 2. Singleton One process-wide instance. Use only for fire-and-forget infrastructure where the caller never reads state/data back. Keep the shape boring: ```dart final class PushTokenRefresh { PushTokenRefresh._(); static final PushTokenRefresh instance = ._(); Future<void> refresh() async { try { await FirebaseMessaging.instance.getToken(); } on Exception catch (e, s) { Crash.error(e, s, reason: 'PushTokenRefresh.refresh'); } } } // Call site: unawaited(PushTokenRefresh.instance.refresh()); ``` Allowed alternate shape when a getter reads better: ```dart final class PushTokenRefresh { PushTokenRefresh._(); static final PushTokenRefresh _instance = ._(); static PushTokenRefresh get instance => _instance; Future<void> refresh() async { /* fire-and-forget work */ } } ``` ### Do not add - public constructor plus `instance` - mutable/lazy `_instance` setter - `debugConfigure`, `debugUse`, `setInstance`, `setBackend`, `setClient` - `Fake*Service` solely for singleton tests - provider/service-locator wrapper solely for singleton tests - public getters / state / streams / controllers / caches / queues - public methods that return data (`Future<User>`, `String`, `bool`, etc.) ### Testing Prefer testing callers through a repository/datasource/provider boundary. Tests may assert the fire-and-forget method directly with `expectLater(..., completes)` to verify it does not throw. ```dart void main() { test('refresh does not throw', () async { await expectLater(PushTokenRefresh.instance.refresh(), completes); }); } ``` If a service needs replacement in tests, config changes, lifecycle disposal, user scope, mutable state, or returned data, it is not a plain singleton. Use a Riverpod provider or a repository/datasource boundary instead. Lint: `service_singleton` allows boring fire-and-forget singleton shapes and flags stateful/data-returning/debug/fake/backend singleton seams. ## 3. Fire-and-Forget Future intentionally no `await`. Five rules: 1. Mark `unawaited(foo())` — explicit intent, satisfy `unawaited_futures` + `discarded_futures` lints. 2. `Future<void>` signature, never `void async` (`avoid_void_async`). 3. Catch internally. Uncaught async errors become unhandled runtime/test failures. 4. No ordering dep on other fire-and-forget calls. 5. Never fire-and-forget in tests — leaked future pollutes the next test. ### Canonical shape ```dart Future<void> trackEvent(String name) async { try { await AnalyticsLog.event(name); } on Exception catch (e, s) { Crash.error(e, s, reason: 'Analytics.$name'); } } // Call site: unawaited(trackEvent('sign_in')); ``` ### When to fire-and-forget Analytics, non-fatal `Crash.error`, breadcrumb `Crash.log`, local-first remote mirror sync, perf trace `stop()`, push-token refresh, cache eviction, session heartbeat. ### When NOT to UI await, toast surface, caller reads return value. ### Testing Tests assert the future directly with `expectLater(..., completes)`. Do not assert against a real Firebase backend in unit/widget tests. ```dart await expectLater(trackEvent('sign_in'), completes); ``` ## Checklist - [ ] Singleton has private constructor + one `static final instance` or trivial getter - [ ] Singleton/facade public API returns only `void` / `Future<void>` - [ ] Singleton/facade is fire-and-forget only: no public getters, returned data, or mutable state - [ ] Static facade public API is tiny and purpose-specific - [ ] No backend/fake/debug injection seam added just for tests - [ ] Fire-and-forget singleton/facade is not wrapped in a provider just for testing - [ ] Provider used only for non-fire-and-forget `Ref`, config reactivity, dispose, override, returned data, or UI state - [ ] Fire-and-forget caller uses `unawaited(...)` - [ ] Fire-and-forget callee catches and reports internally - [ ] Android exact-alarm permission uses `flutter_local_notifications` `canScheduleExactNotifications()` / `requestExactAlarmsPermission()`, not a manual `AndroidIntent` settings launch (`use_local_notifications_exact_alarm_permission_api`) - [ ] Platform-specific plugin APIs resolve `resolvePlatformSpecificImplementation<T>()` before use and handle `null` explicitly; no direct `?.method()`, `?.property`, or `!.method()` chain (`resolve_platform_specific_implementation_before_use`) -
setup.md 2.1 KB
# Setup ## Read first 1. Use this only for Flutter or Riverpod package setup, lint wiring, or broken analyzer plugin detection. 2. `flutter_skill_lints` and `riverpod_lint` belong only under top-level `analysis_options.yaml` `plugins:`. 3. Project setup = package-root `dart analyze` proves both lint plugins can fire + [Dart Decimate](dart-decimate.md) full scan passes. ## Trigger Signals: new Flutter app, `analysis_options.yaml`, `pubspec.yaml`, `dart analyze`, missing lint diagnostics. ## Lint wiring Copy [analysis_options.yaml](analysis_options.yaml) to the project root. It wires `flutter_skill_lints: ^0.13.0` and `riverpod_lint: ^3.1.9` under top-level `plugins:`, keeps strict inference enabled, and enforces `no_dynamic_casts` plus `no_raw_types`. Pure-Dart CLI packages use their native Dart analysis profile; do not add either Flutter/Riverpod plugin. Do not add either analyzer plugin to `pubspec.yaml`. Run: ```bash dart pub get dart analyze ``` Then use the [Dart Decimate](dart-decimate.md) project path: an installed Hard Eng project runs `python3 .hooks/hard-eng.py check`; a standalone project with no established check runs the direct native command from its Git root. ## Extension template Copy [templates/flutter/lib/core/extensions/](../templates/flutter/lib/core/extensions/) into `lib/core/extensions/` for every new Flutter app. If the project already has extension files, merge the template instead of overwriting. ## Analyzer sanity checks Temporarily introduce each violation, run package-root `dart analyze`, then restore the file: ```dart // WRONG: sanity-check violation, then restore the file. Widget _buildHeader() => const SizedBox(); ``` Expected lint: `widget_top_level_function_boundary`. ```dart // WRONG: sanity-check violation, then restore the file. ModalRoute.isCurrentOf(context); ``` Expected lint outside `lib/core/extensions/context_extensions.dart`: `use_context_is_current_modal_route`. ## Git pre-push Raw skill installation cannot register runtime hooks or scanners. Read [dart-decimate.md](dart-decimate.md#git-pre-push) → preserve the existing project hook owner and `core.hooksPath`; this skill does not install a hook. -
state-management-lifecycle.md 6.8 KB
# State Management Lifecycle And Errors ## Read first Read with [notifier-structure.md](state-management/notifier-structure.md) for initialization/loading and [async-mutations.md](state-management/async-mutations.md) for mutation readiness, async guards, and optimistic updates. ## State Teardown Belongs in the Notifier For *save and clear* flows, notifier owns save + clear. Failure preserves retry state. Widgets dispatch method, observe state via `ref.watch` / `onMissing*`, and self-navigate. Do not chain widget-side cleanup after awaited notifier mutation. ```dart // NEVER — widget owns teardown Future<bool> save(Entity entity) async { state = state.copyWith(isSaving: true); await ref.read(logProvider.notifier).addLog(...); return true; // caller must reset } // In widget: final ok = await ref.read(formProvider.notifier).save(entity); if (!context.mounted) return; ref.read(formProvider.notifier).reset(); // widget-side teardown ``` ```dart // DO — notifier owns teardown Future<bool> save(Entity entity) async { if (state.isSaving) return false; state = state.copyWith(isSaving: true); try { await ref.read(logProvider.notifier).addLog(buildLog(entity)); if (!ref.mounted) return false; reset(); // single owner of teardown — success path only return true; } catch (e, s) { if (!ref.mounted) return false; Crash.error(e, s); state = state.copyWith( isSaving: false, saveError: AppErrorMapper.from(e), // UI observes and shows feedback ); // preserve, allow retry return false; } } ``` Screen reacts to cleared state and self-navigates via existing `onMissing*` hook. No widget-side `.go(context)` chained off awaited future. See [Modal Snapshot Pattern](common-patterns/modals-navigation.md#modal-snapshot-pattern). ## Exception Ownership Default: catch error once — in notifier. Datasource, repo propagate. ```dart // Datasource — default: propagate Future<List<ProductModel>> fetchAll() => _http.get('/products'); // Repository — default: propagate Future<List<Product>> fetchAll() async { final models = await _remote.fetchAll(); return models.map((m) => m.toEntity()).toList(); } ``` ### Legitimate `try/catch` in data layer Default rule has four narrow exception. Each MUST have reason beyond "log + rethrow": 1. **Domain error translation** — map raw SDK exception to typed domain error so notifier matches on sealed types. 2. **Idempotency recovery** — swallow "already exists" / "not found" on op whose contract is idempotent (e.g. 404 on delete in batch). 3. **Transaction rollback** — catch, run compensating write, rethrow. 4. **Local-first fire-and-forget sync** — remote mirror of local write where caller not await remote. Swallow + log so dead backend no break local path. ❌ WRONG — bare `try { … } catch (e) { log(...); rethrow; }` add nothing. Delete; let notifier catch. ```dart // ❌ pointless Future<void> remove(String id) async { try { await _remote.remove(id); } on Exception catch (e, s) { Crash.error(e, s, reason: 'remove'); rethrow; } } // ✅ propagate Future<void> remove(String id) => _remote.remove(id); ``` ```dart // Notifier — MUST catch here. Translate to typed AppError; never store raw String. Future<void> _load() async { try { final items = await ref.read(productRepositoryProvider).fetchAll(); if (!ref.mounted) return; state = state.copyWith(items: items); } on Exception catch (e, s) { if (!ref.mounted) return; state = state.copyWith(error: AppErrorMapper.from(e)); Crash.error(e, s, reason: 'ProductNotifier._load'); } } ``` ### Domain Error Types **Rule.** `AppError` = **sole** error type in notifier state. Never store `String? error` — pattern-match typed error in UI. Catch in notifier, wrap `AppErrorMapper.from(e)`, then `Crash.error(e, s, reason: …)`. `AppError` stays a pure `@freezed` domain type; the mapper matches `dart:io`/`dart:async` exceptions, so it lives in the data layer. ```dart // core/data/app_error_mapper.dart — exception mapping for notifier wrap abstract final class AppErrorMapper { static AppError from(Object e) => switch (e) { SocketException() || TimeoutException() => .network(e.toString()), FormatException() => .unexpected(e), _ => .unexpected(e), }; } ``` Define sealed error hierarchy for typed error handling in notifier: ```dart // core/domain/app_error.dart @freezed sealed class AppError with _$AppError { const factory AppError.network(String message) = NetworkError; const factory AppError.validation(String field, String message) = ValidationError; const factory AppError.notFound(String resource) = NotFoundError; const factory AppError.unauthorized() = UnauthorizedError; const factory AppError.unexpected(Object error) = UnexpectedError; } ``` Use in notifier state, pattern-match in UI: ```dart // State holds typed error instead of raw string @freezed sealed class ProductState with _$ProductState { const factory ProductState({ @Default([]) List<Product> items, @Default(false) bool isLoading, AppError? error, }) = _ProductState; } // UI pattern-matches for user-friendly display if (state.error case NetworkError(:final message)) ErrorBanner(message: message, onRetry: () => unawaited(ref.read(productProvider.notifier).refresh())) else if (state.error case NotFoundError(:final resource)) Text(l10n.resourceNotFound(resource)) ``` ## Cross-Provider Communication Read other provider via `ref`: ```dart @Riverpod(keepAlive: true) class OrderNotifier extends _$OrderNotifier { @override OrderState build() => const OrderState(); Future<void> placeOrder() async { final cart = ref.read(cartProvider); final user = ref.read(authProvider); if (user case Authenticated(:final user)) { await ref.read(orderRepositoryProvider).create( userId: user.id, items: cart.items, ); if (!ref.mounted) return; // Reset cart after order ref.read(cartProvider.notifier).clear(); } } } ``` ## Pause, projection, and mode boundaries - Durable work = root/bootstrap provider owner, not a route that may pause or leave the tree. - Pause-sensitive projection = watch the base provider directly and select the needed field there. - Computed provider → computed provider chains require proof that pause/resume cannot miss the first update; otherwise flatten the projection. - Listener startup = listener/watch exists before the idempotent operation starts. - One-shot state = event identity/sequence + acknowledgement; resume must not drop or replay it. - Login/signup/reset or form-mode switch = clear mode-owned validation/server error + pending flag before showing the new mode. - Persistent external failure crosses modes only when product behavior explicitly requires it. - Tests = covered route at startup + first update while paused + resume + mode switch after failure. -
testing.md 16.3 KB
# Testing ## Read first 1. Mock interfaces, never concrete implementations. 2. Unit tests use `ProviderContainer.test()`; widget tests use `UncontrolledProviderScope`. 3. Override repo/datasource providers, not notifiers directly. 4. Prefer explicit `pump()`; `pumpAndSettle` only for finite anim/async. 5. Selectors use central deterministic `AppWidgetKeys`; no inline keys, `tapAt`, first icon, case-sensitive labels. 6. Add contract drift tests for copied constants/schema/field IDs across runtimes. 7. Treat clean-install and preserved-restart tests as separate cases, with the state recorded in the test receipt. ## Trigger Signals: ProviderContainer.test, UncontrolledProviderScope, mocktail, widget tests, event contract ## Rules — NEVER Violate 1. **MUST** mock interfaces (`IProductRepository`), NEVER concrete (`ProductRepository`). 2. **MUST** use `ProviderContainer.test()` — NEVER manual `createContainer`. 3. **MUST** use `UncontrolledProviderScope` widget tests — NEVER raw `ProviderScope` w/ overrides. 4. **MUST** prefer explicit `pump()`. `pumpAndSettle` only finite anim/async; bound it with the positional timeout (`pumpAndSettle(const Duration(milliseconds: 100), .sendSemanticsUpdate, const Duration(seconds: 5))`); avoid infinite/ticking. 5. **MUST** override repo/datasource level — NEVER mock notifiers direct. 6. **MUST** use deterministic `ValueKey` selectors from a central key registry for repeated icons, draggable sheets, close/open actions. NEVER use inline string keys, `tapAt(...)`, first-match icon finders, or case-sensitive label text. 7. **MUST** add event-contract tests for streams/realtime/push/sync/shared remote state: exact subscriptions/listeners, every event family, notifier reaction, stale-source refresh, and removal/delete behavior. 8. **MUST** keep shared fakes, mocks, provider-container factories, platform stubs, and async wait helpers in a test helper SSOT. 9. **MUST** add contract drift tests when constants/schema/field IDs are copied across Flutter/backend/functions/native runtimes. 10. **MUST** regression-test pause-sensitive provider startup/projections, transient mode-error clearing, and native-link contracts when those paths exist. 11. **MUST** wait for the old modal key to be absent before reusing that key for a new modal. 12. **MUST** test native/plugin boundaries with the real platform wrapper when the behavior depends on a file picker, permission prompt, keyboard, share sheet, or platform channel. A widget mock proves only the Flutter side. ## Setup ```yaml # pubspec.yaml dev_dependencies: flutter_test: sdk: flutter mocktail: ^1.0.5 build_runner: <version> ``` Resolve `<version>` from [core-stack.md](core-stack.md); do not duplicate its pin here. ## Mock Declaration No codegen. Declare mocks file top: ```dart import 'package:mocktail/mocktail.dart'; class MockIProductRepository extends Mock implements IProductRepository {} class MockIAuthRepository extends Mock implements IAuthRepository {} ``` Non-nullable arg matchers → register fallback once `setUpAll`: ```dart setUpAll(() => registerFallbackValue(const Product(id: '', name: '', price: 0))); ``` **Fake vs Mock** — Mocks (Mocktail) for interaction verify (`verify`, `when`). Fakes (manual subclass) for working impls w/ controlled behavior: ```dart // Fake: real behavior, controlled output class FakeProductRepository extends Fake implements IProductRepository { List<Product> items = []; @override Future<List<Product>> fetchAll() async => items; } // Mock: stub + verify with closure syntax final mock = MockIProductRepository(); when(() => mock.fetchAll()).thenAnswer((_) async => [product]); verify(() => mock.fetchAll()).called(1); ``` ## ProviderContainer.test Auto-dispose each test: ```dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test('fetches products on init', () async { final mockRepo = MockIProductRepository(); when(() => mockRepo.fetchAll()).thenAnswer((_) async => [ const Product(id: '1', name: 'Widget', price: 9.99), ]); final container = ProviderContainer.test( overrides: [ productRepositoryProvider.overrideWithValue(mockRepo), ], ); // Trigger build() and flush the deferred Future.microtask(_load) call. // `.future` only exists on AsyncNotifier — for sync Notifier with // microtask-deferred load, pump the microtask queue instead. container.read(productProvider); await Future<void>.microtask(() {}); final state = container.read(productProvider); expect(state.items, hasLength(1)); expect(state.items.first.name, 'Widget'); verify(() => mockRepo.fetchAll()).called(1); }); } ``` ## Test Helper SSOT Prefer one shared helper module: ```text test/helpers/test_fakes.dart ``` It owns: - `createTestContainer(...)` - fake services and repositories - mock classes for interfaces - mocktail fallback registration - platform stubs - async provider wait helpers - local database setup/teardown helpers Do not redefine common fakes in every test file. Feature-specific fakes may live next to that feature only when they are not useful elsewhere. ## Cross-Runtime Contract Drift Tests If Flutter shares constants with another runtime, test the contract: - table/collection/bucket/function IDs - field names and relationship names - enum/string wire values - manifest/schema/index requirements - copied shared source files - platform channel method names - deep-link path contracts Generic pattern: ```dart test('app and backend table ids stay in sync', () { expect(AppTableIds.workouts, BackendTableIds.workouts); expect(AppFields.userId, BackendFields.userId); }); ``` Keep backend-vendor details in that backend skill. The Flutter rule is: copied runtime contracts need drift tests. ## overrideWithBuild Mock `build()` only, keep notifier methods intact: ```dart test('increment works with custom initial state', () { final container = ProviderContainer.test( overrides: [ counterProvider.overrideWithBuild((_, _) => 42), ], ); expect(container.read(counterProvider), 42); // Original increment method still works container.read(counterProvider.notifier).increment(); expect(container.read(counterProvider), 43); }); ``` ## overrideWithValue for Async Providers ```dart test('handles pre-loaded async data', () { final container = ProviderContainer.test( overrides: [ userProvider.overrideWithValue( .data(const User(id: '1', name: 'Test')), ), ], ); final user = container.read(userProvider); expect(user.value?.name, 'Test'); }); ``` ## Widget Tests `UncontrolledProviderScope` inject container: ```dart testWidgets('shows product list', (tester) async { final mockRepo = MockIProductRepository(); when(() => mockRepo.fetchAll()).thenAnswer((_) async => [ const Product(id: '1', name: 'Widget', price: 9.99), const Product(id: '2', name: 'Gadget', price: 19.99), ]); final container = ProviderContainer.test( overrides: [ productRepositoryProvider.overrideWithValue(mockRepo), ], ); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: const MaterialApp(home: ProductListScreen()), ), ); // Prefer explicit frames: trigger + advance through animation. // For route transitions, avoid hardcoded durations when possible. await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('Widget'), findsOneWidget); expect(find.text('Gadget'), findsOneWidget); }); ``` ## Widget Key Registry Default file: `lib/core/testing/app_widget_keys.dart`. Use existing project equivalent if present. ```dart abstract final class AppWidgetKeys { static const productCloseButton = 'product.close.button'; static const productSaveButton = 'product.save.button'; } ``` Widgets: ```dart final l10n = context.l10n; IconButton( key: const ValueKey(AppWidgetKeys.productCloseButton), tooltip: l10n.closeProductTooltip, onPressed: onClose, icon: const Icon(Icons.close), ) ``` Tests/E2E: ```dart await tester.tap(find.byKey(const ValueKey(AppWidgetKeys.productCloseButton))); ``` Rules: - One registry file per app unless the project already has a namespaced equivalent. - Prefer feature-prefixed names: `profile.avatar.edit`, `checkout.payment.submit`. - No inline `ValueKey('...')` in widgets or tests. - Add keys only to real interaction/inspection targets, not every widget. - Storage keys and API paths use the sibling registries `StorageKeys` / `ApiPaths` in `lib/core/constants/` ([architecture.md](architecture.md#key-registries)). ## WidgetTester.container Access `ProviderContainer` from widget tests: ```dart testWidgets('can access container', (tester) async { final container = ProviderContainer.test(); await tester.pumpWidget( UncontrolledProviderScope( container: container, child: const MaterialApp(home: MyWidget()), ), ); expect(tester.container(), same(container)); expect(container.read(myProvider), someValue); }); ``` ## Testing Notifier Methods ```dart test('deleteItem removes from state', () async { final mockRepo = MockIProductRepository(); when(() => mockRepo.fetchAll()).thenAnswer((_) async => [ const Product(id: '1', name: 'A', price: 10), const Product(id: '2', name: 'B', price: 20), ]); when(() => mockRepo.delete(any())).thenAnswer((_) async {}); final container = ProviderContainer.test( overrides: [ productRepositoryProvider.overrideWithValue(mockRepo), ], ); // Wait for initial load (sync Notifier with deferred microtask load) container.read(productProvider); await Future<void>.microtask(() {}); // Delete and verify await container.read(productProvider.notifier).deleteItem('1'); final state = container.read(productProvider); expect(state.items, hasLength(1)); expect(state.items.first.id, '2'); }); ``` ## Event Contract and Sync Tests Any stream, realtime, push, subscription, callback, poller, cache invalidation, or source-of-truth refresh path needs tests at two levels: 1. Datasource/service contract test: proves the exact channel/topic/query/filter/listener set is registered. 2. Notifier/widget reaction test: emits representative events and proves state updates, refetches, or clears correctly. Do not test only the happy create event. Cover the event families the product depends on: - create/add/join - update/rename/status/order - delete/remove/leave/revoke - generated/regenerated values - permission/ownership changes - stale, partial, duplicate, out-of-order, and unrelated events Minimum contract: ```dart test('subscribes to every event family needed for item sync', () async { final source = FakeRemoteEventSource(); final datasource = ProductRemoteDatasource(source); await datasource.watchProducts(ownerId: 'owner-1').first; expect(source.subscriptions, contains('products.owner-1.create')); expect(source.subscriptions, contains('products.owner-1.update')); expect(source.subscriptions, contains('products.owner-1.delete')); }); ``` Minimum notifier reaction: ```dart test('refetches source of truth after remote update event', () async { final repo = FakeProductRepository() ..items = [const Product(id: 'p1', name: 'Old')]; final events = FakeProductEvents(); final container = ProviderContainer.test( overrides: [ productRepositoryProvider.overrideWithValue(repo), productEventsProvider.overrideWithValue(events), ], ); container.read(productProvider); await Future<void>.microtask(() {}); repo.items = [const Product(id: 'p1', name: 'New')]; events.emit(const .updated(id: 'p1')); await Future<void>.microtask(() {}); expect(container.read(productProvider).items.single.name, 'New'); }); ``` Generated values and read-your-writes: - If create/update/delete can return stale, partial, or derived values, assert the notifier refreshes from the source of truth before success UI/navigation. - If a code/token/link/slug/order/index is generated remotely, mutate it in the fake source first, then assert UI/notifier state eventually shows that exact generated value. - If a selected item is deleted or the actor loses access, assert selected state clears and the list/detail route falls back without throwing. ## Lifecycle regression matrix | Risk | Required red-capable proof | |---|---| | Async startup before listener ownership | Register durable owner/listener, start once, and assert the first result appears once | | Route covered or provider listener paused | Emit the first update while covered, resume, and assert current state/pending event is not lost | | Computed-provider chain | Compare direct base-provider projection and fail if pause/resume misses the first update | | Login/signup/reset mode change | Seed a server/validation error, switch mode, and assert error + pending state clear | | Native/custom link drift | Build URI from producer contract and parse through the Flutter typed-route ingress for Android + iOS fixtures | | E2E harness false positive | Unknown scenario fails; critical log fails; screenshot is taken only after asserted target state | | Modal key reuse | Close the old modal, wait for its key to be absent, open the new modal, and assert the new route state | | Native prompt boundary | Complete the platform prompt and assert the returned app state, not only a widget-tree change | ## Testing Repository Layer ```dart test('fetchAll returns entities from remote', () async { final mockRemote = MockIProductRemoteDatasource(); final mockLocal = MockIProductLocalDatasource(); when(() => mockRemote.fetchAll()).thenAnswer((_) async => [ const ProductModel(id: '1', name: 'Test', price: 9.99), ]); final repo = ProductRepository(mockRemote, mockLocal); final result = await repo.fetchAll(); expect(result, hasLength(1)); expect(result.first.name, 'Test'); expect(result.first, isA<Product>()); // Entity, not Model verify(() => mockRemote.fetchAll()).called(1); }); test('falls back to cache on error', () async { final mockRemote = MockIProductRemoteDatasource(); final mockLocal = MockIProductLocalDatasource(); when(() => mockRemote.fetchAll()).thenThrow(Exception('Network error')); when(() => mockLocal.getAll()).thenAnswer((_) async => [ const ProductModel(id: '1', name: 'Cached', price: 5.00), ]); final repo = ProductRepository(mockRemote, mockLocal); final result = await repo.fetchAll(); expect(result.first.name, 'Cached'); verify(() => mockLocal.getAll()).called(1); }); ``` ## Testing Union States ```dart test('auth state transitions', () async { final mockAuth = MockIAuthRepository(); when(() => mockAuth.getSession()).thenAnswer( (_) async => const User(id: '1', name: 'Test'), ); final container = ProviderContainer.test( overrides: [ authRepositoryProvider.overrideWithValue(mockAuth), ], ); // Initial state is loading final initial = container.read(authProvider); expect(initial, isA<AuthLoading>()); // Wait for session check await Future<void>.microtask(() {}); final state = container.read(authProvider); expect(state, isA<Authenticated>()); // Pattern match to verify user if (state case Authenticated(:final user)) { expect(user.name, 'Test'); } }); ``` ## Common Pitfalls | Issue | Fix | |-------|-----| | `pumpAndSettle` hangs | Explicit `pump()` + bounded `pump(Duration(...))`; `pumpAndSettle` with positional timeout (rule 4) finite anim only | | State not updated after async | `await provider.future` (AsyncValue) or `await Future.microtask(() {})` sealed-state | | Provider not found | Wrap `UncontrolledProviderScope` | | Mock not applied | Verify override matches provider type | | Container disposed early | `ProviderContainer.test()` — auto-manages | | Inline `ValueKey('close')` strings drift from E2E | Put key strings in `AppWidgetKeys`, use constants in widgets/tests | | Realtime join/create not observed | Contract-test exact event families plus notifier reaction test for emitted event | | Delete/remove leaves stale detail UI | Emit delete/remove event and assert selected state clears or route fallback appears | | Generated code/token stale after mutation | Fake source generates new value; notifier must refetch and expose source-of-truth value | | Event test passes but real app does not sync | Add writer/observer Dart MCP E2E from [dart-mcp-e2e-testing.md](dart-mcp-e2e-testing.md) | -
value-objects.md 12.5 KB
# Value Objects ## Read first 1. Domain-meaning primitives (unit/currency/identity/format) become sealed Freezed VOs in `/domain/values/`. 2. Public factories validate; raw redirects stay private (`._raw`, `._meters`). No passthrough factories. 3. Domain entities do not expose named primitive factories; convert at data/notifier/import boundaries. 4. Hive/data models keep primitives; mappers bridge model primitives ↔ domain VOs. 5. Use VO when concept spans 2+ entities; one-off derivation can be an entity getter. 6. Required domain strings are non-empty VOs. Optional strings are `String?` and blank input is normalized to `null` before domain construction. ## Trigger Signals: `Distance`, `Money`, `Email`, `Username`, `Slug`, `PhoneNumber`, `HeartRate`, `Weight`, `Pace`, unit conversion in domain, currency math in domain, bare `double distanceMeters` / `int amountCents` / `String email` at entity boundary, `arch_domain_import` fighting `core/extensions/` import. ## Decision | Scope | Use | |---|---| | 1 entity, 1 derivation | Entity getter | | 2+ entities share primitive concept | Value Object in `/domain/values/` | | Widget/notifier/repo-only helper | `core/extensions/` | Domain never imports `core/extensions/`; `arch_domain_import` = ERROR. ## Where - Feature: `lib/features/<x>/domain/values/<name>.dart` - Shared: `lib/core/domain/values/<name>.dart` Both match `/domain/` → both allowed. ## Nullability + Empty Text `null` means absence. `''` means a present empty string. Do not use `''` as a missing-value sentinel in domain code. | Situation | Use | |---|---| | Required ID / slug / email / display name | VO factory that trims and rejects blank | | Optional note / bio / description | `String?`, with blank normalized to `null` at data/notifier/import boundary | | Search query / form draft | non-domain state field named `query`, `searchQuery`, `draftName`, or `inputText` | | No items | non-null collection default `[]` / `{}` | Boundary normalization: ```dart String? optionalTextFromInput(String input) { final trimmed = input.trim(); return trimmed.isEmpty ? null : trimmed; } ``` ## Distance ```dart // lib/core/domain/values/distance.dart import 'package:freezed_annotation/freezed_annotation.dart'; part 'distance.freezed.dart'; @Freezed(map: .none, when: .none) sealed class Distance with _$Distance { const Distance._(); const factory Distance._meters(double value) = _Meters; const factory Distance._kilometers(double value) = _Kilometers; const factory Distance._miles(double value) = _Miles; factory Distance.fromMeters(double m) { if (m.isNaN || !m.isFinite || m < 0) { throw ArgumentError.value(m, 'm', 'Distance must be finite and non-negative'); } return Distance._meters(m); } double get inMeters => switch (this) { _Meters(:final value) => value, _Kilometers(:final value) => value * 1000, _Miles(:final value) => value * 1609.344, }; double get inKilometers => inMeters / 1000; double get inMiles => inMeters / 1609.344; } ``` Entity: ```dart @freezed sealed class WorkoutSet with _$WorkoutSet { const factory WorkoutSet({required Distance distance, required Duration duration}) = _WorkoutSet; const WorkoutSet._(); double get paceSecondsPerKm => duration.inSeconds / distance.inKilometers; double get speedKmh => distance.inKilometers / (duration.inSeconds / 3600); } ``` ## Money ```dart enum Currency { usd, eur, gbp, sar } @Freezed(map: .none, when: .none) sealed class Money with _$Money { const Money._(); const factory Money({required int cents, required Currency currency}) = _Money; factory Money.usd(double dollars) => Money(cents: (dollars * 100).round(), currency: .usd); double get asDouble => cents / 100; bool get isPositive => cents > 0; Money operator +(Money other) { assert(currency == other.currency); return Money(cents: cents + other.cents, currency: currency); } } ``` Display = widget calls extension on the unwrapped value: ```dart Text(order.total.asDouble.asCurrency(symbol: '\$')) ``` ## Email (identity) ```dart @Freezed(copyWith: false, map: .none, when: .none) sealed class Email with _$Email { const Email._(); const factory Email._raw(String value) = _Email; factory Email(String input) { final t = input.trim().toLowerCase(); if (!_pattern.hasMatch(t)) throw const FormatException('Invalid email'); return Email._raw(t); } static final _pattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); } ``` `User({required Email email})` — invalid string impossible. `copyWith: false` is required when the validating factory is unnamed: Freezed 4 cannot clone its `input` parameter. ## Non-empty text ```dart @Freezed(copyWith: false, map: .none, when: .none) sealed class DisplayName with _$DisplayName { const DisplayName._(); const factory DisplayName._raw(String value) = _DisplayName; factory DisplayName(String input) { final trimmed = input.trim(); if (trimmed.isEmpty) { throw ArgumentError.value(input, 'input', 'DisplayName cannot be blank'); } return DisplayName._raw(trimmed); } String get value => switch (this) { _DisplayName(:final value) => value, }; } ``` IDs use the same shape (`UserId`, `OrderId`): validated factory + `value` getter + `copyWith: false`. No `@Default('') String name` in domain entities. Required text uses a VO; optional text uses `String?`. Lints: `domain_raw_required_string` (required `String` on a domain entity constructor), `domain_unit_primitive` (unit/currency-named numbers such as `sizeBytes`, `weightKg`, `price`). ## Decision matrix | Situation | Use | |---|---| | `m / 1000` once in 1 entity | Entity getter | | `m → km` in 3 entities | `Distance` VO | | `cents / 100` in widget | `cents.asCurrency()` extension | | `cents + cents` math in domain | `Money` VO with `operator +` | | Email validated at form | `Validators.email` | | Email enforced via type | `Email` VO | | Date format in widget | `date.formatted()` extension | | Date diff in domain | built-in `Duration` (it IS a VO) | ## Forbidden ```dart // ❌ extension import in domain import 'package:myapp/core/extensions/num_extensions.dart'; // arch_domain_import ERROR // ❌ primitive obsession @freezed sealed class Order with _$Order { const factory Order({ required int totalCents, required String customerEmail, required double weightKg, }) = _Order; } // ✅ VO boundary @freezed sealed class Order with _$Order { const factory Order({ required Money total, required Email customerEmail, required Weight weight, }) = _Order; } // ❌ public raw VO constructor — caller skips invariants (vo_public_raw_constructor) @Freezed(map: .none, when: .none) sealed class Distance with _$Distance { const Distance._(); const factory Distance.meters(double value) = _Meters; } // ❌ passthrough factory — looks compliant, still skips validation (vo_public_raw_constructor) @Freezed(map: .none, when: .none) sealed class Distance with _$Distance { const Distance._(); const factory Distance._meters(double value) = _Meters; factory Distance.meters(double value) => Distance._meters(value); // zero-touch forward } // ✅ private raw redirect + public factory with EXPLICIT guards in body @Freezed(map: .none, when: .none) sealed class Distance with _$Distance { const Distance._(); const factory Distance._meters(double value) = _Meters; factory Distance.fromMeters(double m) { if (m.isNaN) throw ArgumentError.value(m, 'm', 'Distance cannot be NaN'); if (!m.isFinite) throw ArgumentError.value(m, 'm', 'Distance must be finite'); if (m < 0) throw ArgumentError.value(m, 'm', 'Distance cannot be negative'); return Distance._meters(m); } } // ✅ extracted guard helper — still validates, lint passes (body is function call, not bare arg) @Freezed(map: .none, when: .none) sealed class Distance with _$Distance { const Distance._(); const factory Distance._meters(double value) = _Meters; factory Distance.meters(double v) => Distance._meters(_guard(v, 'meters')); static double _guard(double v, String unit) { if (v.isNaN || !v.isFinite || v < 0) { throw ArgumentError.value(v, 'v', 'Distance.$unit must be finite and non-negative'); } return v; } } // ❌ named primitive factory on domain entity — boundary in wrong layer (domain_entity_primitive_factory) // (entity, not VO — bare `@freezed` is fine here; opt-out only required in /domain/values/) @freezed sealed class User with _$User { const factory User({required Email email}) = _User; factory User.fromPrimitives(String emailString) => User(email: Email(emailString)); } // ✅ convert primitives at data/notifier/import boundary; entity accepts VOs only @freezed sealed class User with _$User { const factory User({required Email email}) = _User; } // inside UserModel.toEntity() or UserImportService — outside /domain/ — // wrap the raw email string in an Email value object, then build the User. // ❌ hand-rolled copyWith in /domain/ (domain_custom_copy_with) @freezed sealed class User with _$User { const User._(); const factory User({required UserId id, required Email email}) = _User; User copyWith({UserId? id, Email? email}) => User(id: id ?? this.id, email: email ?? this.email); } // ✅ let Freezed generate copyWith from the redirect — change the constructor if the API is wrong @freezed sealed class User with _$User { const User._(); const factory User({required UserId id, required Email email}) = _User; } ``` ### Hive collision Disk sacred. `hive_ce_generator` writes `HiveField(N)` indices to `hive_adapters.g.yaml` (committed) from Freezed ctor param order on first run. Wrapping a primitive in a VO on a `@GenerateAdapters`-registered class regenerates that yaml against the new shape — different binary layout from the one on user disks. `dart analyze` blind. Per the [hive_ce docs](https://github.com/IO-Design-Team/hive_ce_docs/blob/master/custom-objects/generate_adapters.md): *"Changing the type of a field is not supported. You should create a new one instead."* **Option A — entity stays primitive, VO via getter.** Use when entity shipped w/ user data. Its `/// HiveField(N)` markers keep the locked slots out of `domain_raw_required_string` / `domain_unit_primitive`. ```dart @freezed sealed class WorkoutSet with _$WorkoutSet { const WorkoutSet._(); const factory WorkoutSet({ /// HiveField(0) required String id, /// HiveField(1) required double distanceMeters, // locked /// HiveField(2) required int durationSeconds, // locked }) = _WorkoutSet; Distance get distance => .fromMeters(distanceMeters); Duration get duration => Duration(seconds: durationSeconds); } ``` **Option B — separate Model (Hive) + Entity (VOs) + mapper.** Use for new entities. ```dart // /data/models/workout_set_model.dart @freezed sealed class WorkoutSetModel with _$WorkoutSetModel { const factory WorkoutSetModel({ /// HiveField(0) required String id, /// HiveField(1) required double distanceMeters, /// HiveField(2) required int durationSeconds, }) = _WorkoutSetModel; } @GenerateAdapters([AdapterSpec<WorkoutSetModel>()], firstTypeId: 1) void _h() {} // /domain/entities/workout_set.dart @freezed sealed class WorkoutSet with _$WorkoutSet { const factory WorkoutSet({required WorkoutSetId id, required Distance distance, required Duration duration}) = _WorkoutSet; } // /data/mappers/workout_set_mapper.dart extension WorkoutSetMapper on WorkoutSetModel { WorkoutSet toEntity() => WorkoutSet(id: WorkoutSetId(id), distance: .fromMeters(distanceMeters), duration: Duration(seconds: durationSeconds)); } ``` Forbidden either option: reorder ctor params on `@GenerateAdapters` class, renumber/reuse `HiveField(N)`, reuse retired `typeId`. See [hive-persistence.md](hive-persistence.md). Lints: `hive_field_no_vo_type` (no VO types on Model ctor params). ## Test ```dart group('Distance', () { test('rejects negative', () => expect(() => Distance.fromMeters(-1), throwsA(isA<ArgumentError>()))); test('m → km', () => expect(Distance.fromMeters(1500).inKilometers, equals(1.5))); test('m → miles', () => expect(Distance.fromMeters(1609.344).inMiles, closeTo(1, 1e-9))); }); ``` ## Related - Rule 11: extensions outer only. Domain blocked. - Rule 12: this. VO in `/domain/`. - Rule 7: multi-unit VO = sealed Freezed. Match via native `switch`. - Lint `arch_domain_import`: VOs in `/domain/` import freely. ## When NOT to VO - No domain meaning (counters, UI flags) - Form-boundary only (use `Validators`) - Already a VO: `Duration`, `DateTime`, `Uri` Over-VO = own anti-pattern. Apply when invariants exist OR primitive shared 2+ entities. -
widget-previews.md 4.6 KB
# Widget Previews ## Read first 1. Import `package:flutter/widget_previews.dart` only in preview files/preview blocks. 2. Wrap preview targets in app shell/theme/localization (`AppPreviewShell` or project equivalent). 3. Override repos/datasources/auth/config/clock with fakes. No real backends. 4. No native plugins, platform channels, `dart:io`, Firebase, Hive boxes, or real HTTP. 5. Use deterministic small data and central `AppWidgetKeys`; preview surfaces, not full runtime screens. ## Trigger Signals: @Preview, AppPreviewShell, widget_previews, provider overrides, preview fakes ## Rules 1. **MUST** import `package:flutter/widget_previews.dart` only in preview files or preview-only blocks. 2. **MUST** wrap preview targets in the app theme/shell used by production widgets. 3. **MUST** override Riverpod providers with fakes for repository, datasource, auth, config, and clock dependencies. 4. **MUST NOT** call native plugins, `dart:io`, platform channels, Firebase, Hive boxes, or real HTTP from previews. 5. **MUST NOT** add `@Preview` to stateful app screens that require full runtime boot. Create a small preview surface instead. 6. **MUST** keep preview data deterministic and small. 7. **MUST** preserve the central key registry rule. Use `ValueKey(AppWidgetKeys.someAction)`, never inline string keys. ## File Placement Prefer one preview file next to the widget: ```text features/products/presentation/widgets/ product_card.dart product_card_preview.dart ``` If the project already has a preview convention, follow it. ## Preview Shell Create one app-owned shell so every preview gets theme, localization, text scale, and provider overrides consistently. ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart' show Override; class AppPreviewShell extends StatelessWidget { const AppPreviewShell({ super.key, required this.child, this.overrides = const [], }); final Widget child; final List<Override> overrides; @override Widget build(BuildContext context) { return ProviderScope( overrides: overrides, child: MaterialApp( theme: buildAppTheme(), home: Scaffold(body: SafeArea(child: child)), ), ); } } ``` ## Riverpod Preview Pattern Keep the widget itself production-real. Override only dependencies. ```dart import 'package:flutter/material.dart'; import 'package:flutter/widget_previews.dart'; @Preview(name: 'Product card - in stock') Widget productCardInStockPreview() { return AppPreviewShell( overrides: [ productRepositoryProvider.overrideWithValue( FakeProductRepository( products: const [ Product(id: 'preview-1', name: 'Suture Kit', price: 24), ], ), ), ], child: const ProductCard(productId: 'preview-1'), ); } ``` ## Preview Fakes Use simple fakes that implement interfaces. Do not mock notifiers directly. ```dart class FakeProductRepository implements IProductRepository { const FakeProductRepository({required this.products}); final List<Product> products; @override Future<List<Product>> fetchAll() async => products; @override Future<Product> fetchById(String id) async { final product = products.lookupByKey(id, (product) => product.id); if (product == null) { return Future<Product>.error(StateError('Unknown preview product $id')); } return product; } } ``` ## Preview Matrix For reusable widgets, add enough previews to catch real UI states: | State | Required preview | |---|---| | Empty/null | Empty state surface | | Loading | Skeleton/spinner state if visible | | Data | Typical content | | Long text | Longest likely localized string | | Error | Non-sensitive failure message | | Theme | Light and dark when supported | | Width | Compact and expanded when layout changes | ## Limitations - Previewer runs in a web-like environment. Native plugins and file/database APIs can fail. - Previewer is visual feedback. Keep widget/unit/E2E tests for behavior. - If preview setup needs many overrides, the widget is likely too coupled. Move platform or data work behind interfaces. ## Checklist - [ ] Preview target imports `package:flutter/widget_previews.dart`. - [ ] Preview target uses `AppPreviewShell` or the project equivalent. - [ ] Repositories/datasources/services are faked through provider overrides. - [ ] No native plugin, Hive, Firebase, platform channel, or real HTTP call runs in preview. - [ ] Long text, empty, error, and compact/expanded states are covered when applicable. - [ ] No inline string `ValueKey`s were added. -
windows-installer-pipeline.md 40.4 KB
# Windows Installer Pipeline ## Read first 1. Scope = Flutter Windows EXE + Inno Setup/`inno_bundle` + updater + GitHub Actions delivery. 2. Current contract = installed package/tool/action + primary docs/changelog + resolved runner paths; memory + cached/local success = no proof. 3. Entry = secret-free manual Windows diagnostic for one exact SHA; publish only that proven SHA with one release actor. 4. Copy scaffold = [windows-installer-workflow.yml](../assets/windows-installer-workflow.yml) + [`inno_bundle` pubspec fragment](../assets/inno-bundle-pubspec.yaml) + [Inno settlement sentinel](../assets/inno-uninstall-settlement-sentinel.ps1) + [Defender scanner](../assets/defender-installer-scan.ps1); replace repository-owned commands + audit every action/tool pin before first run. 5. Provider boundary = artifact store/index/pointer are interfaces; keep provider names, endpoints, project IDs, PII, and credentials outside this skill/package. 6. Audited scaffold baseline (2026-08-01) = `inno_bundle 0.11.2` + Inno Setup `7.0.2` + `actions/checkout 7.0.1` + `actions/upload-artifact 7.0.1` + `actions/download-artifact 8.0.1`; re-resolve primary releases before first dispatch and update pins + contracts together when newer. ## Contents - [Flow](#flow) - [Cross-app adaptation](#cross-app-adaptation) - [Cost-aware proof ladder](#cost-aware-proof-ladder) - [Clean runner](#clean-runner) - [Windows native bundle](#windows-native-bundle) - [`inno_bundle` setup](#inno_bundle-setup) - [Inno ownership](#inno-ownership) - [Installer identity](#installer-identity) - [Install lifecycle](#install-lifecycle) - [Bounded processes](#bounded-processes) - [Updater behavior](#updater-behavior) - [Security](#security) - [Publication](#publication) - [Proof](#proof) - [Sources](#sources) ## Flow 1. Research = resolve Flutter/Dart/Node/actions/Inno/`inno_bundle` versions + official source + hashes/signatures where supplied. 2. Contract = inventory last green step order + generated outputs + runtime DLLs + installer identity + data-preservation policy + publication interfaces. 3. Cheap proof = local portable syntax/unit/static/crypto tests + one Linux provider preflight. 4. Diagnostic = exact SHA + same repository-owned Windows orchestration on a clean native x64 Windows VM or repository-scoped ephemeral/JIT x64 runner + no publisher secrets/writes. 5. Diagnostic artifact = upload installer + machine receipt only after every check passes; short retention; publication = `none`. 6. Publisher = one GitHub-hosted Windows run for diagnostic-proven SHA; quality/preparation may parallelize, native/external mutations remain sequential. 7. Delivery = immutable installer/manifest upload → independent download/readback → pointer/index update last → final remote receipt. - Dispatch selector = branch/tag containing the workflow; exact 40-character candidate SHA = separate input. - Dispatch input validation = a no-checkout job receives raw values only through environment variables, then rejects everything outside the typed grammar before any checkout-local action or release script: `mode` is `verify-windows | publish`; `revision` is exactly 40 lowercase hexadecimal characters and equals `github.sha`; `version` is `MAJOR.MINOR.PATCH` with each component `0` or a non-zero decimal integer of at most nine digits; `diagnostic_run_id` is empty for verification or 1-20 decimal digits for publication. - Release command boundary = validated outputs enter Bash and PowerShell through environment variables; no workflow-dispatch input is interpolated into command source. Artifact names and paths use only the validated version or provider run ID. - Dispatch admission = event SHA equals candidate SHA + remote tip/tag readback equals candidate SHA before any paid/native/external phase. - `gh workflow run --ref <sha>` = `FAIL`; workflow-dispatch `ref` selects a branch/tag, while `actions/checkout` `ref` may consume the admitted SHA. - YAML surface = minimum runner + permission + cache + handoff + artifact + external-write boundaries. - Ordered internal phases = repository-owned orchestration script + phase receipts; avoid one YAML step per command. - Compacting = move calls, never remove guards; contract-test each required guard call + its order before the single expensive build. - Shared YAML = boundary topology only; orchestration branches own same-job codegen vs verified transfer + first-release vs upgrade lifecycle. - Branch contract = every accepted internal branch retains common syntax/timeout/CRT/Inno guards + one native build + identity/security/lifecycle proof + publication ordering. - Cross-app engine = one semantic release stage DAG; app identity + provider values = validated typed configuration/adapters. - Second-app admission = stage-by-stage parity map against one proven reference + real execution of every adapted branch before publish. - Copy-pasted app-specific release engine + foreign project/object IDs = `FAIL`; justified schema differences stay explicit at typed adapter boundaries. - Publisher minimum = read-only Linux admission + one GitHub-hosted Windows build/publisher; target-native diagnostic runs locally or on repository-scoped ephemeral/JIT self-hosted Windows. - Duplicate Windows build, repeated tool setup, and visible step count without a permission/runner/artifact boundary = `FAIL`. - Verification permissions = `contents: read` + `actions: read` only when run/readback needs it. - Verification exclusions = tags + releases + deployments + pointer/index writes + machine installation + signing/publisher secrets. - Verification mode = skip Linux quality/preparation/publication jobs. - Release actor = one per repository + target + environment + revision; concurrency cancels no in-flight publisher. - Cache = acceleration only; delete/disable cache and retain correctness. ## Cross-app adaptation - Canonical standard = one semantic stage DAG + one orchestration implementation; byte identity is required only for files declared universal. - Ownership classes = universal engine bytes | validated typed app config | explicit capability adapter | app-owned product/runtime/preservation code. - Literal-copy manifest = universal files only + exact source revision/hash; app-owned tests, native targets, storage probes, and product IDs are excluded. - Adaptation = copy universal bytes once → render typed config/adapters → reject every undeclared byte delta and every copied foreign identity/namespace. - Blind repository-wide byte equality = `FAIL`; it can import nonexistent targets, incompatible schemas, foreign cleanup markers, or production-only behavior. - Capability matrix = each optional guard/target/path is `required | unsupported`; omission without an explicit capability result = `FAIL`. - Clean-target inventory = every configured script/test/file/native target exists and is executable/reachable in a tracked-only checkout before hosted admission. - Test-path contract = enumerate intended paths from the target repository; copied paths that exist only in the reference repository = `FAIL`. - Namespace = app identity/config derives installer AppId + artifact/object IDs + fixture roots + registry/data markers + cleanup diagnostics. - Reference-app names/literals outside config fixtures = `FAIL`; cleanup and error receipts never use a foreign app marker. - Parity proof = render two controlled app configs through the same engine → universal bytes/call graph stay equal + only allowlisted typed outputs differ. - Real branch proof = first release + upgrade + generated-source mode + every accepted capability/provider adapter; static hash/call-presence parity alone = insufficient. ## Cost-aware proof ladder 1. Portable local = macOS/Linux syntax + unit + static contracts + deterministic crypto/signature fixtures; no Windows-native or live-provider claim. 2. Provider preflight = cheap Linux + official SDK + harmless exact-candidate format/size/protocol/security policy → owned temporary create + public read/hash + bounded delete settlement. 3. Target-native = same checked-in `windows_installer.ps1 verify` on a clean native x64 Windows VM OR repository-scoped ephemeral/JIT x64 Windows runner → one native build + complete installer/lifecycle/security receipt. 4. Hosted release = only after step 3 passes for the exact SHA → one GitHub-hosted Windows publisher + one native build inside that run + immutable activation proof. - Failure cancels later paid work; retry only the smallest failed rung after root-cause + adjacent-assumption proof. - `act` = portable wiring aid only; its container images are Linux-oriented + intentionally incomplete, and Windows/macOS labels require opting out to an actual matching host. `act` on macOS/Linux is not Windows target-native proof. - Self-hosted Actions usage = no GitHub-hosted Actions minute charge; machine + image + updates + isolation + cleanup + logs remain operator-owned. - Self-hosted security = private repository only + repository scope + one exact revision/job + ephemeral/JIT clean environment + teardown after receipt. Persistent runners can retain compromise or secrets across jobs. - Forbidden runner = public repository + organization-wide shared runner + persistent developer workstation + production machine holding sensitive user data, live credentials, signing keys, or access to sensitive services. - Local Windows VM = dedicated disposable proof environment; never reinterpret a normal production workstation as clean. - VM use = an explicit accepted proof rung only; a user-excluded VM/UI remains excluded and cannot be revived as a fallback. - Windows 11 ARM64 VM = useful supplemental smoke for PowerShell + Inno compile/install/uninstall/lifecycle + x86/x64 user-mode EXE emulation. - ARM64 boundary = not native x64 compiler/toolchain/CRT/driver/GitHub-runner proof; Windows emulation does not cover kernel drivers, which require native ARM64. - Local entrypoint = exact checked-in `windows_installer.ps1 verify` + `publication=none`; no tag/release/pointer/provider mutation. - Architecture receipt = host architecture + guest architecture + process/EXE architecture + emulation state + Windows build + resolved toolchain paths/versions. - Runner registration = separate security + persistent-access boundary; direct local execution never authorizes persistent self-hosted registration. - Compact contract = local VM + self-hosted verify + hosted publish call the same repository-owned orchestration; YAML owns boundaries only. - Build count = exactly one Flutter/native compile per target-native orchestration invocation; setup, guards, Inno, Defender, lifecycle, readback, and publication never trigger a second compile in that invocation. ## Clean runner - Bash entry script = `set -euo pipefail` + `script_root="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"` + separate `readonly script_root` before first path use. - Entry ownership = each executable resolves its own directory; inherited caller CWD + undeclared/shared `script_root` = `FAIL`. - Real-branch fixture = invoke the actual entry script under identical strict-mode flags + controlled command/PATH stubs → traverse each live branch into every delegated helper. - Adjacent audit = definition-before-use for variables crossing branch/setup/helper boundaries; static YAML/text/source presence proves wiring only. - Regression = missing `script_root` red under `set -u` + initialized real-helper branch green; testing a helper directly is insufficient. - Every Windows consumer = `flutter pub get` → source-changing version materialization → `dart run build_runner build` → `flutter gen-l10n` when configured → analyze/test → `flutter build windows --release`. - Generated output = tracked OR generated in consumer OR transferred as explicit hash/provenance-verified artifact. - Ubuntu-generated ignored output without transfer = absent on Windows. - `build_runner` command = installed help + official changelog; current supported build command has no conflict-output flag. - Forbidden = `-d` + `--delete-conflicting-output` + `--delete-conflicting-outputs`. - Version materialization = capture tracked paths immediately before/after + allow exact intended delta only. - Whole-workspace-clean assertion after setup/codegen = invalid; caches + ignored generation are expected. - Exact-tip read with `persist-credentials: false` = explicitly authenticate job-scoped read-only `github.token`. - Private Git order = `github.token` → `GH_TOKEN` → `gh auth setup-git` → exact-tip fetch; token persistence/output = forbidden. - Referenced-path gate = every workflow/script/test/native target from typed config exists in the tracked-only checkout before archive/build. - Transferred generation = create destination parent → extract sealed archive → verify provenance + hash + path/cardinality before consumption. - Paid retry = previous failure classified + adjacent assumptions audited + cheapest failing sentinel green first. ## Windows native bundle - Build = `flutter build windows --release` on `windows-latest`. - Resolve release directory + primary EXE from produced build graph; guessed legacy path = no proof. - Bundle = EXE + Flutter/plugin DLLs + `data/` + accepted Visual C++ runtime strategy. - App-local CRT source = Visual Studio installation resolved with `vswhere` + `VCToolsRedistDir`. - Required x64 set = `msvcp140.dll` + `vcruntime140.dll` + `vcruntime140_1.dll`. - Each CRT = exactly one source + PE `MZ`/`PE` + machine `0x8664` + copied destination + source/destination SHA-256 equality. - Forbidden CRT proof = opportunistic System32 copy + optional/missing-tolerated file + compiler presence alone. - Native API = include `<windows.h>` before `<shellapi.h>` for `CommandLineToArgvW`; link `Shell32.lib`. - Native diagnosis = declaration/header order + include directories + link libraries + runtime bundle; one layer cannot prove another. - Floating runner label = record resolved image OS/version + `flutter doctor -v` + `vswhere` result + actual native build; old Visual Studio paths/generators are not current-run proof. - Runner migration = research the current image manifest/announcement before pinning or retrying; choose an explicit older image only for a proven compatibility need + accepted support tradeoff. ## `inno_bundle` setup 1. Resolve current stable package + installed help + primary changelog; then run `dart pub add --dev inno_bundle`. 2. Merge [inno-bundle-pubspec.yaml](../assets/inno-bundle-pubspec.yaml) into `pubspec.yaml`. 3. Generate the AppId once with `dart run inno_bundle:id` or an accepted GUID owner → commit the stable value before first release. 4. Choose `admin` + `arch` from accepted install scope; do not silently change elevation/install-location behavior. 5. Keep `vc_redist: false` in this proven flow → stage app-local CRTs from `VCToolsRedistDir` + verify PE/hash independently. 6. Build once = `flutter build windows --release` → stage/prove CRT → `dart run inno_bundle --no-app`. 7. Inspect generated `build\windows\x64\installer\Release\*.iss` + resulting EXE before accepting package ownership. - Current CLI = `dart run inno_bundle`; `dart run inno_bundle:build` is deprecated. - Existing release output reuse = `dart run inno_bundle --no-app`; no second Flutter compile. - Config owner = `pubspec.yaml` `inno_bundle:` or explicit audited config path; `dlls` is deprecated → use `files`. - Required fields = stable `id`; name/description/version/publisher may inherit pubspec values, but release proof resolves their exact outputs. - Output = locate produced installer from resolved CLI output/build tree → rename/copy only after identity proof to `<app>-windows-installer-v<version>.exe`. - Package-generated `.iss` = candidate artifact; AppId + CRT source + version resources + file layout + upgrade semantics remain explicit gates. ## PowerShell - Syntax gate = compatible Windows `pwsh` + `[System.Management.Automation.Language.Parser]::ParseFile(...)` over every owned `.ps1`; any parse error fails before tool install/build. - Syntax scope = parser owner + cheap smokes + installer harness + every called helper; regex/static intent review is not a parser. - Smoke integrity = syntax gate first → intentional timeout smoke second; a smoke with unparsed syntax proves nothing. - Generated source = literal single-quoted content + dynamic paths/values as named arguments; nested expandable PowerShell source = forbidden. - Generated proof = materialize → parse exact output → execute harmless readiness path under compatible `pwsh` with a bounded receipt before CI/build. - Filename suffix = compute in one scope + pass as an argument; if interpolation is unavoidable, use `${childPidPath}.tmp` or `$($childPidPath).tmp`, never `$childPidPath.tmp`. - Variable names = case-insensitive; `$matches` in every casing aliases automatic `$Matches` → never use it for owned paths/results/arrays. - Regex state = scalar `-match`/`-notmatch` can overwrite or retain `$Matches`; consume captures inside the matching branch + copy only required values to descriptive variables. - `$Matches` regression = strict-mode red fixture stores source results in forbidden casing → scalar regex validation → source consumption fails; green fixture keeps named `@(...)` source intact. - Numeric conversion = validate range + multiply in a wide numeric type + bounds-check + explicit target cast; `[checked]` is not a PowerShell type accelerator. - Command/pipeline/filter result read with `.Count`/index/exact-one = explicit `@(...)` at assignment; singleton object properties are not collection cardinality. - Selected scalar = explicit `[string]` conversion before string APIs. - Cardinality fixture = strict mode + zero/one/many; static contract rejects command/pipeline/filter owners read with `.Count` before array normalization. - Native output = capture array + immediate `$LASTEXITCODE` + normalize to one string before regex. - Process arguments = executable + token array; shell-concatenated command string = avoid. - Native child launch = `.NET ProcessStartInfo.ArgumentList` or equivalent structured API; each token is added separately. - `Start-Process -ArgumentList <array>` joins the array into one command-line string; it is forbidden for owned helper/installer arguments whose values can contain spaces or quotes. - Argument regression = exact spaced path + empty value + quote-bearing value round-trip through the real helper parser; token count/order/value must match. - Cleanup identity = typed app config, never copied marker text; capture/rethrow the primary phase error if cleanup also fails. - Tool path = uniquely resolved absolute path; Program Files guess = forbidden. - `GITHUB_ENV` write = subsequent workflow steps only; the writing step/current process cannot consume the new value. - Same-step installer = return the resolved path or set `$env:ISCC_PATH` in the current PowerShell process + validate absolute existing `ISCC.exe`. - Future-step handoff, when needed = also append `ISCC_PATH=<path>` to `$env:GITHUB_ENV`; this never substitutes for current-process assignment. - Consumption contract = installer result → current `$env:ISCC_PATH`/typed parameter → identity/lifecycle guard; assert non-empty/existing path + call order before build. - Ephemeral root = unique `RUNNER_TEMP` child + owner marker + exact-root cleanup guard. ## Inno ownership - `inno_bundle` = candidate generator, not distribution proof. - Audit installed version = generated `.iss` + AppId + upgrade behavior + file layout + DLL sources + compiler selection + version resources + signing hooks. - Use package directly only when generated contract covers accepted requirements. - Extend least surface; retain custom `.iss`/scripts when preservation, rollback, relaunch, publication, or identity requirements exceed package behavior. - Stable AppId = immutable across releases + in-place install directory. - Update = never delete application data, sibling user paths, credentials, or unknown files. - Inno compiler = current stable version resolved from official release evidence, then exact-pinned + authenticated/hash-verified `ISCC.exe` + cheap distinct-version sentinel + compile exit `0`. - Tiny identity sentinel = distinct numeric version + textual version; compile/read fields before expensive Flutter build. - Tiny lifecycle sentinel = unique temp root + invocation-namespaced synthetic AppId stable through compile/install/uninstall → invoke uninstaller once → bounded settlement of exact install directory + AppId uninstall key; run before expensive Flutter build. ## Installer identity - Filename = `<app>-windows-installer-v<version>.exe`. - File = exact expected name + accepted size bounds + `MZ` + SHA-256. - Product identity = ProductName + FileDescription + stable AppId. - Install path = the pinned Inno source tag owns `InstallLocation`; audited 7.0.2 includes `AddBackslash(...)`, so canonicalize trailing separators on expected/actual paths before equality only. - Numeric fields = `VersionInfoVersion` + `VersionInfoProductVersion`; compare four integer parts independently. - Text field = `VersionInfoProductTextVersion`; trim only observed textual boundary padding before equality. - Diagnostic = exact failed field + expected value + safe observed value/length/hash. - Path canonicalization never weakens AppId/name/version/hash/signature assertions; opaque aggregate identity exception = forbidden. - Signature required by accepted delivery policy = verify chain + subject/thumbprint policy + timestamp before publication. ## Install lifecycle ### First release - Previous-release lookup = authoritative published index/pointer + immutable retrievable installer + signed manifest/hash. - No authoritative prior release = do not synthesize, wait, rebuild old source, or use current-source/expired Actions artifacts. - Resolver mode = `none`; old-installer path = absent. - Receipt = `phase=prior-release result=skipped_no_prior_release`. - Required proof = bounded clean install + forced-failure cleanup + relaunch + uninstall + no application/user-data deletion. - Skipped prior release skips only the old-installer branch; synthetic state seeding/preservation + clean install/failure/relaunch/uninstall remain mandatory. - Preservation fixture = invocation-namespaced synthetic AppData + registry/credential entries owned directly by the harness or a tiny fixture helper. - Forbidden fixture = production app EXE/`main.dart` probe mode + real repository/provider/storage graph + real user data + live credentials. - Fixture regression = production executable/provider wiring red + isolated synthetic seed/read/preserve/cleanup green. ### Upgrade release - Release two onward = exact previous published installer/manifest is mandatory. - Resolver mode = `managed`; old-installer path = exact verified publication object. - Baseline identity = independently download + verify manifest signature + installer hash/signature/version before execution. - Required proof = old install → seed accepted local state → failed new install restores old program/data → successful new install preserves state + relaunches → uninstall removes owned program/registration only. - Previous artifact = immutable publication object or equivalent authoritative release asset; current build + locally rebuilt tag + expired diagnostic artifact = forbidden surrogate. - Actions handoff = same-run transport only after authoritative verification; verify its digest again after download. - Harness refuses pre-existing unrelated installation; fixture data/credentials = synthetic + namespaced. - Cleanup = owned processes + owned install + owned registry + owned temp markers only. ### Forced-failure proof - Test-only failure = establish owned backup/recovery state → `PrepareToInstall` returns a non-empty diagnostic. - Expected installer result = exact exit code `7`; exit `0` = false failure proof + immediate stop. - Cleanup/restore = `DeinitializeSetup`; it runs even when Setup exits before installation. - Assert = installer exit + prior program bytes + accepted local state + no partial new version + owned cleanup. - Forbidden = raise from `[Files]` `AfterInstall` + assume `/SUPPRESSMSGBOXES` makes the file error fatal/nonzero. - Different Inno version/trigger = reverify official event + exit-code contract before use. ### Uninstall settlement - Exit `0` = original uninstaller completed; its temporary cleanup clone may still be running. - Invocation = launch the exact owned uninstaller once; after exit `0`, never invoke its vanishing path again. - Settlement = bounded poll until exact install directory + exact AppId uninstall registry key + owned TEMP-clone process/file state are absent. - Ownership = retain installation/cleanup state until settlement passes or times out; no second uninstaller fallback. - Timeout receipt = unresolved directory/key condition + elapsed/deadline + safe process state; bounded owned cleanup only. - Relaunch cleanup = stop only the exact process proven to be the newly relaunched app; name-wide/process-wide termination = forbidden. - Error precedence = capture primary phase exception + stack → attempt bounded cleanup → attach cleanup failure → rethrow primary; cleanup never masks root cause. - Data contract = settlement targets installed program/registration only; accepted application/user data remains preserved. ## Bounded processes - Whole-job timeout = outer failsafe only; every child phase owns a smaller explicit deadline. - Phases = baseline install + forced-failure install + rollback observation + new install + updater launch + relaunch observation + uninstall process + uninstall settlement. - Receipt = `phase=<name> result=started|completed|timeout|cleanup-timeout` + deadline/exit details. - Start = emit phase + deadline + safe command identity + PID receipt. - Wait = finite process wait; `Start-Process -Wait` + `WaitForSingleObject(..., INFINITE)` forbidden. - Timeout = emit phase + elapsed + PID/alive/exit state → terminate exact owned process tree → bounded cleanup → fail immediately. - PowerShell owner = `Start-Process -PassThru` + finite `WaitForExit(milliseconds)`/bounded polling + exact-tree termination on timeout or failed installer. - Native owner = `WaitForSingleObject(handle, timeout_ms)` + separate parent/installer `WAIT_TIMEOUT` exits + closed handles. - Regression sentinel = child intentionally exceeds deadline → gate exits within bound + names phase + kills descendant + leaves no owned artifact/process. - Nested deadlines = child phase + cleanup headroom < job deadline. - Cold Flutter build ceiling = `900s` default inner deadline from observed clean-run range `~161–555s`; evidence bound, not vendor SLA. - Duration tuning = record image/toolchain + phase duration; tighter repo-owned evidence may reduce the ceiling, relaxation requires new receipts. ## Updater behavior - Surface = small muted warning/error-color banner + Settings indicator. - Actions = `Download and install` + `Later`. - Reminder = once after 24 hours; no tight polling/nag loop. - Mandatory = security-critical or explicitly owner-marked important only; normal release remains deferrable. - Install = download → verify manifest signature + installer SHA/signature → flush local state → close app → bounded helper/installer → verify installed version → relaunch. - Unsupported platform/storage = fail closed. - Provider choice = project-owned; UI/domain depends on manifest/download interfaces, not vendor SDK. - Gateway path contract = producer + consumer share one canonical component encoder/decoder; test literal reserved form + one uppercase/lowercase percent-encoded form + reject double/ambiguous encoding. - Gateway authorization = normalize/validate route → authenticate → object lookup; missing/existing object identity never changes anonymous response. - Anonymous preflight = non-redirecting HEAD + range-limited GET accepts only the configured auth challenge and records method/status/curl exit/redirect count/auth-header presence without URL/body/token/header leakage. - Runtime user token = existing signed-in user mints it at runtime; CI creates no temporary user/password/session/JWT unless an accepted contract specifically requires authenticated end-to-end gateway proof. ## Security - Bundle/log/artifact = no embedded API keys, credentials, private signing keys, publisher tokens, or PII. - Client-visible telemetry destination, when accepted, follows [error-reporting.md](error-reporting.md); upload tokens remain build-only. - Scan = current tracked tree + built bundle + extracted installer. - Defender invocation = official `MpCmdRun.exe -Scan -ScanType 3 -File <installer> -DisableRemediation -ReturnHR`; legacy exit `2` is ambiguous detection/action/error evidence and cannot prove a clean scan. - Defender success = exact HRESULT `0x00000000`; detection/action-required HRESULT + unknown HRESULT + timeout = fail closed. - Defender retry = only exact `HRESULT_FROM_WIN32(ERROR_SHARING_VIOLATION)` = `0x80070020` + at most one bounded retry; every other nonzero result fails immediately. - Defender diagnostics = named phase + attempt + HRESULT + bounded redacted output + full stdout/stderr hashes; raw unbounded scanner logs/path output = forbidden. - Defender fixture = [copyable scanner](../assets/defender-installer-scan.ps1) self-test proves clean green + detection red + unknown red + one sharing-violation retry green without adding a workflow step. - Credential-store behavior, when used = synthetic round-trip + unsupported-platform fail-closed proof. - Verification lane = no signing/publisher secret environment or arguments. - Logs/receipts = hashes + versions + safe field diagnostics; never secret values. ## Publication - Version/tag materialization = after exact-tree gates + before consuming codegen/native build. - Build/sign = installer + signed manifest bound to exact source SHA/version/hash/size/classification. - Immutable upload = versioned installer + manifest; collision accepted only when downloaded bytes match. - Readback = independent download + installer identity/hash/signature + manifest signature/payload. - Pointer/index activation = last external mutation after every immutable object readback passes. - Activation input inventory = exact endpoint/project/index/pointer/object/version/SHA/key names are validated in the activation step environment before invocation; prior-step environment presence is not proof. - Final readback = active pointer/index + immutable objects + exact version/tag/SHA. - Failure before activation = previous pointer unchanged. - Actions artifact = short-lived evidence or same-run transport of an already verified publication object; never previous-release authority or independent publication/readback proof. - Provider binding = deployed server/version + official SDK compatibility pin + endpoint/mode + storage security policy; tiny credential/API probes prove none of the release upload path. - Provider preflight = official SDK + candidate-size/type object + same chunking/protocol + same bucket/security policy → upload + metadata/hash/readback + bounded delete settlement before release activation. - Manifest fixture = same canonical builder/schema owner as publication; otherwise contract-test parity for every required identity field, including `platform`. - Crypto round-trip = canonical manifest payload → real signer → real verifier; reduced ad hoc maps + weaker fixture validators = false-gate risk. - Public-read settlement = after immutable create returns, use the unauthenticated/public consumer path + bounded typed polling until exact bytes/hash/signature/payload are readable. - Retryable read = exact `storage_file_not_found` + HTTP `429` + `5xx` only → bounded exponential backoff + attempt/deadline/type/code receipt. - Immediate failure = `401` + `403` + unexpected `4xx` + hash/signature/payload/size/identity mismatch. - Ambiguous create/read recovery = same immutable object ID + same accepted bytes; reconcile/read only. Rebuild + version bump + delete + overwrite + duplicate ID/object = `FAIL`. - Preflight cleanup and release recovery differ: delete the owned temporary preflight object after proof; never delete or replace an immutable release object while availability is settling. - Object identity = allocate one deterministic release object ID before upload + reuse it across attempts. - Failed upload = reconcile that exact ID; partial/incomplete object → delete + verify absent before retry. Fresh ID, orphaned chunks, or raw REST fallback = `FAIL`. - Failure ownership = native build/installer proof and downstream storage publication are separate phases/receipts; a storage failure never invalidates passed native proof or authorizes rebuilding it. - Security-policy workaround = disabling scan/security, changing bucket/provider, server upgrade/custom image, or alternate protocol → explicit external-write + security/risk approval boundary. - Provider adapter = preflight candidate write/read/delete + idempotency + permissions + atomicity/ordering contract before release. - Release engine stages = admit exact revision/config → prepare → build once → verify → publish immutable objects → settle readback → activate pointer last. - Cross-app parity map = stage + branch + input/config + output/receipt + ordering + timeout/failure/cleanup invariant; omitted or reordered stage blocks adaptation. - Typed app config = product identity + stable AppId + artifact names + schema-required manifest fields + provider adapter inputs; validate before mutation. - Schema variance = preserve intentional fields such as `channel` or `platform` through typed config/adapter contracts; never flatten them or copy another app's IDs. - Real parity proof = execute actual first-release + upgrade + provider branches with controlled adapters; copied text/static call presence alone = insufficient. - Activation-only recovery = when immutable installer/manifest/tag already verify and pointer activation alone failed, reuse those exact identifiers/bytes → reverify → activate/read back; skip codegen/build/Inno/upload/manifest/tag/artifact creation. - Recovery mode = explicit same version/tag/source revision/object IDs + unchanged publication target; classification/no-op paths cannot masquerade as completion. ## Proof - Diagnostic receipt = source SHA + dispatch SHA/ref + resolved tools + generated-source proof + EXE/CRT identity + installer identity/hash/signature + secret/Defender scans + lifecycle branch/results + phase receipts + `publication=none`. - Publisher receipt = diagnostic run/SHA + required jobs/steps + version/tag + immutable object IDs/hashes + downloaded verification + pointer/index readback + absent unintended release/deployment/install. - Independent receipts = native build/installer | isolated lifecycle/state preservation | immutable publication/readback | pointer activation; later failure never invalidates an earlier receipt or authorizes rebuilding it. - Remote PASS = exact required job + named step green; workflow-level green alone = insufficient. - Active repair/retry = report nonterminal; never claim current workflow green before exact run receipt. ## Sources - [Dart build_runner](https://dart.dev/tools/build_runner) - [build_runner changelog](https://pub.dev/packages/build_runner/changelog) - [Flutter Windows distribution](https://docs.flutter.dev/platform-integration/windows/building) - [GitHub workflow syntax and permissions](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) - [GitHub manual workflow dispatch](https://docs.github.com/actions/managing-workflow-runs/manually-running-a-workflow) - [GitHub workflow-dispatch REST `ref`](https://docs.github.com/en/rest/actions/workflows#create-a-workflow-dispatch-event) - [`actions/checkout` `ref` input](https://github.com/actions/checkout/blob/main/action.yml) - [`actions/checkout` 7.0.1 release](https://github.com/actions/checkout/releases/tag/v7.0.1) - [`actions/upload-artifact` 7.0.1 release](https://github.com/actions/upload-artifact/releases/tag/v7.0.1) - [`actions/download-artifact` 8.0.1 release](https://github.com/actions/download-artifact/releases/tag/v8.0.1) - [GitHub `windows-latest` Server 2025 + Visual Studio 2026 migration](https://github.com/actions/runner-images/issues/14017) - [GitHub Actions environment files](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-an-environment-variable) - [GitHub token](https://docs.github.com/en/actions/concepts/security/github_token) - [GitHub Actions billing](https://docs.github.com/en/billing/concepts/product-billing/github-actions) - [GitHub self-hosted runners](https://docs.github.com/en/actions/reference/runners/self-hosted-runners) - [GitHub secure use](https://docs.github.com/en/actions/reference/security/secure-use) - [GitHub private-repository runner recommendation](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/add-runners) - [`act` runner images](https://nektosact.com/usage/runners.html) - [`act` unsupported functionality](https://nektosact.com/not_supported.html) - [Windows on Arm FAQ](https://learn.microsoft.com/en-us/windows/arm/faq) - [Add Arm support to Windows apps](https://learn.microsoft.com/en-us/windows/arm/add-arm-support) - [How x86 and x64 emulation works on Arm](https://learn.microsoft.com/en-us/windows/arm/apps-on-arm-x86-emulation) - [PowerShell case sensitivity](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_case-sensitivity) - [PowerShell automatic `$Matches`](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables) - [PowerShell `-match`/`-notmatch`](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_comparison_operators) - [PowerShell arrays + `@(...)`](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_arrays) - [PowerShell Start-Process](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process) - [.NET `ProcessStartInfo.ArgumentList`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.argumentlist) - [PowerShell Wait-Process](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/wait-process) - [PowerShell `Parser.ParseFile`](https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.language.parser.parsefile?view=powershellsdk-7.6.0) - [PowerShell numeric literals + type accelerators](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_numeric_literals?view=powershell-7.5) - [PowerShell quoting + expandable strings](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.6) - [Visual Studio `vswhere`](https://github.com/microsoft/vswhere) - [Inno Setup AppId](https://jrsoftware.org/ishelp/topic_setup_appid.htm) - [Inno Setup uninstaller exit codes](https://jrsoftware.org/ishelp/topic_uninstexitcodes.htm) - [Inno Setup 7.0.2 revision history](https://jrsoftware.org/files/is7-whatsnew.htm) - [Inno Setup 7.0.2 `InstallLocation` source](https://github.com/jrsoftware/issrc/blob/is-7_0_2/Projects/Src/Setup.Install.pas#L280) - [Inno Setup event functions](https://jrsoftware.org/ishelp/topic_scriptevents.htm) - [Inno Setup exit codes](https://jrsoftware.org/ishelp/topic_setupexitcodes.htm) - [Inno Setup command-line parameters](https://jrsoftware.org/ishelp/topic_setupcmdline.htm) - [Inno command-line compiler](https://jrsoftware.org/ishelp/topic_compilercmdline.htm) - [Inno binary file version](https://jrsoftware.org/ishelp/topic_setup_versioninfoversion.htm) - [Inno binary product version](https://jrsoftware.org/ishelp/topic_setup_versioninfoproductversion.htm) - [Inno textual product version](https://jrsoftware.org/ishelp/topic_setup_versioninfoproducttextversion.htm) - [`inno_bundle`](https://pub.dev/packages/inno_bundle) - [WHATWG URL Standard](https://url.spec.whatwg.org/) - [Win32 `CommandLineToArgvW`](https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-commandlinetoargvw) - [Win32 `WaitForSingleObject`](https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject) - [Microsoft Defender `MpCmdRun` + `-ReturnHR`](https://learn.microsoft.com/en-us/defender-endpoint/command-line-arguments-microsoft-defender-antivirus) - [Microsoft Defender HRESULTs](https://learn.microsoft.com/en-us/defender-endpoint/troubleshoot-microsoft-defender-antivirus) - [Win32 `ERROR_SHARING_VIOLATION`](https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-) - [Win32 `HRESULT_FROM_WIN32`](https://learn.microsoft.com/en-us/windows/win32/api/winerror/nf-winerror-hresult_from_win32) - [Storage permissions](https://appwrite.io/docs/products/storage/permissions) - [Storage response codes](https://appwrite.io/docs/apis/response-codes) - [Storage create/download API](https://appwrite.io/docs/references/cloud/server-rest/storage)
-
-
templates
-
flutter
-
lib
-
core
-
extensions
-
context_extensions.dart 310 B · in bundle
-
extensions.dart 34 B · in bundle
-
-
-
-
-
-
SKILL.md 19.9 KB
--- name: building-flutter-apps description: >- Flutter Riverpod app architecture and Windows installer delivery. Use before changing a Riverpod Flutter app/package or its Windows desktop packaging/update pipeline; skip non-Riverpod stacks and pure-Dart work. license: MIT metadata: author: sgaabdu4 version: "5.12.0" tags: flutter, riverpod, freezed, state-management, clean-architecture, dart, hive, crashlytics, sentry, gorouter, gen-l10n, windows, inno, installer, fire-and-forget, singletons, e2e testing --- ## Read first - This skill overrides generic Flutter/Dart advice; Critical Rules override examples, public docs, and older project code. - Before code, read Trigger Map refs for touched areas. Each ref's `Read first` section is canonical. - After each `.dart`/`pubspec.yaml`/`build.yaml`/`analysis_options.yaml` write batch, emit Pre-Flight; its cited rule/reference owns the applicable check. ## Progressive Disclosure Gate Read only the narrowest matching Trigger Map row(s); scenario/subsystem rows own incidental stack/file words. Do not bulk-read `references/` or parent refs. Cite exact refs in Pre-Flight. ## Critical Rules | ID | Rule | Detail refs | |---|---|---| | R1 | Flutter/Riverpod package = first wire `flutter_skill_lints` + `riverpod_lint`, then run package-root `dart analyze` + its project-owned Dart Decimate check; pure-Dart CLI = native Dart analysis profile with neither plugin. | [analysis-options.md](references/analysis-options.md), [dart-decimate.md](references/dart-decimate.md), [setup.md](references/setup.md) | | R2 | Every provider uses `@riverpod` / `@Riverpod` codegen; no manual provider classes or legacy provider families. | [riverpod-codegen.md](references/riverpod-codegen.md) | | R3 | Guard async gaps with `ref.mounted` / `context.mounted`; `finally` uses `if (ref.mounted) { ... }`. | [async-mutations.md](references/state-management/async-mutations.md) | | R4 | Widgets are public classes; no `_buildXxx()`, widget top-level helpers, or private widget classes except `State`. | [atomic-design.md](references/atomic-design.md), [performance.md](references/performance.md) | | R5 | Nullability is semantic; no empty/null/bool sentinel fallbacks, `value!`, nullable collections, or raw required domain strings. | [value-objects.md](references/value-objects.md), [freezed-sealed.md](references/freezed-sealed.md); Lints: `domain_raw_required_string` | | R6 | All user-facing strings, tooltips, semantics, and visible accessibility copy use `AppLocalizations`. | [localization.md](references/localization.md), [accessibility.md](references/atomic-design/accessibility.md) | | R7 | Immutable state/entities use sealed Freezed, one declaration per file, native `switch`, and VO map/when disabled. | [freezed-sealed.md](references/freezed-sealed.md), [value-objects.md](references/value-objects.md) | | R8 | `presentation/widgets/` renders immutable inputs + emits typed callbacks; screens/routes/notifiers own navigation, workflow, domain state, and infrastructure. | [presentation-widgets.md](references/presentation-widgets.md) | | R9 | Duplicate behavior in 2+ classes becomes a small stateless `*Mixin` with an `on` clause. | [mixins.md](references/mixins.md) | | R10 | Storage SDK calls live in local datasources behind repositories; production Hive imports use `hive_ce_flutter`. | [hive-persistence.md](references/hive-persistence.md), [architecture.md](references/architecture.md) | | R11 | Primitive/context/collection operations live in `core/extensions/`; domain never imports those extensions. | [context-ui.md](references/extensions/context-ui.md), [primitive-formatting.md](references/extensions/primitive-formatting.md), [collections-helpers.md](references/extensions/collections-helpers.md) | | R12 | Domain primitives with meaning become validated Freezed Value Objects; Hive models keep primitives and mappers bridge. | [value-objects.md](references/value-objects.md), [hive-persistence.md](references/hive-persistence.md); Lints: `domain_unit_primitive` | | R13 | Typed GoRouter routes are navigation SSOT; redirects are pure resolver logic with nullable by-id fallback UI. | [deep-linking.md](references/deep-linking.md), [routing-app-shell.md](references/common-patterns/routing-app-shell.md) | | R14 | Dialogs/sheets render immutable snapshots, pop results, and leave mutations/teardown to notifiers. | [modals-navigation.md](references/common-patterns/modals-navigation.md), [state-management-lifecycle.md](references/state-management-lifecycle.md) | | R15 | Debounce, gate, and batch high-frequency UI, sync, persistence, remote-function, reset, and lookup boundaries. | [debounce-gate-batch.md](references/common-patterns/debounce-gate-batch.md) | | R16 | App shell stays declarative; bootstrap listeners live in a sibling root `ConsumerWidget`. | [routing-app-shell.md](references/common-patterns/routing-app-shell.md) | | R17 | Keep control flow flat after exits; remove unnecessary `else` after `return` / `throw` / `break` / `continue`. | Lint: `avoid_unnecessary_else_after_control_flow` | | R18 | Use `onReorderItem` post-removal indexes directly; never add legacy `onReorder` adapter math. | Lint: `use_on_reorder_item_index_semantics` | | R19 | Android exact alarms use `flutter_local_notifications` permission APIs, not manual settings intents. | Lint: `use_local_notifications_exact_alarm_permission_api` | | R20 | Resolve nullable platform-specific plugin implementations before calling platform members. | Lint: `resolve_platform_specific_implementation_before_use` | | R21 | Widget previews are preview-only with deterministic fakes; no real HTTP/Firebase/Hive/native plugins. | [widget-previews.md](references/widget-previews.md) | | R22 | Runtime E2E proves behavior with stable selectors, failure-sensitive scenarios/logs, subject-matched evidence, source-of-truth verification, cleanup, and multi-actor proof when needed. | [dart-mcp-e2e-testing.md](references/dart-mcp-e2e-testing.md) | | R23 | Accessibility is UI correctness: localized tooltips/semantic labels, 48x48 targets, contrast, text scale, `Text.rich`. | [accessibility.md](references/atomic-design/accessibility.md), [flutter-optimizations.md](references/flutter-optimizations.md#semantics) | | R24 | If remote error reporting is accepted or already present, use one app-owned `Crash` boundary and one reporting owner per operation; otherwise add no provider/facade. Scrub sensitive data + reconcile ambiguous remote outcomes before telemetry. | [error-reporting.md](references/error-reporting.md), [networking.md](references/networking.md) | | R25 | Windows installer delivery = one semantic engine + typed app config/capabilities + minimal-step exact-SHA diagnostic → one publisher; keep cheap guards before one build, isolate synthetic preservation proof, and activate only verified immutable bytes. | [windows-installer-pipeline.md](references/windows-installer-pipeline.md), [workflow scaffold](assets/windows-installer-workflow.yml), [`inno_bundle` pubspec scaffold](assets/inno-bundle-pubspec.yaml), [Inno settlement sentinel](assets/inno-uninstall-settlement-sentinel.ps1), [Defender scanner](assets/defender-installer-scan.ps1) | | R26 | Pause-sensitive Riverpod state starts only after its durable owner/listener exists; projections watch base state directly; switching auth/form modes clears transient errors. | [notifier-structure.md](references/state-management/notifier-structure.md), [state-management-lifecycle.md](references/state-management-lifecycle.md), [testing.md](references/testing.md) | | R27 | Native/custom links use one URI contract across producer, platform registration, Flutter delivery, and typed router; prove cold/warm + signed-state delivery on the target device. | [deep-linking.md](references/deep-linking.md), [dart-mcp-e2e-testing.md](references/dart-mcp-e2e-testing.md) | ## Trigger Map Before writing code in any row below, read the listed reference(s). Prefer the narrowest matching row. Read the large parent refs only when no scenario row fits. | Touching | Read | |---|---| | New app/project scaffolding with incidental stack/package mentions, `main.dart`, `ProviderScope`, `MaterialApp.router`, app startup shell | [setup.md](references/setup.md) + [architecture.md](references/architecture.md) + [routing-app-shell.md](references/common-patterns/routing-app-shell.md) | | Notifier/AsyncNotifier shape, sync `Notifier.build()` init, paused route/listener startup, provider projection, auth/form mode error reset, loading/progress, `AsyncValue`, cleanup | [notifier-structure.md](references/state-management/notifier-structure.md) + [state-management-lifecycle.md](references/state-management-lifecycle.md) + [testing.md](references/testing.md) | | Mutation method, `ref.read` / `ref.watch` / `ref.listen`, `_ensureRepository`, async cancellation, `ref.mounted`, optimistic update, duplicate fetch | [async-mutations.md](references/state-management/async-mutations.md) + [state-management-lifecycle.md](references/state-management-lifecycle.md) | | Freezed entity, sealed union, `fromJson` / `toJson`, `copyWith`, model vs entity, `build.yaml` for `explicit_to_json` | [freezed-sealed.md](references/freezed-sealed.md) | | Provider declaration, `@riverpod`, family, `keepAlive`, codegen, `Mutation<T>` (experimental) | [riverpod-codegen.md](references/riverpod-codegen.md) | | Repository, datasource, domain entity, layered architecture, `IHttpService`, mapping models to entities | [architecture.md](references/architecture.md) | | Value Object, primitive obsession, `Distance`/`Money`/`Email`/`Slug`, unit conversion in domain, cross-entity primitive, `double distanceMeters`/`int amountCents`/`String email` smell, `arch_domain_import` error | [value-objects.md](references/value-objects.md) | | GoRouter, typed route, redirect, auth-protected route, router provider, `context.go`, deep link, custom URI scheme, native extension/activity link, cold-start, navigation gate | [routing-app-shell.md](references/common-patterns/routing-app-shell.md) + [deep-linking.md](references/deep-linking.md) | | HTTP, network, REST, source-of-truth fetch after mutation, long-running remote function, async-start + reconcile, transport id vs domain id | [networking.md](references/networking.md) + [debounce-gate-batch.md](references/common-patterns/debounce-gate-batch.md) | | Atom, molecule, organism, design tokens, atomic widgets, `core/widgets/` promotion | [atomic-design.md](references/atomic-design.md) | | Reusable `presentation/widgets/`, widget-owned navigation/page stack/selected entity/workflow state, direct repository/service/provider access | [presentation-widgets.md](references/presentation-widgets.md) | | Accessibility, semantics, tooltip, semanticLabel, image alt text, tap target, contrast, text scaling | [accessibility.md](references/atomic-design/accessibility.md) + [flutter-optimizations.md](references/flutter-optimizations.md#semantics) | | Widget test, `ProviderContainer.test()`, `UncontrolledProviderScope`, fakes, mocks, `AppWidgetKeys`, event-contract tests | [testing.md](references/testing.md) | | `flutter_driver`, Dart MCP, Marionette MCP, E2E, `integration_test`, semantic selectors, scenario validation, screenshot/media proof, log capture, native integration builds but fails on device, runtime permissions, plugin hangs, release-only runtime failure | [dart-mcp-e2e-testing.md](references/dart-mcp-e2e-testing.md) | | Hive, `TypeAdapter`, TypeId, box, persistence migration, retired field accounting | [hive-persistence.md](references/hive-persistence.md) | | Crashlytics, FirebaseCrashlytics, Sentry, `sentry_flutter`, DSN, error reporting, `Crash.init`, `Crash.error`, `Crash.log`, symbol upload | [error-reporting.md](references/error-reporting.md) | | Mixin, capability vs interface, retry helper, RNG, bulk operation | [mixins.md](references/mixins.md) | | Service, singleton, fire-and-forget, `abstract final class`, `unawaited()`, `Future<void>` signature | [services-and-singletons.md](references/services-and-singletons.md) | | `@Preview`, `widget_previews.dart`, preview fakes, deterministic preview data | [widget-previews.md](references/widget-previews.md) | | `AppLocalizations`, ARB file, gen-l10n, locale fallback, placeholders, plural / select | [localization.md](references/localization.md) | | Performance, build cost, `.select()`, `const` constructors, `ListView.builder`, large list compute | [performance.md](references/performance.md) + [flutter-optimizations.md](references/flutter-optimizations.md) | | `LayoutBuilder`, `RenderFlex` overflow, `Expanded` / `Flexible` outside `Row` / `Column`, `Positioned` outside `Stack`, text-scale clamp | [layout-diagnostics.md](references/layout-diagnostics.md) | | Pagination, infinite scroll, cursor loading, search debounce, registration/form validation and submission, batch processing, pull-to-refresh | [lists-forms-workflows.md](references/common-patterns/lists-forms-workflows.md) + [async-mutations.md](references/state-management/async-mutations.md) | | `BuildContext` helpers, `ModalRoute` current-route checks, dialogs, `SnackBarUtils`, snackbar dispatch from notifier | [context-ui.md](references/extensions/context-ui.md) | | `DateTime` format/diff/timeAgo/startOfDay, `String` capitalize/truncate/titleCase/initials/format, `int` / `double` / `num` clamp/pluralized/asCurrency/percent/toFixed, `Duration` format, `NumberFormat`, `DateFormat`, `intl` | [primitive-formatting.md](references/extensions/primitive-formatting.md) | | `Iterable` lookup/indexing, widget list helpers, `Debouncer`, validators, `Result`, extension types, `core/extensions/` barrel export | [collections-helpers.md](references/extensions/collections-helpers.md) | | Records `(x, y)`, extension type IDs, pattern matching, primary/concise constructors, `new()`, `factory()`, dot shorthand such as `.center`, `@RecordUse` | [dart-patterns-records.md](references/dart-patterns-records.md) | | Flutter/Riverpod `analysis_options.yaml`, `dart analyze`, plugin wiring, `riverpod_lint` version pin, analyzer crash | [analysis-options.md](references/analysis-options.md) + [analysis_options.yaml](references/analysis_options.yaml) | | Skill setup, Git pre-push, hook/scanner registration | [setup.md](references/setup.md) | | `build_runner`, missing generated parts, clean checkout, Xcode selection, Flutter SwiftPM generated package, Apple device build, local-vs-CI mismatch | [build-reproducibility.md](references/build-reproducibility.md) + [core-stack.md](references/core-stack.md) | | Package constraints, dependency upgrade, generator/analyzer compatibility | [core-stack.md](references/core-stack.md) | | Flutter Windows desktop packaging, GitHub Actions Windows installer, Inno Setup, `inno_bundle`, updater/auto-update, CRT DLLs, PowerShell/native installer process, installer/version/AppId failure | [windows-installer-pipeline.md](references/windows-installer-pipeline.md) + [build-reproducibility.md](references/build-reproducibility.md) + [core-stack.md](references/core-stack.md) | | Dart Decimate, dead code, circular dependency, duplicate code, complexity, dependency hygiene, full zero-finding scan | [dart-decimate.md](references/dart-decimate.md) | | Common navigation / form / list / debounce / route-param-fallback patterns | [common-patterns.md](references/common-patterns.md) | | Incremental remote pull, delta token, per-table sync date, merge/delete reconciliation | [delta-sync.md](references/common-patterns/delta-sync.md) | | Route-param safety, wizard sequencing, guarded next-step navigation | [navigation-flow.md](references/common-patterns/navigation-flow.md) | | Dialog / sheet / modal, snapshot value object, post-await teardown, dismiss-then-route, pop fallback, nested navigator dismissal | [modals-navigation.md](references/common-patterns/modals-navigation.md) + [state-management-lifecycle.md](references/state-management-lifecycle.md#state-teardown-belongs-in-the-notifier) | | Debounce / throttle / coalesce — `TextField.onChanged`, `Slider.onChanged`, scroll listener, sync `saveAll`, full-collection rewrite after subset mutation, persistence helper, reset/clear sentinel preservation, `_userTapped` gate, `WebView` / `VideoPlayer` in `build`, `_storage.read` in service, `ref.listenManual` ban, keepAlive collection watch, datasource batch loader, zero-value save guard, primitive→VO at notifier boundary, `routeSettings` on modal helper | [debounce-gate-batch.md](references/common-patterns/debounce-gate-batch.md) | ## Pre-Flight After each `.dart` / `pubspec.yaml` / `build.yaml` / `analysis_options.yaml` write batch, emit a checked list before yielding. Fill T0 always. Add T1 for state/notifier/mutation changes and T2 for network/E2E/stream/route changes. Cite rule IDs or refs for any failed item. ### T0 — Core - [ ] Flutter/Riverpod package: package-root `dart analyze` exits 0 with `flutter_skill_lints` + `riverpod_lint`; setup changes prove one diagnostic from each plugin. Pure-Dart CLI: native Dart analysis profile applies; both plugins are N/A. - [ ] A current same-scope project-owned Dart Decimate result is green: Hard Eng uses `python3 .hooks/hard-eng.py check`; another project uses its established check or, if it has none, `pnpm dlx --config.ignore-scripts=false --allow-build=dart-decimate dart-decimate@latest check . --threshold 0 --format json` from its Git root. The project workflow schedules an integrated run; reuse its valid result instead of duplicating a full runner. Cite scan scope. Do not add a wrapper, dependency, or global coordinator for this skill. - [ ] Async gaps are guarded: `ref.mounted` / `context.mounted`, no bare `mounted`, and `finally` uses `if (ref.mounted) { ... }`. - [ ] Providers, state, and widgets follow Rules 2-8 and 14: reusable widgets own UI lifecycle only; screens/routes/notifiers own navigation, workflow branching, selected domain records, provider state, and infrastructure. - [ ] Domain/data/platform follow Rules 7, 10-13, 17-24, 26-27: sealed Freezed, VOs, datasource/repo storage, core extensions, typed routes, debounce/batch, platform APIs, previews, E2E, pause-safe state, native links, and a11y; if error reporting is accepted/present, it uses one scrubbed once-only boundary, otherwise N/A. - [ ] Rule 25 = N/A unless Windows packaging/updater delivery is touched; when applicable, its diagnostic/publisher proof is green. - [ ] Any row touched in Trigger Map was read; exact lint names are cited when a scanner should enforce the rule. ### T1 — State / Notifier / Mutation - [ ] Mutation deps resolve lazily via stateless helper/mixin; no notifier-local repo/service cache except disposable lifecycle owners. - [ ] Sync `Notifier.build()` avoids pre-return `state` reads; async primary state uses `AsyncNotifier.build`; durable sync startup does not depend on microtask timing before its owner/listener exists. - [ ] `ref.onDispose()` cancels subscriptions/controllers/timers; durable status/snackbar/teardown belongs to notifier state. - [ ] Long-running sync/auth/import guards stale writes; no `ref.watch` inside notifier methods. - [ ] Pause-sensitive projections watch base state directly; route pause/resume keeps the first update; auth/form mode changes clear transient errors. ### T2 — Network / E2E / Stream / Route - [ ] Source-of-truth fetch/reconcile after generated, normalized, reordered, destructive, or remote-function mutations. - [ ] Shared/realtime state has writer + observer E2E proof without manual refresh. - [ ] Selectors use stable text/semantics/tooltips or central `AppWidgetKeys`; no inline string keys or coordinate primary taps. - [ ] E2E entrypoint is deterministic and isolated from production `main.dart`; unknown scenarios fail; critical logs fail the run; evidence shows the asserted screen before app exit; cleanup is verified. - [ ] GoRouter redirects use pure matrix-tested resolver, nullable by-id providers/fallback UI, and generated typed route helpers. - [ ] Native/custom URI producer, Android/iOS registration, Flutter delivery, and typed router share one tested scheme/host/path contract; cold/warm + signed-state device paths pass. - [ ] Cross-runtime constants, schemas, and function contracts have drift tests; no app-root text-scale clamp.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.