oh-my-agent-commandcode 0.0.0-stage → 20.38.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/filter-test-output.sh +52 -0
- package/mods/oma.ts +347 -0
- package/package.json +30 -4
- package/skills/_shared/conditional/experiment-ledger.md +28 -0
- package/skills/_shared/conditional/exploration-loop.md +22 -0
- package/skills/_shared/conditional/quality-score.md +33 -0
- package/skills/_shared/core/anti-ai-prose.md +191 -0
- package/skills/_shared/core/api-contracts/README.md +28 -0
- package/skills/_shared/core/api-contracts/template.md +90 -0
- package/skills/_shared/core/clarification-protocol.md +23 -0
- package/skills/_shared/core/code-intelligence.md +46 -0
- package/skills/_shared/core/collaboration-policy.md +28 -0
- package/skills/_shared/core/common-checklist.md +28 -0
- package/skills/_shared/core/context-budget.md +22 -0
- package/skills/_shared/core/context-loading.md +34 -0
- package/skills/_shared/core/difficulty-guide.md +26 -0
- package/skills/_shared/core/execution-policy.md +31 -0
- package/skills/_shared/core/lessons-learned.md +33 -0
- package/skills/_shared/core/prompt-structure.md +18 -0
- package/skills/_shared/core/quality-principles.md +11 -0
- package/skills/_shared/core/session-metrics.md +35 -0
- package/skills/_shared/core/skill-routing.md +39 -0
- package/skills/_shared/core/test-approach.md +54 -0
- package/skills/_shared/core/vendor-detection.md +52 -0
- package/skills/_shared/runtime/memory-protocol.md +118 -0
- package/skills/_shared/runtime/result-contract.md +64 -0
- package/skills/_shared/ui-interaction-quality.md +133 -0
- package/skills/oma-academic-writing/SKILL.md +181 -0
- package/skills/oma-academic-writing/resources/abstract-guide.md +51 -0
- package/skills/oma-academic-writing/resources/academic-verb-tiers.md +42 -0
- package/skills/oma-academic-writing/resources/anti-ai-checklist.md +52 -0
- package/skills/oma-academic-writing/resources/hedging-guide.md +132 -0
- package/skills/oma-academic-writing/resources/sentence-structure-reference.md +151 -0
- package/skills/oma-architecture/SKILL.md +203 -0
- package/skills/oma-architecture/resources/api-evolution.md +41 -0
- package/skills/oma-architecture/resources/checklist.md +42 -0
- package/skills/oma-architecture/resources/execution-protocol.md +115 -0
- package/skills/oma-architecture/resources/methodology-selection.md +92 -0
- package/skills/oma-architecture/resources/migration-patterns.md +28 -0
- package/skills/oma-architecture/resources/output-templates.md +111 -0
- package/skills/oma-architecture/resources/stakeholder-synthesis.md +80 -0
- package/skills/oma-backend/SKILL.md +202 -0
- package/skills/oma-backend/resources/checklist.md +45 -0
- package/skills/oma-backend/resources/error-playbook.md +97 -0
- package/skills/oma-backend/resources/execution-protocol.md +39 -0
- package/skills/oma-backend/resources/orm-reference.md +61 -0
- package/skills/oma-backend/variants/node/api-template.ts +315 -0
- package/skills/oma-backend/variants/node/snippets.md +475 -0
- package/skills/oma-backend/variants/node/stack.yaml +21 -0
- package/skills/oma-backend/variants/node/tech-stack.md +33 -0
- package/skills/oma-backend/variants/python/api-template.py +378 -0
- package/skills/oma-backend/variants/python/snippets.md +199 -0
- package/skills/oma-backend/variants/python/stack.yaml +18 -0
- package/skills/oma-backend/variants/python/tech-stack.md +45 -0
- package/skills/oma-backend/variants/rust/api-template.rs +635 -0
- package/skills/oma-backend/variants/rust/snippets.md +471 -0
- package/skills/oma-backend/variants/rust/stack.yaml +20 -0
- package/skills/oma-backend/variants/rust/tech-stack.md +36 -0
- package/skills/oma-backend/variants/stack.schema.json +73 -0
- package/skills/oma-brainstorm/SKILL.md +166 -0
- package/skills/oma-brainstorm/resources/triz-lite.md +117 -0
- package/skills/oma-coordination/SKILL.md +171 -0
- package/skills/oma-db/SKILL.md +201 -0
- package/skills/oma-db/resources/anti-patterns.md +177 -0
- package/skills/oma-db/resources/checklist.md +67 -0
- package/skills/oma-db/resources/document-templates.md +107 -0
- package/skills/oma-db/resources/error-playbook.md +50 -0
- package/skills/oma-db/resources/examples.md +170 -0
- package/skills/oma-db/resources/execution-protocol.md +127 -0
- package/skills/oma-db/resources/iso-controls.md +98 -0
- package/skills/oma-db/resources/migration-playbook.md +93 -0
- package/skills/oma-db/resources/query-tuning.md +57 -0
- package/skills/oma-db/resources/vector-db.md +159 -0
- package/skills/oma-debug/SKILL.md +146 -0
- package/skills/oma-debug/resources/bug-report-template.md +327 -0
- package/skills/oma-debug/resources/checklist.md +31 -0
- package/skills/oma-debug/resources/common-patterns.md +734 -0
- package/skills/oma-debug/resources/debugging-checklist.md +364 -0
- package/skills/oma-debug/resources/error-playbook.md +94 -0
- package/skills/oma-debug/resources/execution-protocol.md +40 -0
- package/skills/oma-design/SKILL.md +135 -0
- package/skills/oma-design/resources/asset-strategy.md +34 -0
- package/skills/oma-design/resources/checklist.md +63 -0
- package/skills/oma-design/resources/data-grid.md +48 -0
- package/skills/oma-design/resources/design-md-spec.md +107 -0
- package/skills/oma-design/resources/design-tokens.md +53 -0
- package/skills/oma-design/resources/error-playbook.md +18 -0
- package/skills/oma-design/resources/execution-protocol.md +95 -0
- package/skills/oma-design/resources/external-design-skills.md +130 -0
- package/skills/oma-design/resources/getdesign-fetcher.md +104 -0
- package/skills/oma-design/resources/product-interface-review.md +59 -0
- package/skills/oma-design/resources/redesign-protocol.md +52 -0
- package/skills/oma-design/resources/stitch-integration.md +244 -0
- package/skills/oma-dev-workflow/SKILL.md +375 -0
- package/skills/oma-dev-workflow/resources/affected-checks.py +58 -0
- package/skills/oma-dev-workflow/resources/api-workflows.md +58 -0
- package/skills/oma-dev-workflow/resources/database-patterns.md +107 -0
- package/skills/oma-dev-workflow/resources/i18n-patterns.md +63 -0
- package/skills/oma-dev-workflow/resources/release-coordination.md +240 -0
- package/skills/oma-dev-workflow/resources/tooling-profiles.md +217 -0
- package/skills/oma-dev-workflow/resources/troubleshooting.md +78 -0
- package/skills/oma-dev-workflow/resources/validation-pipeline.md +246 -0
- package/skills/oma-docs/SKILL.md +77 -0
- package/skills/oma-docs/resources/commands.md +76 -0
- package/skills/oma-explanation/SKILL.md +212 -0
- package/skills/oma-explanation/resources/document-structure.md +115 -0
- package/skills/oma-explanation/resources/draft-format.md +199 -0
- package/skills/oma-explanation/resources/html-contract.md +102 -0
- package/skills/oma-frontend/SKILL.md +227 -0
- package/skills/oma-frontend/resources/angular-rules.md +110 -0
- package/skills/oma-frontend/resources/checklist.md +76 -0
- package/skills/oma-frontend/resources/error-playbook.md +125 -0
- package/skills/oma-frontend/resources/execution-protocol.md +46 -0
- package/skills/oma-frontend/resources/snippets.md +354 -0
- package/skills/oma-frontend/resources/specialized-visual-implementation.md +193 -0
- package/skills/oma-frontend/resources/tailwind-rules.md +83 -0
- package/skills/oma-frontend/resources/tech-stack.md +156 -0
- package/skills/oma-hwp/SKILL.md +166 -0
- package/skills/oma-hwp/config/hwp-config.yaml +35 -0
- package/skills/oma-hwp/resources/balanced-tables.ts +39 -0
- package/skills/oma-hwp/resources/execution-protocol.md +145 -0
- package/skills/oma-hwp/resources/flatten-tables.ts +101 -0
- package/skills/oma-hwp/resources/package.json +13 -0
- package/skills/oma-hwp/resources/troubleshooting.md +70 -0
- package/skills/oma-hwp/resources/tsconfig.json +10 -0
- package/skills/oma-hwp/resources/turndown-plugin-gfm.d.ts +9 -0
- package/skills/oma-image/SKILL.md +142 -0
- package/skills/oma-image/config/image-config.yaml +70 -0
- package/skills/oma-image/resources/checklist.md +18 -0
- package/skills/oma-image/resources/execution-protocol.md +117 -0
- package/skills/oma-image/resources/invocation.md +105 -0
- package/skills/oma-image/resources/prompt-tips.md +71 -0
- package/skills/oma-image/resources/vendor-matrix.md +90 -0
- package/skills/oma-market/SKILL.md +142 -0
- package/skills/oma-market/resources/checklist.md +12 -0
- package/skills/oma-market/resources/error-playbook.md +12 -0
- package/skills/oma-market/resources/execution-protocol.md +60 -0
- package/skills/oma-market/resources/frameworks/pestel.md +101 -0
- package/skills/oma-market/resources/frameworks/porters-5f.md +90 -0
- package/skills/oma-market/resources/frameworks/swot.md +61 -0
- package/skills/oma-market/resources/intent-rules.md +35 -0
- package/skills/oma-market/resources/output-laws.md +53 -0
- package/skills/oma-mobile/SKILL.md +168 -0
- package/skills/oma-mobile/resources/checklist.md +77 -0
- package/skills/oma-mobile/resources/error-playbook.md +198 -0
- package/skills/oma-mobile/resources/execution-protocol.md +47 -0
- package/skills/oma-mobile/resources/screen-template.dart +331 -0
- package/skills/oma-mobile/resources/screen-template.swift +239 -0
- package/skills/oma-mobile/resources/screen-template.tsx +179 -0
- package/skills/oma-mobile/resources/tech-stack.md +69 -0
- package/skills/oma-mobile/variants/README.md +45 -0
- package/skills/oma-mobile/variants/flutter/api-template.dart +461 -0
- package/skills/oma-mobile/variants/flutter/snippets.md +1353 -0
- package/skills/oma-mobile/variants/flutter/stack.yaml +21 -0
- package/skills/oma-mobile/variants/flutter/tech-stack.md +201 -0
- package/skills/oma-mobile/variants/kotlin-android/api-template.kt +182 -0
- package/skills/oma-mobile/variants/kotlin-android/snippets.md +871 -0
- package/skills/oma-mobile/variants/kotlin-android/stack.yaml +32 -0
- package/skills/oma-mobile/variants/kotlin-android/tech-stack.md +159 -0
- package/skills/oma-mobile/variants/react-native/api-template.ts +531 -0
- package/skills/oma-mobile/variants/react-native/snippets.md +1014 -0
- package/skills/oma-mobile/variants/react-native/stack.yaml +22 -0
- package/skills/oma-mobile/variants/react-native/tech-stack.md +192 -0
- package/skills/oma-mobile/variants/stack.schema.json +68 -0
- package/skills/oma-mobile/variants/swift-ios/api-template.swift +165 -0
- package/skills/oma-mobile/variants/swift-ios/snippets.md +1028 -0
- package/skills/oma-mobile/variants/swift-ios/stack.yaml +25 -0
- package/skills/oma-mobile/variants/swift-ios/tech-stack.md +174 -0
- package/skills/oma-observability/SKILL.md +225 -0
- package/skills/oma-observability/resources/anti-patterns.md +555 -0
- package/skills/oma-observability/resources/boundaries/cross-application.md +348 -0
- package/skills/oma-observability/resources/boundaries/multi-tenant.md +290 -0
- package/skills/oma-observability/resources/boundaries/release.md +372 -0
- package/skills/oma-observability/resources/boundaries/slo.md +225 -0
- package/skills/oma-observability/resources/checklist.md +123 -0
- package/skills/oma-observability/resources/examples.md +448 -0
- package/skills/oma-observability/resources/execution-protocol.md +166 -0
- package/skills/oma-observability/resources/incident-forensics.md +348 -0
- package/skills/oma-observability/resources/intent-rules.md +307 -0
- package/skills/oma-observability/resources/layers/L3-network.md +281 -0
- package/skills/oma-observability/resources/layers/L4-transport.md +299 -0
- package/skills/oma-observability/resources/layers/L7-application/crash-analytics.md +359 -0
- package/skills/oma-observability/resources/layers/L7-application/mobile-rum.md +278 -0
- package/skills/oma-observability/resources/layers/L7-application/waf.md +246 -0
- package/skills/oma-observability/resources/layers/L7-application/web-rum.md +350 -0
- package/skills/oma-observability/resources/layers/mesh.md +368 -0
- package/skills/oma-observability/resources/matrix.md +177 -0
- package/skills/oma-observability/resources/meta-observability.md +605 -0
- package/skills/oma-observability/resources/observability-as-code.md +666 -0
- package/skills/oma-observability/resources/signals/audit.md +310 -0
- package/skills/oma-observability/resources/signals/cost.md +303 -0
- package/skills/oma-observability/resources/signals/logs.md +263 -0
- package/skills/oma-observability/resources/signals/metrics.md +478 -0
- package/skills/oma-observability/resources/signals/privacy.md +351 -0
- package/skills/oma-observability/resources/signals/profiles.md +245 -0
- package/skills/oma-observability/resources/signals/traces.md +360 -0
- package/skills/oma-observability/resources/standards.md +214 -0
- package/skills/oma-observability/resources/transport/collector-topology.md +308 -0
- package/skills/oma-observability/resources/transport/otlp-grpc-vs-http.md +256 -0
- package/skills/oma-observability/resources/transport/sampling-recipes.md +297 -0
- package/skills/oma-observability/resources/transport/udp-statsd-mtu.md +167 -0
- package/skills/oma-observability/resources/vendor-categories.md +352 -0
- package/skills/oma-orchestration/SKILL.md +302 -0
- package/skills/oma-orchestration/config/cli-config.yaml +92 -0
- package/skills/oma-orchestration/resources/memory-schema.md +232 -0
- package/skills/oma-orchestration/resources/subagent-prompt-template.md +30 -0
- package/skills/oma-orchestration/scripts/parallel-run.sh +6 -0
- package/skills/oma-orchestration/scripts/spawn-agent.sh +5 -0
- package/skills/oma-orchestration/scripts/verify.sh +20 -0
- package/skills/oma-orchestration/templates/backend-task.md +19 -0
- package/skills/oma-orchestration/templates/debug-task.md +16 -0
- package/skills/oma-orchestration/templates/frontend-task.md +18 -0
- package/skills/oma-orchestration/templates/mobile-task.md +18 -0
- package/skills/oma-orchestration/templates/qa-task.md +16 -0
- package/skills/oma-orchestration/templates/tasks-example.yaml +15 -0
- package/skills/oma-pdf/SKILL.md +162 -0
- package/skills/oma-pdf/config/pdf-config.yaml +27 -0
- package/skills/oma-pdf/resources/execution-protocol.md +127 -0
- package/skills/oma-pm/SKILL.md +157 -0
- package/skills/oma-pm/resources/error-playbook.md +75 -0
- package/skills/oma-pm/resources/examples.md +439 -0
- package/skills/oma-pm/resources/execution-protocol.md +62 -0
- package/skills/oma-pm/resources/iso-planning.md +110 -0
- package/skills/oma-pm/resources/plan-phase-protocol.md +66 -0
- package/skills/oma-pm/resources/task-template.json +86 -0
- package/skills/oma-qa/SKILL.md +141 -0
- package/skills/oma-qa/resources/checklist.md +339 -0
- package/skills/oma-qa/resources/error-playbook.md +98 -0
- package/skills/oma-qa/resources/execution-protocol.md +169 -0
- package/skills/oma-qa/resources/iso-quality.md +82 -0
- package/skills/oma-qa/resources/verify-ship-protocol.md +116 -0
- package/skills/oma-recap/SKILL.md +245 -0
- package/skills/oma-recap/resources/output-formats.md +83 -0
- package/skills/oma-refactor/SKILL.md +184 -0
- package/skills/oma-refactor/resources/definition.md +108 -0
- package/skills/oma-refactor/resources/governance.md +33 -0
- package/skills/oma-refactor/resources/hotspots.py +44 -0
- package/skills/oma-refactor/resources/measurement.md +93 -0
- package/skills/oma-scholar/SKILL.md +276 -0
- package/skills/oma-scholar/config/scholar-config.yaml +79 -0
- package/skills/oma-scholar/resources/api-endpoints.md +174 -0
- package/skills/oma-scholar/resources/checklist.md +92 -0
- package/skills/oma-scholar/resources/execution-protocol.md +224 -0
- package/skills/oma-scholar/resources/fallback-providers.md +143 -0
- package/skills/oma-scholar/resources/setup-openalex.md +85 -0
- package/skills/oma-scholar/resources/sidecar-spec.md +341 -0
- package/skills/oma-scholar/resources/upstream-spec-cache.md +972 -0
- package/skills/oma-scm/SKILL.md +103 -0
- package/skills/oma-scm/resources/cm-operations.md +56 -0
- package/skills/oma-scm/resources/codeowners-playbook.md +45 -0
- package/skills/oma-scm/resources/conventional-commits.md +108 -0
- package/skills/oma-scm/resources/merge-risk.md +76 -0
- package/skills/oma-scm/resources/onboarding-risk-signals.md +71 -0
- package/skills/oma-search/SKILL.md +125 -0
- package/skills/oma-search/resources/checklist.md +36 -0
- package/skills/oma-search/resources/error-playbook.md +66 -0
- package/skills/oma-search/resources/examples.md +80 -0
- package/skills/oma-search/resources/execution-protocol.md +86 -0
- package/skills/oma-search/resources/intent-rules.md +47 -0
- package/skills/oma-search/resources/trust-registry.md +53 -0
- package/skills/oma-security/SKILL.md +172 -0
- package/skills/oma-security/resources/artex.md +108 -0
- package/skills/oma-security/resources/ci.md +134 -0
- package/skills/oma-security/resources/cisco-source.md +108 -0
- package/skills/oma-security/resources/deepsec-config.md +45 -0
- package/skills/oma-security/resources/deepsec-matchers.md +35 -0
- package/skills/oma-security/resources/deepsec-scanning.md +45 -0
- package/skills/oma-security/resources/deepsec-setup.md +41 -0
- package/skills/oma-security/resources/findings-contract.md +408 -0
- package/skills/oma-security/resources/mcp-scanning.md +122 -0
- package/skills/oma-security/resources/skill-scanning.md +106 -0
- package/skills/oma-security/resources/validation.md +71 -0
- package/skills/oma-skill-creation/SKILL.md +84 -0
- package/skills/oma-skill-creation/resources/prompt-evaluation.md +36 -0
- package/skills/oma-skill-creation/resources/ssl-lite-template.md +108 -0
- package/skills/oma-skill-creation/resources/validation-checklist.md +101 -0
- package/skills/oma-slide/SKILL.md +277 -0
- package/skills/oma-slide/resources/animation-patterns.md +245 -0
- package/skills/oma-slide/resources/assets/deck-stage.js +514 -0
- package/skills/oma-slide/resources/assets/viewport-base.css +257 -0
- package/skills/oma-slide/resources/canva-integration.md +203 -0
- package/skills/oma-slide/resources/checklist.md +46 -0
- package/skills/oma-slide/resources/design-doctrine.md +260 -0
- package/skills/oma-slide/resources/fixed-stage.md +232 -0
- package/skills/oma-slide/resources/generation-protocol.md +393 -0
- package/skills/oma-slide/resources/native-pptx.md +84 -0
- package/skills/oma-slide/resources/selection-index.json +461 -0
- package/skills/oma-slide/resources/style-presets.LICENSE +10 -0
- package/skills/oma-slide/resources/style-presets.md +520 -0
- package/skills/oma-tf-infra/SKILL.md +201 -0
- package/skills/oma-tf-infra/resources/checklist.md +53 -0
- package/skills/oma-tf-infra/resources/cost-optimization.md +574 -0
- package/skills/oma-tf-infra/resources/error-playbook.md +68 -0
- package/skills/oma-tf-infra/resources/examples.md +38 -0
- package/skills/oma-tf-infra/resources/execution-protocol.md +129 -0
- package/skills/oma-tf-infra/resources/iso-42001-infra.md +229 -0
- package/skills/oma-tf-infra/resources/multi-cloud-examples.md +241 -0
- package/skills/oma-tf-infra/resources/policy-testing-examples.md +332 -0
- package/skills/oma-translation/SKILL.md +229 -0
- package/skills/oma-translation/resources/anti-ai-patterns.md +104 -0
- package/skills/oma-translation/resources/lang/_template.md +73 -0
- package/skills/oma-translation/resources/lang/en.md +123 -0
- package/skills/oma-translation/resources/lang/ja.md +274 -0
- package/skills/oma-translation/resources/lang/ko.md +228 -0
- package/skills/oma-translation/resources/lang/zh.md +285 -0
- package/skills/oma-translation/resources/style-skills.md +104 -0
- package/skills/oma-translation/resources/translation-rubric.md +126 -0
- package/skills/oma-video/SKILL.md +229 -0
- package/skills/oma-video/config/video-config.yaml +60 -0
- package/skills/oma-video/resources/checklist.md +26 -0
- package/skills/oma-video/resources/execution-protocol.md +122 -0
- package/skills/oma-video/resources/existing-media.md +185 -0
- package/skills/oma-video/resources/hyperframes-authoring/README.md +44 -0
- package/skills/oma-video/resources/hyperframes-authoring/demo.md +31 -0
- package/skills/oma-video/resources/hyperframes-authoring/explainer.md +10 -0
- package/skills/oma-video/resources/hyperframes-authoring/shorts.md +10 -0
- package/skills/oma-video/resources/motion-principles.md +81 -0
- package/skills/oma-video/resources/mpt/driver.py +301 -0
- package/skills/oma-video/resources/prompt-tips.md +75 -0
- package/skills/oma-video/resources/script-schema.md +81 -0
- package/skills/oma-video/resources/sound-design.md +127 -0
- package/skills/oma-video/resources/strudel/package.json +11 -0
- package/skills/oma-video/resources/strudel/page.html +87 -0
- package/skills/oma-video/resources/strudel/render.mjs +295 -0
- package/skills/oma-video/resources/vendor-matrix.md +124 -0
- package/skills/oma-voice/SKILL.md +249 -0
- package/skills/oma-voice/config/voice-config.yaml +30 -0
- package/skills/oma-voice/resources/audio-processing.md +119 -0
- package/skills/oma-voice/resources/checklist.md +47 -0
- package/skills/oma-voice/resources/execution-protocol.md +215 -0
- package/skills/oma-voice/resources/prompt-tips.md +43 -0
- package/skills/oma-voice/resources/voice-matrix.md +51 -0
- package/skills/oma-voice/resources/voicestudio.md +87 -0
|
@@ -0,0 +1,1353 @@
|
|
|
1
|
+
# Mobile Agent - Code Snippets (Flutter)
|
|
2
|
+
|
|
3
|
+
Copy-paste ready patterns. Use these as starting points; adapt to the specific task.
|
|
4
|
+
Always cache decoded domain entities at the Repository layer — never at the Dio transport layer.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## §1. pubspec.yaml Dependencies
|
|
9
|
+
|
|
10
|
+
```yaml
|
|
11
|
+
# pubspec.yaml
|
|
12
|
+
name: my_app
|
|
13
|
+
description: Flutter app with Clean Architecture + Riverpod + Drift offline-first cache.
|
|
14
|
+
version: 1.0.0+1
|
|
15
|
+
|
|
16
|
+
environment:
|
|
17
|
+
sdk: ">=3.10.0 <4.0.0" # go_router 17 requires Dart >= 3.10
|
|
18
|
+
flutter: ">=3.44.0"
|
|
19
|
+
|
|
20
|
+
dependencies:
|
|
21
|
+
flutter:
|
|
22
|
+
sdk: flutter
|
|
23
|
+
|
|
24
|
+
# State management
|
|
25
|
+
flutter_riverpod: ^3.3.0
|
|
26
|
+
riverpod_annotation: ^4.0.0
|
|
27
|
+
|
|
28
|
+
# Navigation
|
|
29
|
+
go_router: ^17.3.0
|
|
30
|
+
|
|
31
|
+
# HTTP client
|
|
32
|
+
dio: ^5.4.3+1
|
|
33
|
+
pretty_dio_logger: ^1.4.0 # debug builds only; wrap in kDebugMode guard
|
|
34
|
+
|
|
35
|
+
# Local storage — offline cache (system of record for server-owned data)
|
|
36
|
+
drift: ^2.34.0
|
|
37
|
+
sqlite3_flutter_libs: ^0.5.24
|
|
38
|
+
path_provider: ^2.1.3
|
|
39
|
+
path: ^1.9.0
|
|
40
|
+
|
|
41
|
+
# Secrets
|
|
42
|
+
flutter_secure_storage: ^9.2.2
|
|
43
|
+
|
|
44
|
+
# Lightweight preferences
|
|
45
|
+
shared_preferences: ^2.3.2
|
|
46
|
+
|
|
47
|
+
# Value types / equality (optional but recommended for domain entities)
|
|
48
|
+
freezed_annotation: ^3.1.0
|
|
49
|
+
json_annotation: ^4.9.0
|
|
50
|
+
|
|
51
|
+
dev_dependencies:
|
|
52
|
+
flutter_test:
|
|
53
|
+
sdk: flutter
|
|
54
|
+
|
|
55
|
+
# Code generation
|
|
56
|
+
build_runner: ^2.4.11
|
|
57
|
+
riverpod_generator: ^4.0.0
|
|
58
|
+
drift_dev: ^2.34.0
|
|
59
|
+
go_router_builder: ^4.3.0
|
|
60
|
+
freezed: ^3.2.0
|
|
61
|
+
json_serializable: ^6.8.0
|
|
62
|
+
|
|
63
|
+
# Test mocking
|
|
64
|
+
mocktail: ^1.0.4
|
|
65
|
+
|
|
66
|
+
# Lint
|
|
67
|
+
flutter_lints: ^6.0.0
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Run code generation after adding packages:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
flutter pub get
|
|
74
|
+
dart run build_runner build --delete-conflicting-outputs
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## §2. GoRouter with Typed Routes
|
|
80
|
+
|
|
81
|
+
```dart
|
|
82
|
+
// lib/core/router/app_router.dart
|
|
83
|
+
import 'package:flutter/material.dart';
|
|
84
|
+
import 'package:go_router/go_router.dart';
|
|
85
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
86
|
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
87
|
+
import '../../features/todos/presentation/screens/todos_screen.dart';
|
|
88
|
+
import '../../features/todos/presentation/screens/todo_detail_screen.dart';
|
|
89
|
+
|
|
90
|
+
part 'app_router.g.dart';
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Typed route declarations — go_router_builder generates push()/go() helpers
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
@TypedGoRoute<TodosRoute>(
|
|
97
|
+
path: '/',
|
|
98
|
+
routes: [
|
|
99
|
+
TypedGoRoute<TodoDetailRoute>(path: 'todos/:id'),
|
|
100
|
+
],
|
|
101
|
+
)
|
|
102
|
+
class TodosRoute extends GoRouteData with $TodosRoute {
|
|
103
|
+
const TodosRoute();
|
|
104
|
+
@override
|
|
105
|
+
Widget build(BuildContext context, GoRouterState state) =>
|
|
106
|
+
const TodosScreen();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class TodoDetailRoute extends GoRouteData with $TodoDetailRoute {
|
|
110
|
+
const TodoDetailRoute({required this.id});
|
|
111
|
+
final int id;
|
|
112
|
+
@override
|
|
113
|
+
Widget build(BuildContext context, GoRouterState state) =>
|
|
114
|
+
TodoDetailScreen(todoId: id);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Router provider — singleton, lives as long as the ProviderScope
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
@Riverpod(keepAlive: true)
|
|
122
|
+
GoRouter appRouter(Ref ref) {
|
|
123
|
+
return GoRouter(
|
|
124
|
+
initialLocation: '/',
|
|
125
|
+
debugLogDiagnostics: true,
|
|
126
|
+
routes: $appRoutes, // generated list
|
|
127
|
+
redirect: (context, state) {
|
|
128
|
+
// Global auth guard example — replace with real auth state:
|
|
129
|
+
// final isAuthenticated = ref.read(authProvider).isAuthenticated;
|
|
130
|
+
// if (!isAuthenticated && state.matchedLocation != '/login') return '/login';
|
|
131
|
+
return null;
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Usage in a screen:
|
|
138
|
+
|
|
139
|
+
```dart
|
|
140
|
+
// Navigate to detail:
|
|
141
|
+
TodoDetailRoute(id: todo.id).push(context);
|
|
142
|
+
|
|
143
|
+
// Navigate replacing current:
|
|
144
|
+
const TodosRoute().go(context);
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## §3. Domain Entity + Repository Interface + Offline-First Implementation
|
|
150
|
+
|
|
151
|
+
### §3a. Domain Entity
|
|
152
|
+
|
|
153
|
+
```dart
|
|
154
|
+
// lib/features/todos/domain/todo.dart
|
|
155
|
+
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
156
|
+
|
|
157
|
+
part 'todo.freezed.dart';
|
|
158
|
+
|
|
159
|
+
/// Pure domain entity — no Drift, no Dio, no JSON annotations.
|
|
160
|
+
/// Equality and copyWith are generated by freezed.
|
|
161
|
+
/// Freezed 3 requires the class to be `abstract` (or `sealed`).
|
|
162
|
+
@freezed
|
|
163
|
+
abstract class Todo with _$Todo {
|
|
164
|
+
const factory Todo({
|
|
165
|
+
required int id,
|
|
166
|
+
required String title,
|
|
167
|
+
required bool completed,
|
|
168
|
+
required DateTime cachedAt, // populated by the Repository when storing
|
|
169
|
+
}) = _Todo;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### §3b. Repository Interface (domain layer)
|
|
174
|
+
|
|
175
|
+
```dart
|
|
176
|
+
// lib/features/todos/domain/todo_repository.dart
|
|
177
|
+
import 'todo.dart';
|
|
178
|
+
|
|
179
|
+
/// Abstract contract the presentation layer depends on.
|
|
180
|
+
/// Concrete implementations (online, offline-first, stub) swap behind this seam.
|
|
181
|
+
abstract interface class TodoRepository {
|
|
182
|
+
/// Emits a warm cached list immediately; cold loads await network and surface errors.
|
|
183
|
+
/// Callers never interact with Drift or Dio directly.
|
|
184
|
+
Stream<List<Todo>> watchAll();
|
|
185
|
+
/// Bypass TTL; complete only after the network result has been stored.
|
|
186
|
+
Future<void> refresh();
|
|
187
|
+
|
|
188
|
+
/// Returns the cached todo for [id] immediately (or null when absent),
|
|
189
|
+
/// revalidating from the network in the background only when it is stale.
|
|
190
|
+
/// A `Future` cannot re-emit — watchers of [watchAll] observe the refreshed
|
|
191
|
+
/// row via the Drift stream after a background revalidation completes.
|
|
192
|
+
Future<Todo?> findById(int id);
|
|
193
|
+
|
|
194
|
+
Future<Todo> create(String title);
|
|
195
|
+
Future<Todo> toggle(int id);
|
|
196
|
+
Future<void> delete(int id);
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### §3c. Drift Table + DAO (data layer — local cache)
|
|
201
|
+
|
|
202
|
+
```dart
|
|
203
|
+
// lib/core/database/app_database.dart
|
|
204
|
+
import 'dart:io';
|
|
205
|
+
import 'package:drift/drift.dart';
|
|
206
|
+
import 'package:drift/native.dart';
|
|
207
|
+
import 'package:path_provider/path_provider.dart';
|
|
208
|
+
import 'package:path/path.dart' as p;
|
|
209
|
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
210
|
+
import '../../features/todos/data/local/todos_dao.dart';
|
|
211
|
+
|
|
212
|
+
part 'app_database.g.dart';
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Todos cache table
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/// One row per server-owned Todo. `cachedAt` drives TTL checks.
|
|
219
|
+
class TodosTable extends Table {
|
|
220
|
+
IntColumn get id => integer()();
|
|
221
|
+
TextColumn get title => text()();
|
|
222
|
+
BoolColumn get completed => boolean().withDefault(const Constant(false))();
|
|
223
|
+
DateTimeColumn get cachedAt => dateTime()();
|
|
224
|
+
|
|
225
|
+
@override
|
|
226
|
+
Set<Column> get primaryKey => {id};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
@DriftDatabase(tables: [TodosTable], daos: [TodosDao])
|
|
230
|
+
class AppDatabase extends _$AppDatabase {
|
|
231
|
+
AppDatabase() : super(_openConnection());
|
|
232
|
+
AppDatabase.forTesting(super.executor); // pass NativeDatabase.memory() in tests
|
|
233
|
+
|
|
234
|
+
@override
|
|
235
|
+
int get schemaVersion => 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
LazyDatabase _openConnection() {
|
|
239
|
+
return LazyDatabase(() async {
|
|
240
|
+
final dir = await getApplicationDocumentsDirectory();
|
|
241
|
+
final file = File(p.join(dir.path, 'app.sqlite'));
|
|
242
|
+
return NativeDatabase.createInBackground(file);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// App-wide DB provider
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
@Riverpod(keepAlive: true)
|
|
251
|
+
AppDatabase appDatabase(Ref ref) {
|
|
252
|
+
final db = AppDatabase();
|
|
253
|
+
ref.onDispose(db.close);
|
|
254
|
+
return db;
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
```dart
|
|
259
|
+
// lib/features/todos/data/local/todos_dao.dart
|
|
260
|
+
import 'package:drift/drift.dart';
|
|
261
|
+
import '../../../../core/database/app_database.dart';
|
|
262
|
+
|
|
263
|
+
part 'todos_dao.g.dart';
|
|
264
|
+
|
|
265
|
+
@DriftAccessor(tables: [TodosTable])
|
|
266
|
+
class TodosDao extends DatabaseAccessor<AppDatabase> with _$TodosDaoMixin {
|
|
267
|
+
TodosDao(super.db);
|
|
268
|
+
|
|
269
|
+
/// Live stream — emits updated rows whenever Drift detects a change.
|
|
270
|
+
Stream<List<TodosTableData>> watchAll() => select(todosTable).watch();
|
|
271
|
+
|
|
272
|
+
/// Upsert a batch of rows (INSERT OR REPLACE). Additive only — does NOT
|
|
273
|
+
/// remove rows absent from [rows]. Use for seeding; use [replaceAll] to
|
|
274
|
+
/// mirror a full server list.
|
|
275
|
+
Future<void> upsertAll(List<TodosTableCompanion> rows) =>
|
|
276
|
+
batch((b) => b.insertAllOnConflictUpdate(todosTable, rows));
|
|
277
|
+
|
|
278
|
+
/// Replace the whole cached list with the fresh server set in ONE
|
|
279
|
+
/// transaction: delete rows the server no longer returns, then upsert the
|
|
280
|
+
/// rest. Without the delete step, server-side deletions linger as ghost
|
|
281
|
+
/// rows. `isNotIn([])` (empty fresh set) safely deletes all rows in Drift.
|
|
282
|
+
Future<void> replaceAll(List<TodosTableCompanion> rows) {
|
|
283
|
+
return transaction(() async {
|
|
284
|
+
final freshIds = rows.map((r) => r.id.value).toList();
|
|
285
|
+
await (delete(todosTable)..where((t) => t.id.isNotIn(freshIds))).go();
|
|
286
|
+
await batch((b) => b.insertAllOnConflictUpdate(todosTable, rows));
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/// Upsert a single row after create/toggle.
|
|
291
|
+
Future<void> upsertOne(TodosTableCompanion row) =>
|
|
292
|
+
into(todosTable).insertOnConflictUpdate(row);
|
|
293
|
+
|
|
294
|
+
Future<void> deleteById(int id) =>
|
|
295
|
+
(delete(todosTable)..where((t) => t.id.equals(id))).go();
|
|
296
|
+
|
|
297
|
+
Future<TodosTableData?> findById(int id) =>
|
|
298
|
+
(select(todosTable)..where((t) => t.id.equals(id))).getSingleOrNull();
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### §3d. Remote Data Source (data layer — Dio calls)
|
|
303
|
+
|
|
304
|
+
```dart
|
|
305
|
+
// lib/features/todos/data/remote/todos_remote_data_source.dart
|
|
306
|
+
import 'package:dio/dio.dart';
|
|
307
|
+
import '../dtos/todo_dto.dart';
|
|
308
|
+
|
|
309
|
+
/// All Dio calls for the /todos resource.
|
|
310
|
+
/// No caching here — transport concerns only.
|
|
311
|
+
class TodosRemoteDataSource {
|
|
312
|
+
TodosRemoteDataSource(this._dio);
|
|
313
|
+
final Dio _dio;
|
|
314
|
+
|
|
315
|
+
Future<List<TodoDto>> fetchAll() async {
|
|
316
|
+
final response = await _dio.get<List<dynamic>>('/todos');
|
|
317
|
+
return (response.data ?? [])
|
|
318
|
+
.map((json) => TodoDto.fromJson(json as Map<String, dynamic>))
|
|
319
|
+
.toList();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
Future<TodoDto> fetchById(int id) async {
|
|
323
|
+
final response = await _dio.get<Map<String, dynamic>>('/todos/$id');
|
|
324
|
+
return TodoDto.fromJson(response.data!);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
Future<TodoDto> create(String title) async {
|
|
328
|
+
final response = await _dio.post<Map<String, dynamic>>(
|
|
329
|
+
'/todos',
|
|
330
|
+
data: {'title': title},
|
|
331
|
+
);
|
|
332
|
+
return TodoDto.fromJson(response.data!);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
Future<TodoDto> toggle(int id) async {
|
|
336
|
+
final response = await _dio.patch<Map<String, dynamic>>('/todos/$id/toggle');
|
|
337
|
+
return TodoDto.fromJson(response.data!);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
Future<void> delete(int id) => _dio.delete<void>('/todos/$id');
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
```dart
|
|
345
|
+
// lib/features/todos/data/dtos/todo_dto.dart
|
|
346
|
+
import 'package:json_annotation/json_annotation.dart';
|
|
347
|
+
import '../../domain/todo.dart';
|
|
348
|
+
|
|
349
|
+
part 'todo_dto.g.dart';
|
|
350
|
+
|
|
351
|
+
/// JSON transfer object. Isolated from the domain entity.
|
|
352
|
+
@JsonSerializable()
|
|
353
|
+
class TodoDto {
|
|
354
|
+
const TodoDto({
|
|
355
|
+
required this.id,
|
|
356
|
+
required this.title,
|
|
357
|
+
required this.completed,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
final int id;
|
|
361
|
+
final String title;
|
|
362
|
+
final bool completed;
|
|
363
|
+
|
|
364
|
+
factory TodoDto.fromJson(Map<String, dynamic> json) => _$TodoDtoFromJson(json);
|
|
365
|
+
Map<String, dynamic> toJson() => _$TodoDtoToJson(this);
|
|
366
|
+
|
|
367
|
+
/// Map to domain entity; `cachedAt` is set by the Repository, not the DTO.
|
|
368
|
+
Todo toDomain({required DateTime cachedAt}) => Todo(
|
|
369
|
+
id: id,
|
|
370
|
+
title: title,
|
|
371
|
+
completed: completed,
|
|
372
|
+
cachedAt: cachedAt,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### §3e. Offline-First Repository Implementation (data layer)
|
|
378
|
+
|
|
379
|
+
```dart
|
|
380
|
+
// lib/features/todos/data/todo_repository_impl.dart
|
|
381
|
+
import 'package:dio/dio.dart';
|
|
382
|
+
import 'package:drift/drift.dart';
|
|
383
|
+
import '../../../core/database/app_database.dart';
|
|
384
|
+
import '../domain/todo.dart';
|
|
385
|
+
import '../domain/todo_repository.dart';
|
|
386
|
+
import 'dtos/todo_dto.dart';
|
|
387
|
+
import 'local/todos_dao.dart';
|
|
388
|
+
import 'remote/todos_remote_data_source.dart';
|
|
389
|
+
|
|
390
|
+
/// TTL for the todos list cache. Revalidate if the newest row is older than this.
|
|
391
|
+
const _kCacheTtl = Duration(minutes: 5);
|
|
392
|
+
|
|
393
|
+
/// Offline-first implementation of [TodoRepository].
|
|
394
|
+
///
|
|
395
|
+
/// Caching contract:
|
|
396
|
+
/// - [watchAll] maps a warm Drift stream to domain entities immediately.
|
|
397
|
+
/// A background revalidation triggers whenever the cache is stale (> TTL)
|
|
398
|
+
/// when warm; a cold cache waits for the network and surfaces failure. Drift re-emits after writes.
|
|
399
|
+
/// - Writes ([create], [toggle], [delete]) hit the network first, then upsert
|
|
400
|
+
/// or delete the local Drift row so the stream emits the change instantly.
|
|
401
|
+
/// - Cache key = logical operation, never URLs. TTL is explicit; no infinite cache.
|
|
402
|
+
/// - Secrets and user-authored data are NOT stored here — use flutter_secure_storage
|
|
403
|
+
/// or a separate Drift table with a different retention policy.
|
|
404
|
+
///
|
|
405
|
+
/// Note: staleness is evaluated at subscription time only. A long-lived
|
|
406
|
+
/// subscription revalidates when it is (re-)subscribed — e.g. on app
|
|
407
|
+
/// foreground or a `ref.invalidate` — not continuously while open. Drive
|
|
408
|
+
/// periodic refresh from the presentation layer (pull-to-refresh, an
|
|
409
|
+
/// app-lifecycle listener) if you need it while a screen stays mounted.
|
|
410
|
+
class TodoRepositoryImpl implements TodoRepository {
|
|
411
|
+
TodoRepositoryImpl({
|
|
412
|
+
required TodosDao dao,
|
|
413
|
+
required TodosRemoteDataSource remote,
|
|
414
|
+
}) : _dao = dao,
|
|
415
|
+
_remote = remote;
|
|
416
|
+
|
|
417
|
+
final TodosDao _dao;
|
|
418
|
+
final TodosRemoteDataSource _remote;
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// READ — stale-while-revalidate
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
@override
|
|
425
|
+
Stream<List<Todo>> watchAll() async* {
|
|
426
|
+
final snapshot = await _dao.watchAll().first;
|
|
427
|
+
final now = DateTime.now();
|
|
428
|
+
if (snapshot.isEmpty) {
|
|
429
|
+
// Cold-cache failures must reach Riverpod's error state.
|
|
430
|
+
await refresh();
|
|
431
|
+
} else if (snapshot.any((row) => now.difference(row.cachedAt) > _kCacheTtl)) {
|
|
432
|
+
// Only warm-cache revalidation may degrade to the stale value.
|
|
433
|
+
refresh().ignore();
|
|
434
|
+
}
|
|
435
|
+
yield* _dao.watchAll().map((rows) => rows.map(_rowToEntity).toList());
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
@override
|
|
439
|
+
Future<void> refresh() => _revalidate();
|
|
440
|
+
|
|
441
|
+
@override
|
|
442
|
+
Future<Todo?> findById(int id) async {
|
|
443
|
+
final cached = await _dao.findById(id);
|
|
444
|
+
final now = DateTime.now();
|
|
445
|
+
|
|
446
|
+
if (cached != null) {
|
|
447
|
+
// Serve the cached row immediately. Revalidate in the background only
|
|
448
|
+
// when it is stale; watchers of watchAll() pick up the refreshed row via
|
|
449
|
+
// the Drift stream. This Future returns the cached snapshot as-is.
|
|
450
|
+
if (now.difference(cached.cachedAt) > _kCacheTtl) {
|
|
451
|
+
_revalidateById(id, now).ignore();
|
|
452
|
+
}
|
|
453
|
+
return _rowToEntity(cached);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Cache miss — fetch from network and persist.
|
|
457
|
+
try {
|
|
458
|
+
final dto = await _remote.fetchById(id);
|
|
459
|
+
await _dao.upsertOne(_dtoToCompanion(dto, cachedAt: now));
|
|
460
|
+
return dto.toDomain(cachedAt: now);
|
|
461
|
+
} on DioException catch (e) {
|
|
462
|
+
if (e.response?.statusCode == 404) return null;
|
|
463
|
+
rethrow;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
Future<void> _revalidate() async {
|
|
468
|
+
final dtos = await _remote.fetchAll();
|
|
469
|
+
final now = DateTime.now();
|
|
470
|
+
await _dao.replaceAll(
|
|
471
|
+
dtos.map((dto) => _dtoToCompanion(dto, cachedAt: now)).toList(),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
Future<void> _revalidateById(int id, DateTime now) async {
|
|
476
|
+
try {
|
|
477
|
+
final dto = await _remote.fetchById(id);
|
|
478
|
+
await _dao.upsertOne(_dtoToCompanion(dto, cachedAt: now));
|
|
479
|
+
} catch (_) {
|
|
480
|
+
// Non-fatal — caller already received the cached value.
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
// WRITE — invalidate/update cache after success
|
|
486
|
+
// ---------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
@override
|
|
489
|
+
Future<Todo> create(String title) async {
|
|
490
|
+
final dto = await _remote.create(title);
|
|
491
|
+
final now = DateTime.now();
|
|
492
|
+
// Upsert the new row so watchAll() emits without waiting for a revalidation.
|
|
493
|
+
await _dao.upsertOne(_dtoToCompanion(dto, cachedAt: now));
|
|
494
|
+
return dto.toDomain(cachedAt: now);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
@override
|
|
498
|
+
Future<Todo> toggle(int id) async {
|
|
499
|
+
final dto = await _remote.toggle(id);
|
|
500
|
+
final now = DateTime.now();
|
|
501
|
+
await _dao.upsertOne(_dtoToCompanion(dto, cachedAt: now));
|
|
502
|
+
return dto.toDomain(cachedAt: now);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
@override
|
|
506
|
+
Future<void> delete(int id) async {
|
|
507
|
+
await _remote.delete(id);
|
|
508
|
+
// Remove the row so the stream emits the deletion immediately.
|
|
509
|
+
await _dao.deleteById(id);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// Helpers
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
Todo _rowToEntity(TodosTableData row) => Todo(
|
|
517
|
+
id: row.id,
|
|
518
|
+
title: row.title,
|
|
519
|
+
completed: row.completed,
|
|
520
|
+
cachedAt: row.cachedAt,
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
TodosTableCompanion _dtoToCompanion(TodoDto dto, {required DateTime cachedAt}) =>
|
|
524
|
+
TodosTableCompanion(
|
|
525
|
+
id: Value(dto.id),
|
|
526
|
+
title: Value(dto.title),
|
|
527
|
+
completed: Value(dto.completed),
|
|
528
|
+
cachedAt: Value(cachedAt),
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
---
|
|
534
|
+
|
|
535
|
+
## §4. Riverpod StreamNotifier Consuming the Repository Stream
|
|
536
|
+
|
|
537
|
+
```dart
|
|
538
|
+
// lib/features/todos/presentation/providers/todos_provider.dart
|
|
539
|
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
540
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
541
|
+
import '../../domain/todo.dart';
|
|
542
|
+
import '../../domain/todo_repository.dart';
|
|
543
|
+
import '../../../../core/di/providers.dart'; // todoRepositoryProvider
|
|
544
|
+
|
|
545
|
+
part 'todos_provider.g.dart';
|
|
546
|
+
|
|
547
|
+
/// Exposes the offline-first todo list.
|
|
548
|
+
///
|
|
549
|
+
/// Riverpod converts the Repository's Stream into AsyncValue<List<Todo>>.
|
|
550
|
+
/// The UI receives AsyncValue.loading() on first subscription, then
|
|
551
|
+
/// AsyncValue.data(cachedList) on the first Drift emission, then
|
|
552
|
+
/// AsyncValue.data(freshList) after network revalidation — without any
|
|
553
|
+
/// loading flicker if the cache is warm.
|
|
554
|
+
@riverpod
|
|
555
|
+
class TodosNotifier extends _$TodosNotifier {
|
|
556
|
+
@override
|
|
557
|
+
Stream<List<Todo>> build() {
|
|
558
|
+
final repo = ref.watch(todoRepositoryProvider);
|
|
559
|
+
return repo.watchAll();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
Future<void> create(String title) async {
|
|
563
|
+
final repo = ref.read(todoRepositoryProvider);
|
|
564
|
+
await repo.create(title);
|
|
565
|
+
// Stream auto-updates via Drift; no manual state mutation needed.
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
Future<void> toggle(int id) async {
|
|
569
|
+
final repo = ref.read(todoRepositoryProvider);
|
|
570
|
+
await repo.toggle(id);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
Future<void> delete(int id) async {
|
|
574
|
+
final repo = ref.read(todoRepositoryProvider);
|
|
575
|
+
await repo.delete(id);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
```dart
|
|
581
|
+
// lib/core/di/providers.dart
|
|
582
|
+
import 'package:dio/dio.dart';
|
|
583
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
584
|
+
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
585
|
+
import '../database/app_database.dart';
|
|
586
|
+
import '../network/dio_client.dart';
|
|
587
|
+
import '../../features/todos/data/local/todos_dao.dart';
|
|
588
|
+
import '../../features/todos/data/remote/todos_remote_data_source.dart';
|
|
589
|
+
import '../../features/todos/data/todo_repository_impl.dart';
|
|
590
|
+
import '../../features/todos/domain/todo_repository.dart';
|
|
591
|
+
|
|
592
|
+
part 'providers.g.dart';
|
|
593
|
+
|
|
594
|
+
@Riverpod(keepAlive: true)
|
|
595
|
+
Dio dio(Ref ref) => buildDioClient(ref);
|
|
596
|
+
|
|
597
|
+
@Riverpod(keepAlive: true)
|
|
598
|
+
TodosDao todosDao(Ref ref) {
|
|
599
|
+
final db = ref.watch(appDatabaseProvider);
|
|
600
|
+
return db.todosDao;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
@Riverpod(keepAlive: true)
|
|
604
|
+
TodosRemoteDataSource todosRemoteDataSource(Ref ref) =>
|
|
605
|
+
TodosRemoteDataSource(ref.watch(dioProvider));
|
|
606
|
+
|
|
607
|
+
@Riverpod(keepAlive: true)
|
|
608
|
+
TodoRepository todoRepository(Ref ref) => TodoRepositoryImpl(
|
|
609
|
+
dao: ref.watch(todosDaoProvider),
|
|
610
|
+
remote: ref.watch(todosRemoteDataSourceProvider),
|
|
611
|
+
);
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
## §5. Dio Client with Auth + Retry + Logging Interceptors
|
|
617
|
+
|
|
618
|
+
```dart
|
|
619
|
+
// lib/core/network/dio_client.dart
|
|
620
|
+
import 'package:dio/dio.dart';
|
|
621
|
+
import 'package:flutter/foundation.dart';
|
|
622
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
623
|
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
624
|
+
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
|
|
625
|
+
|
|
626
|
+
/// Builds the app-wide Dio instance with interceptors.
|
|
627
|
+
/// Called once from the DI layer; never instantiated in features directly.
|
|
628
|
+
Dio buildDioClient(Ref ref) {
|
|
629
|
+
final dio = Dio(
|
|
630
|
+
BaseOptions(
|
|
631
|
+
baseUrl: const String.fromEnvironment(
|
|
632
|
+
'API_BASE_URL',
|
|
633
|
+
defaultValue: 'https://api.example.com',
|
|
634
|
+
),
|
|
635
|
+
connectTimeout: const Duration(seconds: 15),
|
|
636
|
+
receiveTimeout: const Duration(seconds: 30),
|
|
637
|
+
headers: {'Content-Type': 'application/json'},
|
|
638
|
+
),
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
// Transport-layer interceptors only — no caching here.
|
|
642
|
+
dio.interceptors.addAll([
|
|
643
|
+
AuthInterceptor(const FlutterSecureStorage()),
|
|
644
|
+
RetryInterceptor(dio),
|
|
645
|
+
if (kDebugMode) PrettyDioLogger(requestBody: true, responseBody: true),
|
|
646
|
+
]);
|
|
647
|
+
|
|
648
|
+
return dio;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
// Auth interceptor — injects Bearer token from secure storage
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
class AuthInterceptor extends Interceptor {
|
|
656
|
+
AuthInterceptor(this._storage);
|
|
657
|
+
final FlutterSecureStorage _storage;
|
|
658
|
+
|
|
659
|
+
@override
|
|
660
|
+
Future<void> onRequest(
|
|
661
|
+
RequestOptions options,
|
|
662
|
+
RequestInterceptorHandler handler,
|
|
663
|
+
) async {
|
|
664
|
+
final token = await _storage.read(key: 'access_token');
|
|
665
|
+
if (token != null) {
|
|
666
|
+
options.headers['Authorization'] = 'Bearer $token';
|
|
667
|
+
}
|
|
668
|
+
handler.next(options);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
@override
|
|
672
|
+
void onError(DioException err, ErrorInterceptorHandler handler) {
|
|
673
|
+
if (err.response?.statusCode == 401) {
|
|
674
|
+
// TODO: trigger token refresh or sign-out flow via ref
|
|
675
|
+
// For now, pass the error through to the caller.
|
|
676
|
+
}
|
|
677
|
+
handler.next(err);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// Retry interceptor — exponential back-off on transient failures
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
class RetryInterceptor extends Interceptor {
|
|
686
|
+
RetryInterceptor(this._dio, {this.maxRetries = 3});
|
|
687
|
+
final Dio _dio;
|
|
688
|
+
final int maxRetries;
|
|
689
|
+
|
|
690
|
+
@override
|
|
691
|
+
Future<void> onError(
|
|
692
|
+
DioException err,
|
|
693
|
+
ErrorInterceptorHandler handler,
|
|
694
|
+
) async {
|
|
695
|
+
final attempt = err.requestOptions.extra['_retryCount'] as int? ?? 0;
|
|
696
|
+
|
|
697
|
+
// Only retry idempotent methods. Retrying a POST on receiveTimeout can
|
|
698
|
+
// duplicate a non-idempotent write (the server may have applied the first
|
|
699
|
+
// request before the response timed out).
|
|
700
|
+
const idempotentMethods = {'GET', 'HEAD', 'PUT', 'DELETE'};
|
|
701
|
+
final isIdempotent =
|
|
702
|
+
idempotentMethods.contains(err.requestOptions.method.toUpperCase());
|
|
703
|
+
final isRetriable = isIdempotent &&
|
|
704
|
+
(err.type == DioExceptionType.connectionTimeout ||
|
|
705
|
+
err.type == DioExceptionType.receiveTimeout ||
|
|
706
|
+
err.type == DioExceptionType.connectionError);
|
|
707
|
+
|
|
708
|
+
if (isRetriable && attempt < maxRetries) {
|
|
709
|
+
await Future.delayed(Duration(milliseconds: 200 * (1 << attempt)));
|
|
710
|
+
try {
|
|
711
|
+
final opts = err.requestOptions
|
|
712
|
+
..extra['_retryCount'] = attempt + 1;
|
|
713
|
+
final response = await _dio.fetch<dynamic>(opts);
|
|
714
|
+
handler.resolve(response);
|
|
715
|
+
return;
|
|
716
|
+
} catch (_) {
|
|
717
|
+
// Fall through to next handler on retry failure.
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
handler.next(err);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
---
|
|
726
|
+
|
|
727
|
+
## §6. Todos Screen (ConsumerStatefulWidget)
|
|
728
|
+
|
|
729
|
+
```dart
|
|
730
|
+
// lib/features/todos/presentation/screens/todos_screen.dart
|
|
731
|
+
import 'package:flutter/material.dart';
|
|
732
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
733
|
+
import '../../domain/todo.dart';
|
|
734
|
+
import '../providers/todos_provider.dart';
|
|
735
|
+
import '../widgets/todo_list_item.dart';
|
|
736
|
+
import '../../../../shared/widgets/empty_state.dart';
|
|
737
|
+
import '../../../../shared/widgets/error_view.dart';
|
|
738
|
+
|
|
739
|
+
class TodosScreen extends ConsumerStatefulWidget {
|
|
740
|
+
const TodosScreen({super.key});
|
|
741
|
+
|
|
742
|
+
@override
|
|
743
|
+
ConsumerState<TodosScreen> createState() => _TodosScreenState();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
class _TodosScreenState extends ConsumerState<TodosScreen> {
|
|
747
|
+
final _titleController = TextEditingController();
|
|
748
|
+
|
|
749
|
+
@override
|
|
750
|
+
void dispose() {
|
|
751
|
+
_titleController.dispose(); // always dispose controllers
|
|
752
|
+
super.dispose();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
@override
|
|
756
|
+
Widget build(BuildContext context) {
|
|
757
|
+
final todosAsync = ref.watch(todosNotifierProvider);
|
|
758
|
+
|
|
759
|
+
return Scaffold(
|
|
760
|
+
appBar: AppBar(
|
|
761
|
+
title: const Text('Todos'),
|
|
762
|
+
actions: [
|
|
763
|
+
IconButton(
|
|
764
|
+
icon: const Icon(Icons.refresh),
|
|
765
|
+
tooltip: 'Refresh',
|
|
766
|
+
onPressed: () async {
|
|
767
|
+
try {
|
|
768
|
+
await ref.read(todoRepositoryProvider).refresh();
|
|
769
|
+
} catch (error) {
|
|
770
|
+
if (!context.mounted) return;
|
|
771
|
+
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Refresh failed: $error')));
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
),
|
|
775
|
+
],
|
|
776
|
+
),
|
|
777
|
+
body: todosAsync.when(
|
|
778
|
+
// Warm cache is available — render immediately, no spinner.
|
|
779
|
+
// Riverpod passes skipLoadingOnRefresh: true by default in Riverpod 3.x
|
|
780
|
+
// so a background revalidation does not re-show the loading state.
|
|
781
|
+
data: (todos) => todos.isEmpty
|
|
782
|
+
? const EmptyState(
|
|
783
|
+
icon: Icons.check_circle_outline,
|
|
784
|
+
title: 'No todos yet',
|
|
785
|
+
subtitle: 'Add your first todo below.',
|
|
786
|
+
)
|
|
787
|
+
: _TodoList(todos: todos),
|
|
788
|
+
loading: () => const Center(child: CircularProgressIndicator()),
|
|
789
|
+
error: (err, _) => ErrorView(
|
|
790
|
+
message: err.toString(),
|
|
791
|
+
onRetry: () => ref.invalidate(todosNotifierProvider),
|
|
792
|
+
),
|
|
793
|
+
),
|
|
794
|
+
floatingActionButton: FloatingActionButton(
|
|
795
|
+
onPressed: _showAddDialog,
|
|
796
|
+
tooltip: 'Add todo',
|
|
797
|
+
child: const Icon(Icons.add),
|
|
798
|
+
),
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
void _showAddDialog() {
|
|
803
|
+
_titleController.clear();
|
|
804
|
+
showDialog<void>(
|
|
805
|
+
context: context,
|
|
806
|
+
builder: (ctx) => AlertDialog(
|
|
807
|
+
title: const Text('New Todo'),
|
|
808
|
+
content: TextField(
|
|
809
|
+
controller: _titleController,
|
|
810
|
+
autofocus: true,
|
|
811
|
+
decoration: const InputDecoration(
|
|
812
|
+
labelText: 'Title',
|
|
813
|
+
hintText: 'What needs to be done?',
|
|
814
|
+
),
|
|
815
|
+
onSubmitted: (_) => _submitAdd(ctx),
|
|
816
|
+
),
|
|
817
|
+
actions: [
|
|
818
|
+
TextButton(
|
|
819
|
+
onPressed: () => Navigator.of(ctx).pop(),
|
|
820
|
+
child: const Text('Cancel'),
|
|
821
|
+
),
|
|
822
|
+
FilledButton(
|
|
823
|
+
onPressed: () => _submitAdd(ctx),
|
|
824
|
+
child: const Text('Add'),
|
|
825
|
+
),
|
|
826
|
+
],
|
|
827
|
+
),
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
Future<void> _submitAdd(BuildContext dialogCtx) async {
|
|
832
|
+
final title = _titleController.text.trim();
|
|
833
|
+
if (title.isEmpty) return;
|
|
834
|
+
Navigator.of(dialogCtx).pop();
|
|
835
|
+
await ref.read(todosNotifierProvider.notifier).create(title);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ---------------------------------------------------------------------------
|
|
840
|
+
// Internal list widget
|
|
841
|
+
// ---------------------------------------------------------------------------
|
|
842
|
+
|
|
843
|
+
class _TodoList extends ConsumerWidget {
|
|
844
|
+
const _TodoList({required this.todos});
|
|
845
|
+
final List<Todo> todos;
|
|
846
|
+
|
|
847
|
+
@override
|
|
848
|
+
Widget build(BuildContext context, WidgetRef ref) {
|
|
849
|
+
return RefreshIndicator(
|
|
850
|
+
onRefresh: () async {
|
|
851
|
+
try {
|
|
852
|
+
await ref.read(todoRepositoryProvider).refresh();
|
|
853
|
+
} catch (error) {
|
|
854
|
+
if (!context.mounted) return;
|
|
855
|
+
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Refresh failed: $error')));
|
|
856
|
+
}
|
|
857
|
+
},
|
|
858
|
+
child: ListView.builder(
|
|
859
|
+
itemCount: todos.length,
|
|
860
|
+
itemBuilder: (ctx, i) => TodoListItem(
|
|
861
|
+
todo: todos[i],
|
|
862
|
+
onToggle: () =>
|
|
863
|
+
ref.read(todosNotifierProvider.notifier).toggle(todos[i].id),
|
|
864
|
+
onDelete: () =>
|
|
865
|
+
ref.read(todosNotifierProvider.notifier).delete(todos[i].id),
|
|
866
|
+
),
|
|
867
|
+
),
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
---
|
|
874
|
+
|
|
875
|
+
## §7. Shared Widgets (EmptyState, ErrorView, TodoListItem)
|
|
876
|
+
|
|
877
|
+
```dart
|
|
878
|
+
// lib/shared/widgets/empty_state.dart
|
|
879
|
+
import 'package:flutter/material.dart';
|
|
880
|
+
|
|
881
|
+
class EmptyState extends StatelessWidget {
|
|
882
|
+
const EmptyState({
|
|
883
|
+
super.key,
|
|
884
|
+
required this.icon,
|
|
885
|
+
required this.title,
|
|
886
|
+
this.subtitle,
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
final IconData icon;
|
|
890
|
+
final String title;
|
|
891
|
+
final String? subtitle;
|
|
892
|
+
|
|
893
|
+
@override
|
|
894
|
+
Widget build(BuildContext context) {
|
|
895
|
+
final theme = Theme.of(context);
|
|
896
|
+
return Center(
|
|
897
|
+
child: Padding(
|
|
898
|
+
padding: const EdgeInsets.all(32),
|
|
899
|
+
child: Column(
|
|
900
|
+
mainAxisSize: MainAxisSize.min,
|
|
901
|
+
children: [
|
|
902
|
+
Icon(icon, size: 64, color: theme.colorScheme.secondary),
|
|
903
|
+
const SizedBox(height: 16),
|
|
904
|
+
Text(title, style: theme.textTheme.titleLarge),
|
|
905
|
+
if (subtitle != null) ...[
|
|
906
|
+
const SizedBox(height: 8),
|
|
907
|
+
Text(
|
|
908
|
+
subtitle!,
|
|
909
|
+
style: theme.textTheme.bodyMedium?.copyWith(
|
|
910
|
+
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
911
|
+
),
|
|
912
|
+
textAlign: TextAlign.center,
|
|
913
|
+
),
|
|
914
|
+
],
|
|
915
|
+
],
|
|
916
|
+
),
|
|
917
|
+
),
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
```dart
|
|
924
|
+
// lib/shared/widgets/error_view.dart
|
|
925
|
+
import 'package:flutter/material.dart';
|
|
926
|
+
|
|
927
|
+
class ErrorView extends StatelessWidget {
|
|
928
|
+
const ErrorView({super.key, required this.message, this.onRetry});
|
|
929
|
+
|
|
930
|
+
final String message;
|
|
931
|
+
final VoidCallback? onRetry;
|
|
932
|
+
|
|
933
|
+
@override
|
|
934
|
+
Widget build(BuildContext context) {
|
|
935
|
+
final theme = Theme.of(context);
|
|
936
|
+
return Center(
|
|
937
|
+
child: Padding(
|
|
938
|
+
padding: const EdgeInsets.all(24),
|
|
939
|
+
child: Column(
|
|
940
|
+
mainAxisSize: MainAxisSize.min,
|
|
941
|
+
children: [
|
|
942
|
+
Icon(Icons.error_outline, size: 64, color: theme.colorScheme.error),
|
|
943
|
+
const SizedBox(height: 16),
|
|
944
|
+
Text(
|
|
945
|
+
'Something went wrong',
|
|
946
|
+
style: theme.textTheme.titleLarge,
|
|
947
|
+
textAlign: TextAlign.center,
|
|
948
|
+
),
|
|
949
|
+
const SizedBox(height: 8),
|
|
950
|
+
Text(
|
|
951
|
+
message,
|
|
952
|
+
style: theme.textTheme.bodyMedium?.copyWith(
|
|
953
|
+
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
954
|
+
),
|
|
955
|
+
textAlign: TextAlign.center,
|
|
956
|
+
),
|
|
957
|
+
if (onRetry != null) ...[
|
|
958
|
+
const SizedBox(height: 24),
|
|
959
|
+
FilledButton.icon(
|
|
960
|
+
onPressed: onRetry,
|
|
961
|
+
icon: const Icon(Icons.refresh),
|
|
962
|
+
label: const Text('Try Again'),
|
|
963
|
+
),
|
|
964
|
+
],
|
|
965
|
+
],
|
|
966
|
+
),
|
|
967
|
+
),
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
```dart
|
|
974
|
+
// lib/features/todos/presentation/widgets/todo_list_item.dart
|
|
975
|
+
import 'package:flutter/material.dart';
|
|
976
|
+
import '../../domain/todo.dart';
|
|
977
|
+
|
|
978
|
+
class TodoListItem extends StatelessWidget {
|
|
979
|
+
const TodoListItem({
|
|
980
|
+
super.key,
|
|
981
|
+
required this.todo,
|
|
982
|
+
required this.onToggle,
|
|
983
|
+
required this.onDelete,
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
final Todo todo;
|
|
987
|
+
final VoidCallback onToggle;
|
|
988
|
+
final VoidCallback onDelete;
|
|
989
|
+
|
|
990
|
+
@override
|
|
991
|
+
Widget build(BuildContext context) {
|
|
992
|
+
return ListTile(
|
|
993
|
+
leading: Checkbox(
|
|
994
|
+
value: todo.completed,
|
|
995
|
+
onChanged: (_) => onToggle(),
|
|
996
|
+
),
|
|
997
|
+
title: Text(
|
|
998
|
+
todo.title,
|
|
999
|
+
style: todo.completed
|
|
1000
|
+
? TextStyle(
|
|
1001
|
+
decoration: TextDecoration.lineThrough,
|
|
1002
|
+
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
|
|
1003
|
+
)
|
|
1004
|
+
: null,
|
|
1005
|
+
),
|
|
1006
|
+
trailing: IconButton(
|
|
1007
|
+
icon: const Icon(Icons.delete_outline),
|
|
1008
|
+
tooltip: 'Delete',
|
|
1009
|
+
onPressed: onDelete,
|
|
1010
|
+
),
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
---
|
|
1017
|
+
|
|
1018
|
+
## §8. App Entry Point (main.dart)
|
|
1019
|
+
|
|
1020
|
+
```dart
|
|
1021
|
+
// lib/main.dart
|
|
1022
|
+
import 'package:flutter/material.dart';
|
|
1023
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
1024
|
+
import 'core/router/app_router.dart';
|
|
1025
|
+
import 'core/theme/app_theme.dart';
|
|
1026
|
+
|
|
1027
|
+
void main() {
|
|
1028
|
+
WidgetsFlutterBinding.ensureInitialized();
|
|
1029
|
+
runApp(
|
|
1030
|
+
// ProviderScope is the Riverpod root — must wrap the entire widget tree.
|
|
1031
|
+
const ProviderScope(child: MyApp()),
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
class MyApp extends ConsumerWidget {
|
|
1036
|
+
const MyApp({super.key});
|
|
1037
|
+
|
|
1038
|
+
@override
|
|
1039
|
+
Widget build(BuildContext context, WidgetRef ref) {
|
|
1040
|
+
final router = ref.watch(appRouterProvider);
|
|
1041
|
+
|
|
1042
|
+
return MaterialApp.router(
|
|
1043
|
+
title: 'My App',
|
|
1044
|
+
theme: AppTheme.light(),
|
|
1045
|
+
darkTheme: AppTheme.dark(),
|
|
1046
|
+
routerConfig: router,
|
|
1047
|
+
debugShowCheckedModeBanner: false,
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
```
|
|
1052
|
+
|
|
1053
|
+
```dart
|
|
1054
|
+
// lib/core/theme/app_theme.dart
|
|
1055
|
+
import 'package:flutter/material.dart';
|
|
1056
|
+
|
|
1057
|
+
/// Material 3 light/dark themes seeded from a single brand color.
|
|
1058
|
+
/// Kept intentionally small — extend with typography/component themes per project.
|
|
1059
|
+
abstract final class AppTheme {
|
|
1060
|
+
static const _seed = Color(0xFF1A2332); // Deep Ocean Navy
|
|
1061
|
+
|
|
1062
|
+
static ThemeData light() => _base(Brightness.light);
|
|
1063
|
+
static ThemeData dark() => _base(Brightness.dark);
|
|
1064
|
+
|
|
1065
|
+
static ThemeData _base(Brightness brightness) {
|
|
1066
|
+
final scheme = ColorScheme.fromSeed(
|
|
1067
|
+
seedColor: _seed,
|
|
1068
|
+
brightness: brightness,
|
|
1069
|
+
);
|
|
1070
|
+
return ThemeData(
|
|
1071
|
+
useMaterial3: true,
|
|
1072
|
+
colorScheme: scheme,
|
|
1073
|
+
scaffoldBackgroundColor: scheme.surface,
|
|
1074
|
+
appBarTheme: AppBarTheme(
|
|
1075
|
+
backgroundColor: scheme.surface,
|
|
1076
|
+
foregroundColor: scheme.onSurface,
|
|
1077
|
+
centerTitle: false,
|
|
1078
|
+
),
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
---
|
|
1085
|
+
|
|
1086
|
+
## §9. flutter_test + mocktail Unit Tests
|
|
1087
|
+
|
|
1088
|
+
```dart
|
|
1089
|
+
// test/features/todos/todos_provider_test.dart
|
|
1090
|
+
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
1091
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
1092
|
+
import 'package:mocktail/mocktail.dart';
|
|
1093
|
+
import 'package:my_app/features/todos/domain/todo.dart';
|
|
1094
|
+
import 'package:my_app/features/todos/domain/todo_repository.dart';
|
|
1095
|
+
import 'package:my_app/features/todos/presentation/providers/todos_provider.dart';
|
|
1096
|
+
import 'package:my_app/core/di/providers.dart';
|
|
1097
|
+
|
|
1098
|
+
// ---------------------------------------------------------------------------
|
|
1099
|
+
// Mock — depends on the abstract repository interface, not the Dio/Drift impl
|
|
1100
|
+
// ---------------------------------------------------------------------------
|
|
1101
|
+
|
|
1102
|
+
class MockTodoRepository extends Mock implements TodoRepository {}
|
|
1103
|
+
|
|
1104
|
+
// ---------------------------------------------------------------------------
|
|
1105
|
+
// Helpers
|
|
1106
|
+
// ---------------------------------------------------------------------------
|
|
1107
|
+
|
|
1108
|
+
final _now = DateTime(2025, 1, 1);
|
|
1109
|
+
|
|
1110
|
+
Todo _makeTodo(int id, {bool completed = false}) => Todo(
|
|
1111
|
+
id: id,
|
|
1112
|
+
title: 'Todo $id',
|
|
1113
|
+
completed: completed,
|
|
1114
|
+
cachedAt: _now,
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
// ---------------------------------------------------------------------------
|
|
1118
|
+
// Tests
|
|
1119
|
+
// ---------------------------------------------------------------------------
|
|
1120
|
+
|
|
1121
|
+
void main() {
|
|
1122
|
+
late MockTodoRepository mockRepo;
|
|
1123
|
+
late ProviderContainer container;
|
|
1124
|
+
|
|
1125
|
+
setUp(() {
|
|
1126
|
+
mockRepo = MockTodoRepository();
|
|
1127
|
+
container = ProviderContainer(
|
|
1128
|
+
overrides: [
|
|
1129
|
+
// Swap the real repository for the mock at the DI seam.
|
|
1130
|
+
todoRepositoryProvider.overrideWithValue(mockRepo),
|
|
1131
|
+
],
|
|
1132
|
+
);
|
|
1133
|
+
addTearDown(container.dispose);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
test('todosNotifierProvider emits data from repository stream', () async {
|
|
1137
|
+
final todos = [_makeTodo(1), _makeTodo(2)];
|
|
1138
|
+
when(() => mockRepo.watchAll()).thenAnswer(
|
|
1139
|
+
(_) => Stream.value(todos),
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
// Wait for the async stream to emit.
|
|
1143
|
+
final result = await container
|
|
1144
|
+
.read(todosNotifierProvider.future);
|
|
1145
|
+
|
|
1146
|
+
expect(result, equals(todos));
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
test('todosNotifierProvider emits empty list when repository returns []', () async {
|
|
1150
|
+
when(() => mockRepo.watchAll()).thenAnswer((_) => Stream.value([]));
|
|
1151
|
+
|
|
1152
|
+
final result = await container.read(todosNotifierProvider.future);
|
|
1153
|
+
|
|
1154
|
+
expect(result, isEmpty);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test('todosNotifierProvider propagates repository errors', () async {
|
|
1158
|
+
when(() => mockRepo.watchAll())
|
|
1159
|
+
.thenAnswer((_) => Stream.error(Exception('network error')));
|
|
1160
|
+
|
|
1161
|
+
await expectLater(
|
|
1162
|
+
container.read(todosNotifierProvider.future),
|
|
1163
|
+
throwsA(isA<Exception>()),
|
|
1164
|
+
);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
test('create calls repository and stream auto-updates', () async {
|
|
1168
|
+
final initial = [_makeTodo(1)];
|
|
1169
|
+
final afterCreate = [_makeTodo(1), _makeTodo(2, completed: false)];
|
|
1170
|
+
var streamIndex = 0;
|
|
1171
|
+
final streams = [Stream.value(initial), Stream.value(afterCreate)];
|
|
1172
|
+
|
|
1173
|
+
when(() => mockRepo.watchAll())
|
|
1174
|
+
.thenAnswer((_) => streams[streamIndex++ % streams.length]);
|
|
1175
|
+
when(() => mockRepo.create(any()))
|
|
1176
|
+
.thenAnswer((_) async => _makeTodo(2));
|
|
1177
|
+
|
|
1178
|
+
await container.read(todosNotifierProvider.notifier).create('Todo 2');
|
|
1179
|
+
|
|
1180
|
+
verify(() => mockRepo.create('Todo 2')).called(1);
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
```dart
|
|
1186
|
+
// test/features/todos/todo_repository_impl_test.dart
|
|
1187
|
+
// Tests the real TodoRepositoryImpl with an in-memory Drift database.
|
|
1188
|
+
import 'dart:async';
|
|
1189
|
+
|
|
1190
|
+
import 'package:drift/drift.dart' show Value; // Value: drift/native.dart does not export it
|
|
1191
|
+
import 'package:drift/native.dart';
|
|
1192
|
+
import 'package:flutter_test/flutter_test.dart';
|
|
1193
|
+
import 'package:mocktail/mocktail.dart';
|
|
1194
|
+
import 'package:my_app/core/database/app_database.dart';
|
|
1195
|
+
import 'package:my_app/features/todos/data/dtos/todo_dto.dart';
|
|
1196
|
+
import 'package:my_app/features/todos/data/local/todos_dao.dart';
|
|
1197
|
+
import 'package:my_app/features/todos/data/remote/todos_remote_data_source.dart';
|
|
1198
|
+
import 'package:my_app/features/todos/data/todo_repository_impl.dart';
|
|
1199
|
+
|
|
1200
|
+
class MockTodosRemoteDataSource extends Mock implements TodosRemoteDataSource {}
|
|
1201
|
+
|
|
1202
|
+
void main() {
|
|
1203
|
+
late AppDatabase db;
|
|
1204
|
+
late TodosDao dao;
|
|
1205
|
+
late MockTodosRemoteDataSource mockRemote;
|
|
1206
|
+
late TodoRepositoryImpl repo;
|
|
1207
|
+
|
|
1208
|
+
setUp(() {
|
|
1209
|
+
db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
1210
|
+
dao = db.todosDao;
|
|
1211
|
+
mockRemote = MockTodosRemoteDataSource();
|
|
1212
|
+
repo = TodoRepositoryImpl(dao: dao, remote: mockRemote);
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
tearDown(() => db.close());
|
|
1216
|
+
|
|
1217
|
+
test('watchAll emits cached rows immediately then revalidates', () async {
|
|
1218
|
+
final now = DateTime.now();
|
|
1219
|
+
// Pre-populate the cache with a stale entry.
|
|
1220
|
+
// `.insert()` takes RAW values for required columns (id, title, cachedAt)
|
|
1221
|
+
// and Value() only for defaulted/nullable ones (completed).
|
|
1222
|
+
await dao.upsertAll([
|
|
1223
|
+
TodosTableCompanion.insert(
|
|
1224
|
+
id: 1,
|
|
1225
|
+
title: 'Cached todo',
|
|
1226
|
+
cachedAt: now.subtract(const Duration(hours: 1)), // older than TTL
|
|
1227
|
+
completed: const Value(false),
|
|
1228
|
+
),
|
|
1229
|
+
]);
|
|
1230
|
+
|
|
1231
|
+
// Gate the remote fetch behind a Completer so revalidation cannot race
|
|
1232
|
+
// ahead of the first (cached) emission. Without the gate, if the upsert
|
|
1233
|
+
// lands before the stream is subscribed, both emissions coalesce into one
|
|
1234
|
+
// and `.take(2)` hangs. The test releases the gate after emission #1,
|
|
1235
|
+
// guaranteeing two distinct emissions deterministically.
|
|
1236
|
+
final gate = Completer<void>();
|
|
1237
|
+
final freshDtos = [
|
|
1238
|
+
TodoDto(id: 1, title: 'Fresh todo', completed: false),
|
|
1239
|
+
];
|
|
1240
|
+
when(() => mockRemote.fetchAll()).thenAnswer((_) async {
|
|
1241
|
+
await gate.future;
|
|
1242
|
+
return freshDtos;
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
final emissions = <int>[];
|
|
1246
|
+
await for (final todos in repo.watchAll().take(2)) {
|
|
1247
|
+
emissions.add(todos.length);
|
|
1248
|
+
if (emissions.length == 1) {
|
|
1249
|
+
gate.complete(); // cached emission seen — now let revalidation run
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// First emission: cached row; second: after revalidation.
|
|
1254
|
+
expect(emissions, [1, 1]);
|
|
1255
|
+
verify(() => mockRemote.fetchAll()).called(1);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
test('cold-cache network failure reaches the stream error state', () async {
|
|
1259
|
+
when(() => mockRemote.fetchAll()).thenThrow(Exception('offline'));
|
|
1260
|
+
await expectLater(repo.watchAll().first, throwsA(isA<Exception>()));
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
test('manual refresh bypasses a warm TTL and awaits the fresh write', () async {
|
|
1264
|
+
await dao.upsertAll([
|
|
1265
|
+
TodosTableCompanion.insert(id: 1, title: 'Cached', cachedAt: DateTime.now()),
|
|
1266
|
+
]);
|
|
1267
|
+
final started = Completer<void>();
|
|
1268
|
+
final release = Completer<void>();
|
|
1269
|
+
when(() => mockRemote.fetchAll()).thenAnswer((_) async {
|
|
1270
|
+
started.complete();
|
|
1271
|
+
await release.future;
|
|
1272
|
+
return [TodoDto(id: 1, title: 'Fresh', completed: false)];
|
|
1273
|
+
});
|
|
1274
|
+
var completed = false;
|
|
1275
|
+
final refreshing = repo.refresh().then((_) => completed = true);
|
|
1276
|
+
await started.future;
|
|
1277
|
+
expect(completed, isFalse);
|
|
1278
|
+
release.complete();
|
|
1279
|
+
await refreshing;
|
|
1280
|
+
expect((await dao.watchAll().first).single.title, 'Fresh');
|
|
1281
|
+
verify(() => mockRemote.fetchAll()).called(1);
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
test('create upserts into Drift and stream emits new item', () async {
|
|
1285
|
+
when(() => mockRemote.create(any()))
|
|
1286
|
+
.thenAnswer((_) async => TodoDto(id: 99, title: 'New', completed: false));
|
|
1287
|
+
|
|
1288
|
+
await repo.create('New');
|
|
1289
|
+
|
|
1290
|
+
final rows = await dao.watchAll().first;
|
|
1291
|
+
expect(rows.any((r) => r.id == 99), isTrue);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
test('delete removes row from Drift after remote success', () async {
|
|
1295
|
+
final now = DateTime.now();
|
|
1296
|
+
await dao.upsertAll([
|
|
1297
|
+
TodosTableCompanion.insert(
|
|
1298
|
+
id: 5,
|
|
1299
|
+
title: 'To delete',
|
|
1300
|
+
cachedAt: now,
|
|
1301
|
+
completed: const Value(false),
|
|
1302
|
+
),
|
|
1303
|
+
]);
|
|
1304
|
+
when(() => mockRemote.delete(5)).thenAnswer((_) async {});
|
|
1305
|
+
|
|
1306
|
+
await repo.delete(5);
|
|
1307
|
+
|
|
1308
|
+
final rows = await dao.watchAll().first;
|
|
1309
|
+
expect(rows.any((r) => r.id == 5), isFalse);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
```
|
|
1313
|
+
|
|
1314
|
+
---
|
|
1315
|
+
|
|
1316
|
+
## §10. Cache Rules Recap
|
|
1317
|
+
|
|
1318
|
+
> These rules are mandatory. Violating any of them is a defect, not a style choice.
|
|
1319
|
+
|
|
1320
|
+
1. **Repository layer, not transport.** Cache decoded `Todo` domain entities inside `TodoRepositoryImpl`. Do NOT use a Dio interceptor (e.g. `dio_http_cache`, `dio_cache_interceptor`) as the system of record for domain reads — interceptors operate on raw bytes and are invisible to the domain and its tests.
|
|
1321
|
+
2. **Stale-while-revalidate.** `watchAll()` emits the locally-cached Drift rows immediately (zero latency for the user), then silently revalidates from the network and re-emits via the Drift stream. No loading spinner on cache hits.
|
|
1322
|
+
3. **Explicit TTL.** Every cache row stores `cachedAt`. The Repository compares it against `_kCacheTtl` to decide whether to revalidate. Never use an implicit or infinite TTL.
|
|
1323
|
+
4. **Key = operation + params, never URLs.** The Drift table primary key (`id`) and the DAO operation name (e.g., `watchAll`, `findById`) are the effective cache keys. Changing the backend URL does not invalidate the cache; only writing new data or deleting rows does.
|
|
1324
|
+
5. **Invalidate on write.** `create` → upsert new row. `toggle` → upsert updated row. `delete` → delete row. After each mutation the Drift stream emits the new state to all active subscribers without any explicit refresh call in the UI.
|
|
1325
|
+
6. **Boundaries.** The Drift cache table is for transient server-owned data. Durable user-authored data (drafts, settings) → separate Drift table with its own retention policy. Secrets (tokens) → `flutter_secure_storage`. Do not use the cache table as a system of record.
|
|
1326
|
+
|
|
1327
|
+
---
|
|
1328
|
+
|
|
1329
|
+
## §11. Maestro E2E Flow
|
|
1330
|
+
|
|
1331
|
+
<!-- oma-docs:ignore-start -->
|
|
1332
|
+
End-to-end UI test driven by Maestro. Lives at `maestro/todos_flow.yaml` (project
|
|
1333
|
+
root, see tech-stack `Testing`). Run with `maestro test maestro/todos_flow.yaml`.
|
|
1334
|
+
<!-- oma-docs:ignore-end -->
|
|
1335
|
+
|
|
1336
|
+
```yaml
|
|
1337
|
+
# maestro/todos_flow.yaml
|
|
1338
|
+
# Launch → assert list screen → add a todo → assert it appears.
|
|
1339
|
+
appId: com.example.my_app
|
|
1340
|
+
---
|
|
1341
|
+
- launchApp:
|
|
1342
|
+
clearState: true
|
|
1343
|
+
- assertVisible: "Todos" # AppBar title confirms the list screen loaded
|
|
1344
|
+
- tapOn: "Add todo" # matches the FAB tooltip/accessibility label
|
|
1345
|
+
- assertVisible: "New Todo" # add dialog opened
|
|
1346
|
+
- tapOn: "Title" # focus the text field
|
|
1347
|
+
- inputText: "Buy milk"
|
|
1348
|
+
- tapOn: "Add" # FilledButton submits the dialog
|
|
1349
|
+
- assertVisible: "Buy milk" # new item rendered in the list
|
|
1350
|
+
```
|
|
1351
|
+
|
|
1352
|
+
Use text selectors for the tooltip/accessibility label above. The `id` selector
|
|
1353
|
+
requires an actual `Semantics(identifier: ...)`; a tooltip is not a resource ID.
|