oh-my-agent-commandcode 0.0.0-stage → 20.38.4
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,1014 @@
|
|
|
1
|
+
# Mobile Agent - Code Snippets (React Native)
|
|
2
|
+
|
|
3
|
+
Copy-paste ready patterns. Use these as starting points; adapt to the specific task.
|
|
4
|
+
Screens and components **never** call axios directly — they consume hooks built on TanStack Query.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. Package Dependencies
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
// package.json (relevant dependencies)
|
|
12
|
+
{
|
|
13
|
+
"dependencies": {
|
|
14
|
+
// Core (RN 0.86 defaults to the New Architecture; 0.82+ is New-Arch only)
|
|
15
|
+
"react": "^19.0.0",
|
|
16
|
+
"react-native": "^0.86.0",
|
|
17
|
+
|
|
18
|
+
// Navigation (v7 is the current stable line)
|
|
19
|
+
"@react-navigation/native": "^7.3.10",
|
|
20
|
+
"@react-navigation/native-stack": "^7.3.10",
|
|
21
|
+
"@react-navigation/bottom-tabs": "^7.3.10",
|
|
22
|
+
"react-native-screens": "^4.26.2",
|
|
23
|
+
"react-native-safe-area-context": "^5.8.0",
|
|
24
|
+
|
|
25
|
+
// HTTP transport
|
|
26
|
+
"axios": "^1.7.7",
|
|
27
|
+
"axios-retry": "^4.5.0",
|
|
28
|
+
|
|
29
|
+
// Server-state cache + offline persistence
|
|
30
|
+
"@tanstack/react-query": "^5.101.2",
|
|
31
|
+
"@tanstack/query-sync-storage-persister": "^5.101.2",
|
|
32
|
+
"@tanstack/react-query-persist-client": "^5.101.2",
|
|
33
|
+
|
|
34
|
+
// Network status (drives onlineManager + useNetworkStatus)
|
|
35
|
+
"@react-native-community/netinfo": "^11.4.1",
|
|
36
|
+
|
|
37
|
+
// Local storage (v4 is the Nitro module; requires RN 0.76+)
|
|
38
|
+
"react-native-mmkv": "^4.3.2",
|
|
39
|
+
"react-native-nitro-modules": "0.35.9", // MMKV 4.3.2 uses this in its development dependencies
|
|
40
|
+
|
|
41
|
+
// Secrets (choose one)
|
|
42
|
+
"expo-secure-store": "^13.0.0",
|
|
43
|
+
// -- OR (bare RN) --
|
|
44
|
+
"react-native-keychain": "^10.0.0",
|
|
45
|
+
|
|
46
|
+
// Client-state
|
|
47
|
+
"zustand": "^5.0.14"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"typescript": "^5.5.4",
|
|
51
|
+
"@types/react": "^19.0.0",
|
|
52
|
+
"jest": "^29.7.0",
|
|
53
|
+
// Matchers (toBeVisible, etc.) ship inside @testing-library/react-native >=12.4,
|
|
54
|
+
// so no separate matcher package is needed. React Native ships its own types too.
|
|
55
|
+
"@testing-library/react-native": "^14.0.1",
|
|
56
|
+
"babel-jest": "^29.7.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
// tsconfig.json (strict baseline)
|
|
63
|
+
{
|
|
64
|
+
"compilerOptions": {
|
|
65
|
+
"strict": true,
|
|
66
|
+
"target": "ES2022",
|
|
67
|
+
"lib": ["ES2022"],
|
|
68
|
+
"jsx": "react-native",
|
|
69
|
+
"moduleResolution": "bundler",
|
|
70
|
+
"baseUrl": ".",
|
|
71
|
+
"paths": {
|
|
72
|
+
"@api/*": ["src/api/*"],
|
|
73
|
+
"@features/*": ["src/features/*"],
|
|
74
|
+
"@shared/*": ["src/shared/*"],
|
|
75
|
+
"@navigation/*": ["src/navigation/*"],
|
|
76
|
+
"@store/*": ["src/store/*"]
|
|
77
|
+
},
|
|
78
|
+
"skipLibCheck": true
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 2. QueryClient + MMKV Persister Setup
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
// src/shared/utils/storage.ts
|
|
89
|
+
// Canonical MMKV singletons. Every module that needs MMKV imports from here —
|
|
90
|
+
// never call `createMMKV(...)` anywhere else. MMKV stores plain text unless an
|
|
91
|
+
// encryptionKey is passed, so it holds NON-SECRET data only (prefs, offline
|
|
92
|
+
// flags, the query cache). Secrets live in the Keychain-backed auth store (§10).
|
|
93
|
+
|
|
94
|
+
import { createMMKV } from 'react-native-mmkv';
|
|
95
|
+
|
|
96
|
+
export const storage = createMMKV({ id: 'app-storage' });
|
|
97
|
+
|
|
98
|
+
// Separate instance namespaced for the query cache to avoid key collisions.
|
|
99
|
+
export const queryStorage = createMMKV({ id: 'query-cache' });
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// src/api/queryClient.ts
|
|
104
|
+
// Creates the single QueryClient, the MMKV cache persister, and wires
|
|
105
|
+
// onlineManager to NetInfo. Import this module once in App.tsx — never create
|
|
106
|
+
// per-component QueryClients.
|
|
107
|
+
|
|
108
|
+
import { QueryClient, onlineManager } from '@tanstack/react-query';
|
|
109
|
+
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
|
|
110
|
+
import NetInfo from '@react-native-community/netinfo';
|
|
111
|
+
import { queryStorage } from '@shared/utils/storage';
|
|
112
|
+
|
|
113
|
+
// Drive TanStack Query's online state from the real network status so paused
|
|
114
|
+
// mutations resume and stale queries refetch the moment connectivity returns.
|
|
115
|
+
onlineManager.setEventListener((setOnline) =>
|
|
116
|
+
NetInfo.addEventListener((state) => setOnline(!!state.isConnected)),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Persister adapter: TanStack Query serialises/deserialises its cache as a
|
|
120
|
+
// single JSON string under the key below. MMKV provides synchronous access so
|
|
121
|
+
// the cache hydrates before the first render, enabling true offline-first UX.
|
|
122
|
+
let cacheSessionVersion = 0;
|
|
123
|
+
export const createAccountPersister = (accountId: string) => {
|
|
124
|
+
const version = cacheSessionVersion;
|
|
125
|
+
return createSyncStoragePersister({
|
|
126
|
+
key: `query-cache:${accountId}`,
|
|
127
|
+
storage: {
|
|
128
|
+
getItem: (key) => queryStorage.getString(key) ?? null,
|
|
129
|
+
setItem: (key, value) => {
|
|
130
|
+
if (version === cacheSessionVersion) queryStorage.set(key, value);
|
|
131
|
+
},
|
|
132
|
+
removeItem: (key) => queryStorage.remove(key),
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const createQueryClient = () => new QueryClient({
|
|
138
|
+
defaultOptions: {
|
|
139
|
+
queries: {
|
|
140
|
+
// Data older than 60 s triggers a background revalidation on next mount.
|
|
141
|
+
staleTime: 60_000,
|
|
142
|
+
// gcTime MUST be >= the persister's maxAge (see App.tsx below), or the
|
|
143
|
+
// in-memory entry is garbage-collected before the persisted copy can be
|
|
144
|
+
// restored — which silently defeats "survives app restart". 24h matches
|
|
145
|
+
// persistQueryClient's default maxAge.
|
|
146
|
+
gcTime: 1000 * 60 * 60 * 24,
|
|
147
|
+
// Retry failed queries up to 3 times with exponential back-off.
|
|
148
|
+
retry: 3,
|
|
149
|
+
// Do not refetch when the window regains focus (mobile has no browser focus).
|
|
150
|
+
refetchOnWindowFocus: false,
|
|
151
|
+
},
|
|
152
|
+
mutations: {
|
|
153
|
+
retry: 0,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
export let queryClient = createQueryClient();
|
|
158
|
+
export async function resetAccountQueryCache(accountId: string | null): Promise<void> {
|
|
159
|
+
cacheSessionVersion += 1; // block delayed disk writes from the retired session
|
|
160
|
+
await queryClient.cancelQueries();
|
|
161
|
+
queryClient.clear();
|
|
162
|
+
if (accountId) await createAccountPersister(accountId).removeClient();
|
|
163
|
+
queryClient = createQueryClient(); // old callbacks retain an unmounted client
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// src/App.tsx (provider wiring — abridged)
|
|
170
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
171
|
+
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
|
|
172
|
+
import { NavigationContainer } from '@react-navigation/native';
|
|
173
|
+
import { queryClient, createAccountPersister } from '@api/queryClient';
|
|
174
|
+
import { RootNavigator } from '@navigation/RootNavigator';
|
|
175
|
+
import { hydrateAuth, useAuthStore } from '@store/authStore';
|
|
176
|
+
import { LoadingView } from '@shared/components/LoadingView';
|
|
177
|
+
|
|
178
|
+
// Bump on breaking changes to any cached shape to discard the persisted cache.
|
|
179
|
+
// Keep it stable across a release (e.g. the app version string).
|
|
180
|
+
const CACHE_BUSTER = '1.0.0';
|
|
181
|
+
|
|
182
|
+
export default function App() {
|
|
183
|
+
const [ready, setReady] = useState(false);
|
|
184
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
185
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
186
|
+
const persister = useMemo(() => accountId ? createAccountPersister(accountId) : null, [accountId, sessionVersion]);
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
hydrateAuth().catch(() => useAuthStore.getState().clearToken()).finally(() => setReady(true));
|
|
189
|
+
}, []);
|
|
190
|
+
if (!ready) return <LoadingView label="Restoring session" />;
|
|
191
|
+
if (!accountId || !persister) return <NavigationContainer><RootNavigator /></NavigationContainer>;
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
// PersistQueryClientProvider restores the MMKV-persisted cache before the
|
|
195
|
+
// first render, so screens show stale data instantly while revalidating.
|
|
196
|
+
// maxAge caps how long a persisted cache is trusted; it must be <= the
|
|
197
|
+
// QueryClient's gcTime (24h) or entries GC out before they can be restored.
|
|
198
|
+
<PersistQueryClientProvider
|
|
199
|
+
key={`${accountId}:${sessionVersion}`}
|
|
200
|
+
client={queryClient}
|
|
201
|
+
persistOptions={{
|
|
202
|
+
persister,
|
|
203
|
+
maxAge: 1000 * 60 * 60 * 24,
|
|
204
|
+
buster: CACHE_BUSTER,
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
<NavigationContainer>
|
|
208
|
+
<RootNavigator />
|
|
209
|
+
</NavigationContainer>
|
|
210
|
+
</PersistQueryClientProvider>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 3. Axios Instance with Auth Interceptor
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
// src/api/client.ts
|
|
221
|
+
// Singleton axios instance — the ONLY place in the codebase that creates an
|
|
222
|
+
// axios instance. All src/api/*.ts functions import from here.
|
|
223
|
+
|
|
224
|
+
import axios, {
|
|
225
|
+
type AxiosInstance,
|
|
226
|
+
type AxiosRequestConfig,
|
|
227
|
+
type InternalAxiosRequestConfig,
|
|
228
|
+
type AxiosResponse,
|
|
229
|
+
type AxiosError,
|
|
230
|
+
} from 'axios';
|
|
231
|
+
import axiosRetry from 'axios-retry';
|
|
232
|
+
import { useAuthStore } from '@store/authStore';
|
|
233
|
+
|
|
234
|
+
const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'https://api.example.com';
|
|
235
|
+
|
|
236
|
+
export const apiClient: AxiosInstance = axios.create({
|
|
237
|
+
baseURL: BASE_URL,
|
|
238
|
+
timeout: 15_000,
|
|
239
|
+
headers: { 'Content-Type': 'application/json' },
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// --- Request interceptor: inject bearer token ---
|
|
243
|
+
// Read synchronously from the in-memory auth store (hydrated from the Keychain
|
|
244
|
+
// at app start, §10). Secrets NEVER live in plain-text MMKV.
|
|
245
|
+
export type SessionRequestConfig = AxiosRequestConfig & { _sessionVersion: number; _accountId: string | null };
|
|
246
|
+
type SessionRequest = InternalAxiosRequestConfig & { _sessionVersion?: number; _accountId?: string | null; _retried?: boolean };
|
|
247
|
+
apiClient.interceptors.request.use((config: SessionRequest) => {
|
|
248
|
+
config._sessionVersion ??= useAuthStore.getState().sessionVersion;
|
|
249
|
+
if (!('_accountId' in config)) config._accountId = useAuthStore.getState().accountId;
|
|
250
|
+
if (config._sessionVersion !== useAuthStore.getState().sessionVersion ||
|
|
251
|
+
config._accountId !== useAuthStore.getState().accountId) {
|
|
252
|
+
throw new axios.CanceledError('Request belongs to a retired session');
|
|
253
|
+
}
|
|
254
|
+
const token = useAuthStore.getState().accessToken;
|
|
255
|
+
if (token) {
|
|
256
|
+
config.headers.set('Authorization', `Bearer ${token}`);
|
|
257
|
+
}
|
|
258
|
+
return config;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// --- Single-flight token refresh ---
|
|
262
|
+
// Only one refresh is ever in flight; concurrent 401s await the same promise
|
|
263
|
+
// and then replay their original request exactly once.
|
|
264
|
+
let refreshPromise: { version: number; promise: Promise<string | null> } | null = null;
|
|
265
|
+
|
|
266
|
+
async function refreshAccessToken(): Promise<string | null> {
|
|
267
|
+
const version = useAuthStore.getState().sessionVersion;
|
|
268
|
+
try {
|
|
269
|
+
// Bare axios (no interceptors/retry) so the refresh can't recurse on itself.
|
|
270
|
+
const { data } = await axios.post<{ accessToken: string }>(
|
|
271
|
+
`${BASE_URL}/auth/refresh`,
|
|
272
|
+
{},
|
|
273
|
+
{ withCredentials: true }, // refresh token rides as an httpOnly cookie
|
|
274
|
+
);
|
|
275
|
+
if (!await useAuthStore.getState().updateToken(data.accessToken, version)) return null;
|
|
276
|
+
return data.accessToken;
|
|
277
|
+
} catch {
|
|
278
|
+
await useAuthStore.getState().clearToken(version);
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- Response interceptor: refresh once on 401, else logout ---
|
|
284
|
+
apiClient.interceptors.response.use(
|
|
285
|
+
(response: AxiosResponse) => response,
|
|
286
|
+
async (error: AxiosError) => {
|
|
287
|
+
const original = error.config as
|
|
288
|
+
| SessionRequest
|
|
289
|
+
| undefined;
|
|
290
|
+
|
|
291
|
+
if (error.response?.status === 401 && original && !original._retried &&
|
|
292
|
+
!original.signal?.aborted && useAuthStore.getState().isAuthenticated &&
|
|
293
|
+
original._sessionVersion === useAuthStore.getState().sessionVersion) {
|
|
294
|
+
original._retried = true;
|
|
295
|
+
const version = useAuthStore.getState().sessionVersion;
|
|
296
|
+
if (!refreshPromise || refreshPromise.version !== version) {
|
|
297
|
+
const promise = refreshAccessToken().finally(() => {
|
|
298
|
+
if (refreshPromise?.promise === promise) refreshPromise = null;
|
|
299
|
+
});
|
|
300
|
+
refreshPromise = { version, promise };
|
|
301
|
+
}
|
|
302
|
+
const newToken = await refreshPromise.promise;
|
|
303
|
+
if (newToken && version === useAuthStore.getState().sessionVersion && !original.signal?.aborted) {
|
|
304
|
+
original.headers.set('Authorization', `Bearer ${newToken}`);
|
|
305
|
+
return apiClient(original); // replay the original request once
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return Promise.reject(error);
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// --- Retry: idempotent requests only, 3 attempts, exponential back-off ---
|
|
313
|
+
axiosRetry(apiClient, {
|
|
314
|
+
retries: 3,
|
|
315
|
+
retryCondition: (error) =>
|
|
316
|
+
['get', 'head', 'options', 'put', 'delete'].includes(error.config?.method?.toLowerCase() ?? '') &&
|
|
317
|
+
axiosRetry.isNetworkOrIdempotentRequestError(error) &&
|
|
318
|
+
error.response?.status !== 401,
|
|
319
|
+
retryDelay: axiosRetry.exponentialDelay,
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## 4. Typed API Data Module (src/api/todos.ts)
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
// src/api/todos.ts
|
|
329
|
+
// Typed axios functions for the /todos resource.
|
|
330
|
+
// These are PURE DATA FUNCTIONS — they return decoded TypeScript objects and
|
|
331
|
+
// know nothing about React, hooks, or the query cache. They are the transport
|
|
332
|
+
// seam: screens never import from this file directly, only through query/mutation
|
|
333
|
+
// hooks (src/features/todos/queries.ts and mutations.ts).
|
|
334
|
+
|
|
335
|
+
import { apiClient, type SessionRequestConfig } from './client';
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Domain types
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
export interface Todo {
|
|
342
|
+
id: string;
|
|
343
|
+
title: string;
|
|
344
|
+
completed: boolean;
|
|
345
|
+
createdAt: string;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export interface CreateTodoInput {
|
|
349
|
+
title: string;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// API functions
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
/** Fetch all todos for the authenticated user. */
|
|
357
|
+
export async function fetchTodos(accountId: string | null, sessionVersion: number, signal?: AbortSignal): Promise<Todo[]> {
|
|
358
|
+
const config: SessionRequestConfig = { _accountId: accountId, _sessionVersion: sessionVersion, signal };
|
|
359
|
+
const { data } = await apiClient.get<Todo[]>('/todos', config);
|
|
360
|
+
return data;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Fetch a single todo by ID. */
|
|
364
|
+
export async function fetchTodo(id: string, accountId: string | null, sessionVersion: number, signal?: AbortSignal): Promise<Todo> {
|
|
365
|
+
const config: SessionRequestConfig = { _accountId: accountId, _sessionVersion: sessionVersion, signal };
|
|
366
|
+
const { data } = await apiClient.get<Todo>(`/todos/${id}`, config);
|
|
367
|
+
return data;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Create a new todo. */
|
|
371
|
+
export async function createTodo(input: CreateTodoInput, accountId: string | null, sessionVersion: number): Promise<Todo> {
|
|
372
|
+
const config: SessionRequestConfig = { _accountId: accountId, _sessionVersion: sessionVersion };
|
|
373
|
+
const { data } = await apiClient.post<Todo>('/todos', input, config);
|
|
374
|
+
return data;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Toggle the completed flag on a todo. */
|
|
378
|
+
export async function toggleTodo(id: string, accountId: string | null, sessionVersion: number): Promise<Todo> {
|
|
379
|
+
const config: SessionRequestConfig = { _accountId: accountId, _sessionVersion: sessionVersion };
|
|
380
|
+
const { data } = await apiClient.patch<Todo>(`/todos/${id}/toggle`, undefined, config);
|
|
381
|
+
return data;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Permanently delete a todo. */
|
|
385
|
+
export async function deleteTodo(id: string, accountId: string | null, sessionVersion: number): Promise<void> {
|
|
386
|
+
const config: SessionRequestConfig = { _accountId: accountId, _sessionVersion: sessionVersion };
|
|
387
|
+
await apiClient.delete(`/todos/${id}`, config);
|
|
388
|
+
}
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## 5. Feature Query and Mutation Hooks
|
|
394
|
+
|
|
395
|
+
```typescript
|
|
396
|
+
// src/features/todos/queries.ts
|
|
397
|
+
// Read hooks — wrap src/api/todos.ts functions with TanStack Query.
|
|
398
|
+
// Explicit staleTime / gcTime on every query; never rely on defaults alone.
|
|
399
|
+
|
|
400
|
+
import { useQuery } from '@tanstack/react-query';
|
|
401
|
+
import { useAuthStore } from '@store/authStore';
|
|
402
|
+
import { fetchTodos, fetchTodo } from '@api/todos';
|
|
403
|
+
|
|
404
|
+
// Centralise query key definitions so mutations can reference them without
|
|
405
|
+
// string duplication. Key shape: [account/tenant, operation, ...params].
|
|
406
|
+
export const todoKeys = {
|
|
407
|
+
all: (accountId: string | null) => ['account', accountId, 'todos'] as const,
|
|
408
|
+
lists: (accountId: string | null) => [...todoKeys.all(accountId), 'list'] as const,
|
|
409
|
+
detail: (accountId: string | null, id: string) => [...todoKeys.all(accountId), 'detail', id] as const,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/** Fetch and cache the todo list. Returns stale data instantly, revalidates in
|
|
413
|
+
* the background when data is older than staleTime. */
|
|
414
|
+
export function useTodosQuery() {
|
|
415
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
416
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
417
|
+
return useQuery({
|
|
418
|
+
queryKey: todoKeys.lists(accountId),
|
|
419
|
+
queryFn: ({ signal }) => fetchTodos(accountId, sessionVersion, signal),
|
|
420
|
+
enabled: Boolean(accountId),
|
|
421
|
+
staleTime: 60_000, // 1 minute — adjust per resource freshness requirement
|
|
422
|
+
gcTime: 1000 * 60 * 60 * 24, // 24h — must stay >= the persister maxAge (§2)
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Fetch and cache a single todo. */
|
|
427
|
+
export function useTodoDetailQuery(id: string) {
|
|
428
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
429
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
430
|
+
return useQuery({
|
|
431
|
+
queryKey: todoKeys.detail(accountId, id),
|
|
432
|
+
queryFn: ({ signal }) => fetchTodo(id, accountId, sessionVersion, signal),
|
|
433
|
+
staleTime: 60_000,
|
|
434
|
+
gcTime: 1000 * 60 * 60 * 24, // 24h — see useTodosQuery
|
|
435
|
+
enabled: Boolean(accountId && id),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
// src/features/todos/mutations.ts
|
|
442
|
+
// Write hooks — wrap src/api/todos.ts functions with useMutation.
|
|
443
|
+
// Every mutation MUST call invalidateQueries for affected keys on onSuccess
|
|
444
|
+
// so the cache repopulates from the server on the next read.
|
|
445
|
+
|
|
446
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
447
|
+
import { useAuthStore } from '@store/authStore';
|
|
448
|
+
import { createTodo, toggleTodo, deleteTodo, type CreateTodoInput, type Todo } from '@api/todos';
|
|
449
|
+
import { todoKeys } from './queries';
|
|
450
|
+
|
|
451
|
+
// A paused/optimistic mutation from an old provider must not use a new login.
|
|
452
|
+
function runAccountMutation<T>(accountId: string | null, version: number, operation: (version: number) => Promise<T>): Promise<T> {
|
|
453
|
+
const current = useAuthStore.getState();
|
|
454
|
+
if (!accountId || current.accountId !== accountId || current.sessionVersion !== version) {
|
|
455
|
+
return Promise.reject(new Error('Mutation belongs to a retired session'));
|
|
456
|
+
}
|
|
457
|
+
return operation(version);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Create a new todo then invalidate the list cache. */
|
|
461
|
+
export function useCreateTodo() {
|
|
462
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
463
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
464
|
+
const queryClient = useQueryClient();
|
|
465
|
+
|
|
466
|
+
return useMutation({
|
|
467
|
+
mutationFn: (input: CreateTodoInput) => runAccountMutation(accountId, sessionVersion, (version) => createTodo(input, accountId, version)),
|
|
468
|
+
onSuccess: () => {
|
|
469
|
+
// Invalidate the list so the next useTodosQuery fetches fresh data.
|
|
470
|
+
queryClient.invalidateQueries({ queryKey: todoKeys.lists(accountId) });
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Toggle a todo's completed flag then invalidate list + detail caches. */
|
|
476
|
+
export function useToggleTodo() {
|
|
477
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
478
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
479
|
+
const queryClient = useQueryClient();
|
|
480
|
+
|
|
481
|
+
return useMutation({
|
|
482
|
+
mutationFn: (id: string) => runAccountMutation(accountId, sessionVersion, (version) => toggleTodo(id, accountId, version)),
|
|
483
|
+
// Optimistic update: flip the flag in the cache immediately for instant UI
|
|
484
|
+
// feedback; roll back if the mutation fails.
|
|
485
|
+
onMutate: async (id) => {
|
|
486
|
+
// Cancel in-flight queries for both keys so they can't clobber our patch.
|
|
487
|
+
await queryClient.cancelQueries({ queryKey: todoKeys.lists(accountId) });
|
|
488
|
+
await queryClient.cancelQueries({ queryKey: todoKeys.detail(accountId, id) });
|
|
489
|
+
|
|
490
|
+
const previousList = queryClient.getQueryData<Todo[]>(todoKeys.lists(accountId));
|
|
491
|
+
const previousDetail = queryClient.getQueryData<Todo>(todoKeys.detail(accountId, id));
|
|
492
|
+
|
|
493
|
+
// Patch each cache entry only when it exists — returning `old` untouched
|
|
494
|
+
// when undefined avoids materialising a fake empty list.
|
|
495
|
+
queryClient.setQueryData<Todo[] | undefined>(todoKeys.lists(accountId), (old) =>
|
|
496
|
+
old ? old.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)) : old,
|
|
497
|
+
);
|
|
498
|
+
queryClient.setQueryData<Todo | undefined>(todoKeys.detail(accountId, id), (old) =>
|
|
499
|
+
old ? { ...old, completed: !old.completed } : old,
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
return { previousList, previousDetail };
|
|
503
|
+
},
|
|
504
|
+
onError: (_err, id, context) => {
|
|
505
|
+
// Roll back both keys on error.
|
|
506
|
+
if (context?.previousList !== undefined) {
|
|
507
|
+
queryClient.setQueryData(todoKeys.lists(accountId), context.previousList);
|
|
508
|
+
}
|
|
509
|
+
if (context?.previousDetail !== undefined) {
|
|
510
|
+
queryClient.setQueryData(todoKeys.detail(accountId, id), context.previousDetail);
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
onSettled: (_data, _err, id) => {
|
|
514
|
+
// Reconcile with server truth for both keys once the mutation settles.
|
|
515
|
+
queryClient.invalidateQueries({ queryKey: todoKeys.lists(accountId) });
|
|
516
|
+
queryClient.invalidateQueries({ queryKey: todoKeys.detail(accountId, id) });
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Delete a todo then invalidate the list cache. */
|
|
522
|
+
export function useDeleteTodo() {
|
|
523
|
+
const accountId = useAuthStore((state) => state.accountId);
|
|
524
|
+
const sessionVersion = useAuthStore((state) => state.sessionVersion);
|
|
525
|
+
const queryClient = useQueryClient();
|
|
526
|
+
|
|
527
|
+
return useMutation({
|
|
528
|
+
mutationFn: (id: string) => runAccountMutation(accountId, sessionVersion, (version) => deleteTodo(id, accountId, version)),
|
|
529
|
+
onSuccess: (_data, id) => {
|
|
530
|
+
queryClient.invalidateQueries({ queryKey: todoKeys.lists(accountId) });
|
|
531
|
+
// Remove the detail cache entry immediately — it is no longer valid.
|
|
532
|
+
queryClient.removeQueries({ queryKey: todoKeys.detail(accountId, id) });
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
---
|
|
539
|
+
|
|
540
|
+
## 6. Screen Consuming the Hooks
|
|
541
|
+
|
|
542
|
+
```typescript
|
|
543
|
+
// src/features/todos/ui/TodosScreen.tsx
|
|
544
|
+
// Consumes useTodosQuery and useDeleteTodo. Note: no axios import here.
|
|
545
|
+
|
|
546
|
+
import React from 'react';
|
|
547
|
+
import {
|
|
548
|
+
View,
|
|
549
|
+
Text,
|
|
550
|
+
FlatList,
|
|
551
|
+
TouchableOpacity,
|
|
552
|
+
RefreshControl,
|
|
553
|
+
StyleSheet,
|
|
554
|
+
} from 'react-native';
|
|
555
|
+
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
|
556
|
+
import type { RootStackParamList } from '@navigation/types';
|
|
557
|
+
import { LoadingView } from '@shared/components/LoadingView';
|
|
558
|
+
import { ErrorView } from '@shared/components/ErrorView';
|
|
559
|
+
import { EmptyStateView } from '@shared/components/EmptyStateView';
|
|
560
|
+
import { useTodosQuery } from '../queries';
|
|
561
|
+
import { useDeleteTodo, useToggleTodo } from '../mutations';
|
|
562
|
+
|
|
563
|
+
type Props = NativeStackScreenProps<RootStackParamList, 'TodoList'>;
|
|
564
|
+
|
|
565
|
+
export function TodosScreen({ navigation }: Props) {
|
|
566
|
+
const { data: todos, isPending, isError, error, refetch, isRefetching } = useTodosQuery();
|
|
567
|
+
const toggleMutation = useToggleTodo();
|
|
568
|
+
const deleteMutation = useDeleteTodo();
|
|
569
|
+
|
|
570
|
+
// States delegate to the shared components (§12) so every screen renders them
|
|
571
|
+
// identically. isPending is the v5 first-load flag (there is no cached data yet).
|
|
572
|
+
if (isPending) return <LoadingView label="Loading todos" />;
|
|
573
|
+
if (isError) return <ErrorView error={error} onRetry={refetch} />;
|
|
574
|
+
if (todos.length === 0) return <EmptyStateView message="No todos yet. Add your first one!" />;
|
|
575
|
+
|
|
576
|
+
// --- Data state ---
|
|
577
|
+
return (
|
|
578
|
+
<FlatList
|
|
579
|
+
data={todos}
|
|
580
|
+
keyExtractor={(item) => item.id}
|
|
581
|
+
refreshControl={
|
|
582
|
+
<RefreshControl refreshing={isRefetching} onRefresh={refetch} />
|
|
583
|
+
}
|
|
584
|
+
renderItem={({ item }) => (
|
|
585
|
+
<View style={styles.row}>
|
|
586
|
+
<TouchableOpacity
|
|
587
|
+
style={styles.checkbox}
|
|
588
|
+
onPress={() => toggleMutation.mutate(item.id)}
|
|
589
|
+
accessibilityRole="checkbox"
|
|
590
|
+
accessibilityState={{ checked: item.completed }}
|
|
591
|
+
>
|
|
592
|
+
<Text>{item.completed ? '☑' : '☐'}</Text>
|
|
593
|
+
</TouchableOpacity>
|
|
594
|
+
|
|
595
|
+
<TouchableOpacity
|
|
596
|
+
style={styles.titleContainer}
|
|
597
|
+
onPress={() => navigation.navigate('TodoDetail', { id: item.id })}
|
|
598
|
+
>
|
|
599
|
+
<Text style={[styles.title, item.completed && styles.completed]}>
|
|
600
|
+
{item.title}
|
|
601
|
+
</Text>
|
|
602
|
+
</TouchableOpacity>
|
|
603
|
+
|
|
604
|
+
<TouchableOpacity
|
|
605
|
+
onPress={() => deleteMutation.mutate(item.id)}
|
|
606
|
+
testID={`todo-delete-${item.id}`}
|
|
607
|
+
accessibilityLabel={`Delete ${item.title}`}
|
|
608
|
+
>
|
|
609
|
+
<Text style={styles.deleteIcon}>✕</Text>
|
|
610
|
+
</TouchableOpacity>
|
|
611
|
+
</View>
|
|
612
|
+
)}
|
|
613
|
+
/>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const styles = StyleSheet.create({
|
|
618
|
+
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0' },
|
|
619
|
+
checkbox: { marginRight: 12 },
|
|
620
|
+
titleContainer: { flex: 1 },
|
|
621
|
+
title: { fontSize: 16, color: '#212121' },
|
|
622
|
+
completed: { textDecorationLine: 'line-through', color: '#9e9e9e' },
|
|
623
|
+
deleteIcon: { color: '#9e9e9e', fontSize: 16, paddingLeft: 12 },
|
|
624
|
+
});
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
---
|
|
628
|
+
|
|
629
|
+
## 7. Navigation Setup with Typed Param List
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
// src/navigation/types.ts
|
|
633
|
+
export type RootStackParamList = {
|
|
634
|
+
TodoList: undefined;
|
|
635
|
+
TodoDetail: { id: string };
|
|
636
|
+
Login: undefined;
|
|
637
|
+
};
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
```typescript
|
|
641
|
+
// src/navigation/RootNavigator.tsx
|
|
642
|
+
import React from 'react';
|
|
643
|
+
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
|
644
|
+
import type { RootStackParamList } from './types';
|
|
645
|
+
import { TodosScreen } from '@features/todos/ui/TodosScreen';
|
|
646
|
+
import { TodoDetailScreen } from '@features/todos/ui/TodoDetailScreen';
|
|
647
|
+
import { LoginScreen } from '@features/auth/ui/LoginScreen';
|
|
648
|
+
import { useAuthStore } from '@store/authStore';
|
|
649
|
+
|
|
650
|
+
const Stack = createNativeStackNavigator<RootStackParamList>();
|
|
651
|
+
|
|
652
|
+
export function RootNavigator() {
|
|
653
|
+
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
|
654
|
+
|
|
655
|
+
return (
|
|
656
|
+
<Stack.Navigator>
|
|
657
|
+
{isAuthenticated ? (
|
|
658
|
+
<>
|
|
659
|
+
<Stack.Screen
|
|
660
|
+
name="TodoList"
|
|
661
|
+
component={TodosScreen}
|
|
662
|
+
options={{ title: 'My Todos' }}
|
|
663
|
+
/>
|
|
664
|
+
<Stack.Screen
|
|
665
|
+
name="TodoDetail"
|
|
666
|
+
component={TodoDetailScreen}
|
|
667
|
+
options={{ title: 'Todo Detail' }}
|
|
668
|
+
/>
|
|
669
|
+
</>
|
|
670
|
+
) : (
|
|
671
|
+
<Stack.Screen
|
|
672
|
+
name="Login"
|
|
673
|
+
component={LoginScreen}
|
|
674
|
+
options={{ headerShown: false }}
|
|
675
|
+
/>
|
|
676
|
+
)}
|
|
677
|
+
</Stack.Navigator>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
---
|
|
683
|
+
|
|
684
|
+
## 8. Jest Test: Component with QueryClientProvider
|
|
685
|
+
|
|
686
|
+
```typescript
|
|
687
|
+
// src/features/todos/__tests__/TodosScreen.test.tsx
|
|
688
|
+
// Wraps the component in a QueryClientProvider with a no-retry, zero-staleTime
|
|
689
|
+
// QueryClient. The api module is mocked at the Jest boundary — tests assert on
|
|
690
|
+
// component output, not axios internals.
|
|
691
|
+
|
|
692
|
+
import React from 'react';
|
|
693
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
|
|
694
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
695
|
+
import { NavigationContainer } from '@react-navigation/native';
|
|
696
|
+
import { TodosScreen } from '../ui/TodosScreen';
|
|
697
|
+
|
|
698
|
+
// Mock the entire api/todos module — the seam between hooks and transport.
|
|
699
|
+
jest.mock('@api/todos', () => ({
|
|
700
|
+
fetchTodos: jest.fn(),
|
|
701
|
+
toggleTodo: jest.fn(),
|
|
702
|
+
deleteTodo: jest.fn(),
|
|
703
|
+
}));
|
|
704
|
+
|
|
705
|
+
import * as todosApi from '@api/todos';
|
|
706
|
+
|
|
707
|
+
// Helper: create a fresh QueryClient per test to avoid state bleed.
|
|
708
|
+
function createTestQueryClient() {
|
|
709
|
+
return new QueryClient({
|
|
710
|
+
defaultOptions: {
|
|
711
|
+
queries: {
|
|
712
|
+
retry: false, // Surface errors immediately in tests
|
|
713
|
+
staleTime: 0, // Always fetch in tests; no stale cache reads
|
|
714
|
+
gcTime: Infinity, // Keep data for the test duration
|
|
715
|
+
},
|
|
716
|
+
mutations: { retry: false },
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Helper: wrap the component with required providers.
|
|
722
|
+
function renderWithProviders(ui: React.ReactElement) {
|
|
723
|
+
const testQueryClient = createTestQueryClient();
|
|
724
|
+
return render(
|
|
725
|
+
<QueryClientProvider client={testQueryClient}>
|
|
726
|
+
<NavigationContainer>
|
|
727
|
+
{ui}
|
|
728
|
+
</NavigationContainer>
|
|
729
|
+
</QueryClientProvider>,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const mockNavigation = { navigate: jest.fn() } as any;
|
|
734
|
+
|
|
735
|
+
describe('TodosScreen', () => {
|
|
736
|
+
afterEach(() => {
|
|
737
|
+
jest.clearAllMocks();
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it('shows a loading indicator while fetching', async () => {
|
|
741
|
+
// Delay resolution so we can catch the loading state.
|
|
742
|
+
(todosApi.fetchTodos as jest.Mock).mockReturnValue(new Promise(() => {}));
|
|
743
|
+
renderWithProviders(<TodosScreen navigation={mockNavigation} route={{} as any} />);
|
|
744
|
+
|
|
745
|
+
expect(screen.getByLabelText('Loading todos')).toBeTruthy();
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('renders todo titles after successful fetch', async () => {
|
|
749
|
+
(todosApi.fetchTodos as jest.Mock).mockResolvedValue([
|
|
750
|
+
{ id: '1', title: 'Buy milk', completed: false, createdAt: '' },
|
|
751
|
+
{ id: '2', title: 'Walk the dog', completed: true, createdAt: '' },
|
|
752
|
+
]);
|
|
753
|
+
|
|
754
|
+
renderWithProviders(<TodosScreen navigation={mockNavigation} route={{} as any} />);
|
|
755
|
+
|
|
756
|
+
expect(await screen.findByText('Buy milk')).toBeTruthy();
|
|
757
|
+
expect(screen.getByText('Walk the dog')).toBeTruthy();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('shows empty state when the list is empty', async () => {
|
|
761
|
+
(todosApi.fetchTodos as jest.Mock).mockResolvedValue([]);
|
|
762
|
+
|
|
763
|
+
renderWithProviders(<TodosScreen navigation={mockNavigation} route={{} as any} />);
|
|
764
|
+
|
|
765
|
+
expect(await screen.findByText(/No todos yet/i)).toBeTruthy();
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('shows an error message and retry button on fetch failure', async () => {
|
|
769
|
+
(todosApi.fetchTodos as jest.Mock).mockRejectedValue(new Error('Network error'));
|
|
770
|
+
|
|
771
|
+
renderWithProviders(<TodosScreen navigation={mockNavigation} route={{} as any} />);
|
|
772
|
+
|
|
773
|
+
expect(await screen.findByText('Network error')).toBeTruthy();
|
|
774
|
+
expect(screen.getByText('Retry')).toBeTruthy();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('calls deleteTodo when the delete button is pressed', async () => {
|
|
778
|
+
(todosApi.fetchTodos as jest.Mock).mockResolvedValue([
|
|
779
|
+
{ id: '42', title: 'Delete me', completed: false, createdAt: '' },
|
|
780
|
+
]);
|
|
781
|
+
(todosApi.deleteTodo as jest.Mock).mockResolvedValue(undefined);
|
|
782
|
+
|
|
783
|
+
renderWithProviders(<TodosScreen navigation={mockNavigation} route={{} as any} />);
|
|
784
|
+
|
|
785
|
+
const deleteButton = await screen.findByLabelText('Delete Delete me');
|
|
786
|
+
fireEvent.press(deleteButton);
|
|
787
|
+
|
|
788
|
+
await waitFor(() => {
|
|
789
|
+
expect(todosApi.deleteTodo).toHaveBeenCalledWith('42');
|
|
790
|
+
});
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
---
|
|
796
|
+
|
|
797
|
+
## 9. Cache Rules Recap
|
|
798
|
+
|
|
799
|
+
> These rules apply to every `useQuery` and `useMutation` in the codebase.
|
|
800
|
+
> They define the repository-layer cache contract for TanStack Query: the
|
|
801
|
+
> data-fetching layer is the single source of caching truth.
|
|
802
|
+
|
|
803
|
+
1. **Cache decoded objects, not bytes.** TanStack Query stores the return value of `queryFn` — a decoded TypeScript object. Never cache raw `AxiosResponse` or `ArrayBuffer`.
|
|
804
|
+
2. **Explicit `staleTime` and `gcTime` on every `useQuery`.** No implicit infinite TTL. `staleTime` governs background revalidation; `gcTime` governs memory reclamation.
|
|
805
|
+
3. **Query keys = `[account/tenant, operation, ...params]`.** Never key on URLs. Centralise key definitions in a `todoKeys` (or `<resource>Keys`) object so mutations and queries share the same reference.
|
|
806
|
+
4. **Invalidate on write.** Every `useMutation` calls `queryClient.invalidateQueries({ queryKey })` for all affected list and detail keys in `onSuccess`. Optimistic updates (optional) must pair a rollback in `onError`.
|
|
807
|
+
5. **MMKV persistence for offline-first.** The `PersistQueryClientProvider` + MMKV persister hydrates the cache before the first render. Stale data renders immediately; revalidation runs in the background.
|
|
808
|
+
6. **Repository seam.** `src/api/*.ts` functions are the only axios callers. Screens and components never import from `axios` or from `src/api/` directly — they consume query/mutation hooks.
|
|
809
|
+
7. **Query cache = transient server-owned data.** Durable non-secret user data belongs in MMKV; secrets (the access token) live only in memory in the Keychain-backed auth store (§10), never in plain-text MMKV. Never use TanStack Query as a system of record.
|
|
810
|
+
|
|
811
|
+
---
|
|
812
|
+
|
|
813
|
+
## 10. Auth Store (Zustand + Keychain)
|
|
814
|
+
|
|
815
|
+
```typescript
|
|
816
|
+
// src/store/authStore.ts
|
|
817
|
+
// Auth session store. The access token lives ONLY in memory here and in the
|
|
818
|
+
// platform secure enclave via react-native-keychain (iOS Keychain / Android
|
|
819
|
+
// Keystore) — never in plain-text MMKV. The axios request interceptor (§3)
|
|
820
|
+
// reads `accessToken` synchronously via useAuthStore.getState().
|
|
821
|
+
|
|
822
|
+
import { create } from 'zustand';
|
|
823
|
+
import * as Keychain from 'react-native-keychain';
|
|
824
|
+
import { resetAccountQueryCache } from '@api/queryClient';
|
|
825
|
+
|
|
826
|
+
// Namespaces the credential entry. Use a stable, app-unique service string.
|
|
827
|
+
const KEYCHAIN_SERVICE = 'com.example.app.auth';
|
|
828
|
+
|
|
829
|
+
interface AuthState {
|
|
830
|
+
accessToken: string | null;
|
|
831
|
+
accountId: string | null; // validated non-secret account/tenant ID from the server
|
|
832
|
+
sessionVersion: number;
|
|
833
|
+
isAuthenticated: boolean;
|
|
834
|
+
setToken: (token: string, accountId: string) => Promise<void>;
|
|
835
|
+
updateToken: (token: string, version: number) => Promise<boolean>;
|
|
836
|
+
clearToken: (expectedVersion?: number) => Promise<void>;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Serialize credential writes: a late refresh cannot undo logout.
|
|
840
|
+
let sessionTransition: Promise<unknown> = Promise.resolve();
|
|
841
|
+
function transition<T>(operation: () => Promise<T>): Promise<T> {
|
|
842
|
+
const next = sessionTransition.then(operation);
|
|
843
|
+
sessionTransition = next.catch(() => undefined);
|
|
844
|
+
return next;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export const useAuthStore = create<AuthState>()((set, get) => ({
|
|
848
|
+
accessToken: null,
|
|
849
|
+
accountId: null,
|
|
850
|
+
sessionVersion: 0,
|
|
851
|
+
isAuthenticated: false,
|
|
852
|
+
setToken: (token, accountId) => transition(async () => {
|
|
853
|
+
if (!accountId) throw new Error('Validated account ID is required');
|
|
854
|
+
const previousAccount = get().accountId;
|
|
855
|
+
set({ accessToken: null, accountId: null, isAuthenticated: false,
|
|
856
|
+
sessionVersion: get().sessionVersion + 1 }); // unmount old account before awaiting
|
|
857
|
+
await resetAccountQueryCache(previousAccount);
|
|
858
|
+
await Keychain.setGenericPassword(accountId, token, { service: KEYCHAIN_SERVICE });
|
|
859
|
+
set({ accessToken: token, accountId, isAuthenticated: true });
|
|
860
|
+
}),
|
|
861
|
+
updateToken: (token, version) => transition(async () => {
|
|
862
|
+
const accountId = get().accountId;
|
|
863
|
+
if (version !== get().sessionVersion || !accountId) return false;
|
|
864
|
+
await Keychain.setGenericPassword(accountId, token, { service: KEYCHAIN_SERVICE });
|
|
865
|
+
set({ accessToken: token });
|
|
866
|
+
return true;
|
|
867
|
+
}),
|
|
868
|
+
clearToken: (expectedVersion) => transition(async () => {
|
|
869
|
+
// Check inside the queue: a queued new login may have finished meanwhile.
|
|
870
|
+
if (expectedVersion !== undefined && expectedVersion !== get().sessionVersion) return;
|
|
871
|
+
const previousAccount = get().accountId;
|
|
872
|
+
set({ accessToken: null, accountId: null, isAuthenticated: false,
|
|
873
|
+
sessionVersion: get().sessionVersion + 1 });
|
|
874
|
+
await resetAccountQueryCache(previousAccount);
|
|
875
|
+
await Keychain.resetGenericPassword({ service: KEYCHAIN_SERVICE });
|
|
876
|
+
}),
|
|
877
|
+
}));
|
|
878
|
+
|
|
879
|
+
// Call before mounting account providers. The username is the validated account
|
|
880
|
+
// ID written by setToken; discard legacy token-only entries during migration.
|
|
881
|
+
export async function hydrateAuth(): Promise<void> {
|
|
882
|
+
const creds = await Keychain.getGenericPassword({ service: KEYCHAIN_SERVICE });
|
|
883
|
+
if (creds && creds.username !== 'accessToken') {
|
|
884
|
+
await useAuthStore.getState().setToken(creds.password, creds.username);
|
|
885
|
+
} else if (creds) {
|
|
886
|
+
await useAuthStore.getState().clearToken();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
---
|
|
892
|
+
|
|
893
|
+
## 11. Network Status Hook
|
|
894
|
+
|
|
895
|
+
```typescript
|
|
896
|
+
// src/shared/hooks/useNetworkStatus.ts
|
|
897
|
+
// Reactive online/offline flag backed by NetInfo. Complements the onlineManager
|
|
898
|
+
// wiring in §2 (which drives TanStack Query) — use this hook for UI affordances
|
|
899
|
+
// such as an offline banner.
|
|
900
|
+
|
|
901
|
+
import { useEffect, useState } from 'react';
|
|
902
|
+
import NetInfo from '@react-native-community/netinfo';
|
|
903
|
+
|
|
904
|
+
/** Returns true while the device reports an active network connection. */
|
|
905
|
+
export function useNetworkStatus(): boolean {
|
|
906
|
+
const [isOnline, setIsOnline] = useState(true);
|
|
907
|
+
|
|
908
|
+
useEffect(() => {
|
|
909
|
+
// addEventListener returns its own unsubscribe function.
|
|
910
|
+
const unsubscribe = NetInfo.addEventListener((state) =>
|
|
911
|
+
setIsOnline(!!state.isConnected),
|
|
912
|
+
);
|
|
913
|
+
return unsubscribe;
|
|
914
|
+
}, []);
|
|
915
|
+
|
|
916
|
+
return isOnline;
|
|
917
|
+
}
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
---
|
|
921
|
+
|
|
922
|
+
## 12. Shared UI Components (Loading / Error / Empty)
|
|
923
|
+
|
|
924
|
+
```typescript
|
|
925
|
+
// src/shared/components/LoadingView.tsx
|
|
926
|
+
import React from 'react';
|
|
927
|
+
import { View, ActivityIndicator, StyleSheet } from 'react-native';
|
|
928
|
+
|
|
929
|
+
export function LoadingView({ label = 'Loading' }: { label?: string }) {
|
|
930
|
+
return (
|
|
931
|
+
<View style={styles.center}>
|
|
932
|
+
<ActivityIndicator size="large" accessibilityLabel={label} />
|
|
933
|
+
</View>
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const styles = StyleSheet.create({
|
|
938
|
+
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
|
939
|
+
});
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
```typescript
|
|
943
|
+
// src/shared/components/ErrorView.tsx
|
|
944
|
+
import React from 'react';
|
|
945
|
+
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
|
946
|
+
|
|
947
|
+
type Props = { error: unknown; onRetry?: () => void };
|
|
948
|
+
|
|
949
|
+
export function ErrorView({ error, onRetry }: Props) {
|
|
950
|
+
const message = error instanceof Error ? error.message : 'Something went wrong.';
|
|
951
|
+
return (
|
|
952
|
+
<View style={styles.center}>
|
|
953
|
+
<Text style={styles.errorText}>{message}</Text>
|
|
954
|
+
{onRetry ? (
|
|
955
|
+
<TouchableOpacity style={styles.retryButton} onPress={onRetry} accessibilityRole="button">
|
|
956
|
+
<Text style={styles.retryText}>Retry</Text>
|
|
957
|
+
</TouchableOpacity>
|
|
958
|
+
) : null}
|
|
959
|
+
</View>
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const styles = StyleSheet.create({
|
|
964
|
+
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
|
965
|
+
errorText: { color: '#d32f2f', textAlign: 'center', marginBottom: 12 },
|
|
966
|
+
retryButton: { backgroundColor: '#1976d2', borderRadius: 8, paddingHorizontal: 20, paddingVertical: 10 },
|
|
967
|
+
retryText: { color: '#fff', fontWeight: '600' },
|
|
968
|
+
});
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
```typescript
|
|
972
|
+
// src/shared/components/EmptyStateView.tsx
|
|
973
|
+
import React from 'react';
|
|
974
|
+
import { View, Text, StyleSheet } from 'react-native';
|
|
975
|
+
|
|
976
|
+
export function EmptyStateView({ message }: { message: string }) {
|
|
977
|
+
return (
|
|
978
|
+
<View style={styles.center}>
|
|
979
|
+
<Text style={styles.emptyText}>{message}</Text>
|
|
980
|
+
</View>
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const styles = StyleSheet.create({
|
|
985
|
+
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
|
986
|
+
emptyText: { color: '#757575', textAlign: 'center' },
|
|
987
|
+
});
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
---
|
|
991
|
+
|
|
992
|
+
## 13. Maestro E2E Flow
|
|
993
|
+
|
|
994
|
+
Before this flow, sign into an isolated test account and seed a todo with title
|
|
995
|
+
Buy milk and a known ID. Supply that ID as MAESTRO_TODO_ID. Keep the signed-in
|
|
996
|
+
session: clearState would return the example navigator to Login. This example
|
|
997
|
+
covers existing row actions; add/login flows belong to their actual screens.
|
|
998
|
+
|
|
999
|
+
```yaml
|
|
1000
|
+
appId: com.example.app
|
|
1001
|
+
---
|
|
1002
|
+
- launchApp:
|
|
1003
|
+
clearState: false
|
|
1004
|
+
- assertVisible: "My Todos"
|
|
1005
|
+
- assertVisible: "Buy milk"
|
|
1006
|
+
- tapOn:
|
|
1007
|
+
id: "todo-checkbox-${MAESTRO_TODO_ID}"
|
|
1008
|
+
- tapOn: "Buy milk"
|
|
1009
|
+
- assertVisible: "Todo Detail"
|
|
1010
|
+
- back
|
|
1011
|
+
- tapOn:
|
|
1012
|
+
id: "todo-delete-${MAESTRO_TODO_ID}"
|
|
1013
|
+
- assertNotVisible: "Buy milk"
|
|
1014
|
+
```
|