Compare commits
33 Commits
feat/add-m
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1391b63464 | ||
|
|
b80cce48bd | ||
|
|
9643620f1a | ||
|
|
f02366cd0f | ||
|
|
cf44f7b122 | ||
|
|
0e006d124b | ||
|
|
f87b423670 | ||
|
|
37046a3edb | ||
|
|
ee1e834f6c | ||
|
|
551541a974 | ||
|
|
5361adc8e5 | ||
|
|
b4e6c16f4b | ||
|
|
34c6cf05d7 | ||
|
|
2983aab561 | ||
|
|
7a04b5ecf1 | ||
|
|
c2c6a25b27 | ||
|
|
2995582a5e | ||
|
|
33cdc32a8e | ||
|
|
9fe1628851 | ||
|
|
f639ffbe8a | ||
|
|
6ec1c6a7f1 | ||
|
|
14106a3ffd | ||
|
|
06786fef28 | ||
|
|
56a37aec73 | ||
|
|
6c404106e3 | ||
|
|
fb18ac5aa1 | ||
|
|
0a61f2be6a | ||
|
|
6d8beb6ade | ||
|
|
0b2927a366 | ||
|
|
f742ee77e4 | ||
|
|
002174b651 | ||
|
|
e3890709c9 | ||
|
|
52b72fb4c0 |
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "minimax-skills",
|
"name": "minimax-skills",
|
||||||
"description": "MiniMax AI skills library for frontend, fullstack, and Android native development",
|
"description": "MiniMax AI skills library for frontend, fullstack, mobile, document, presentation, shader, and multimodal media workflows",
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "MiniMax AI"
|
"name": "MiniMax AI"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "minimax-skills",
|
"name": "minimax-skills",
|
||||||
"description": "MiniMax AI skills library: frontend development, fullstack development, and Android native development",
|
"description": "MiniMax AI skills library for frontend, fullstack, Android, iOS, shader, GIF sticker, document, presentation, spreadsheet, and multimodal media workflows",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "minimax-skills",
|
"name": "minimax-skills",
|
||||||
"description": "MiniMax AI skills library: frontend development, fullstack development, and Android native development",
|
"description": "MiniMax AI skills library for frontend, fullstack, Android, iOS, shader, GIF sticker, document, presentation, spreadsheet, and multimodal media workflows",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "MiniMax AI"
|
"name": "MiniMax AI"
|
||||||
@@ -8,5 +8,5 @@
|
|||||||
"homepage": "https://github.com/MiniMax-AI/skills",
|
"homepage": "https://github.com/MiniMax-AI/skills",
|
||||||
"repository": "https://github.com/MiniMax-AI/skills",
|
"repository": "https://github.com/MiniMax-AI/skills",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": ["skills", "frontend", "fullstack", "android", "minimax"]
|
"keywords": ["skills", "frontend", "fullstack", "android", "ios", "shader", "gif", "sticker", "pdf", "pptx", "presentation", "xlsx", "excel", "spreadsheet", "docx", "word", "document", "multimodal", "video", "image", "audio", "music", "minimax"]
|
||||||
}
|
}
|
||||||
|
|||||||
93
.cursor-plugin/INSTALL.md
Normal file
93
.cursor-plugin/INSTALL.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Installing MiniMax Skills for Cursor
|
||||||
|
|
||||||
|
Enable MiniMax skills in Cursor by cloning the repository locally and pointing Cursor's skills path at the `skills/` directory.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Cursor installed
|
||||||
|
- Git
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/MiniMax-AI/skills.git ~/.cursor/minimax-skills
|
||||||
|
```
|
||||||
|
|
||||||
|
Set Cursor's skills path to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.cursor/minimax-skills/skills/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/MiniMax-AI/skills.git "$env:USERPROFILE\.cursor\minimax-skills"
|
||||||
|
```
|
||||||
|
|
||||||
|
Set Cursor's skills path to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
C:\Users\YOUR_USERNAME\.cursor\minimax-skills\skills\
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `YOUR_USERNAME` with your Windows account name.
|
||||||
|
|
||||||
|
After saving the path, restart Cursor or reload the window so it rescans local skills.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
Confirm the clone exists and contains `SKILL.md` files:
|
||||||
|
|
||||||
|
### macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find ~/.cursor/minimax-skills/skills -maxdepth 2 -name SKILL.md | head
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.cursor\minimax-skills\skills" -Directory | ForEach-Object {
|
||||||
|
Get-ChildItem $_.FullName -Filter SKILL.md
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
### macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.cursor/minimax-skills && git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location "$env:USERPROFILE\.cursor\minimax-skills"
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstalling
|
||||||
|
|
||||||
|
### macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf ~/.cursor/minimax-skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Remove-Item -Recurse -Force "$env:USERPROFILE\.cursor\minimax-skills"
|
||||||
|
```
|
||||||
|
|
||||||
|
## VS Code Note
|
||||||
|
|
||||||
|
This repository does not currently ship a standalone VS Code extension.
|
||||||
|
|
||||||
|
If you use VS Code, the recommended options are:
|
||||||
|
- run a supported CLI tool such as Codex, Claude Code, or OpenCode inside the VS Code integrated terminal
|
||||||
|
- use Cursor if you want native local-skills configuration from this repository
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "minimax-skills",
|
"name": "minimax-skills",
|
||||||
"displayName": "MiniMax Skills",
|
"displayName": "MiniMax Skills",
|
||||||
"description": "MiniMax AI skills library: frontend development, fullstack development, Android native development, iOS application development, shader development, GIF sticker maker, PDF generation, PPTX presentations, Excel/spreadsheet processing, and DOCX document processing",
|
"description": "MiniMax AI skills library for frontend, fullstack, Android, iOS, shader, GIF sticker, document, presentation, spreadsheet, and multimodal media workflows",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "MiniMax AI"
|
"name": "MiniMax AI"
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"homepage": "https://github.com/MiniMax-AI/skills",
|
"homepage": "https://github.com/MiniMax-AI/skills",
|
||||||
"repository": "https://github.com/MiniMax-AI/skills",
|
"repository": "https://github.com/MiniMax-AI/skills",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": ["skills", "frontend", "fullstack", "android", "ios", "shader", "gif", "sticker", "pdf", "pptx", "xlsx", "excel", "spreadsheet", "docx", "word", "document", "minimax"],
|
"keywords": ["skills", "frontend", "fullstack", "android", "ios", "shader", "gif", "sticker", "pdf", "pptx", "xlsx", "excel", "spreadsheet", "docx", "word", "document", "multimodal", "video", "image", "audio", "music", "minimax"],
|
||||||
"logo": "assets/logo.png",
|
"logo": "assets/logo.png",
|
||||||
"skills": "./skills/"
|
"skills": "./skills/"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
git clone https://github.com/MiniMax-AI/skills.git ~/.minimax-skills
|
git clone https://github.com/MiniMax-AI/skills.git ~/.minimax-skills
|
||||||
|
|
||||||
mkdir -p ~/.config/opencode/skills
|
mkdir -p ~/.config/opencode/skills
|
||||||
ln -s ~/.minimax-skills/skills/* ~/.config/opencode/skills/
|
for skill in ~/.minimax-skills/skills/*/; do
|
||||||
|
skill_name=$(basename "$skill")
|
||||||
|
ln -s "$skill" ~/.config/opencode/skills/minimax-"$skill_name"
|
||||||
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows (PowerShell)
|
### Windows (PowerShell)
|
||||||
@@ -22,7 +25,7 @@ git clone https://github.com/MiniMax-AI/skills.git "$env:USERPROFILE\.minimax-sk
|
|||||||
|
|
||||||
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.config\opencode\skills"
|
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.config\opencode\skills"
|
||||||
Get-ChildItem "$env:USERPROFILE\.minimax-skills\skills" -Directory | ForEach-Object {
|
Get-ChildItem "$env:USERPROFILE\.minimax-skills\skills" -Directory | ForEach-Object {
|
||||||
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.config\opencode\skills\$($_.Name)" -Target $_.FullName
|
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.config\opencode\skills\minimax-$($_.Name)" -Target $_.FullName
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -58,14 +61,14 @@ Symlinks will automatically point to the updated content — no need to re-link.
|
|||||||
### macOS / Linux
|
### macOS / Linux
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf ~/.config/opencode/skills
|
rm -f ~/.config/opencode/skills/minimax-*
|
||||||
rm -rf ~/.minimax-skills
|
rm -rf ~/.minimax-skills
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows (PowerShell)
|
### Windows (PowerShell)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Remove-Item -Recurse -Force "$env:USERPROFILE\.config\opencode\skills"
|
Get-ChildItem "$env:USERPROFILE\.config\opencode\skills\minimax-*" | Remove-Item -Force
|
||||||
Remove-Item -Recurse -Force "$env:USERPROFILE\.minimax-skills"
|
Remove-Item -Recurse -Force "$env:USERPROFILE\.minimax-skills"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
85
.opencode/INSTALL_zh.md
Normal file
85
.opencode/INSTALL_zh.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# 安装 MiniMax Skills for OpenCode
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
- 已安装 [OpenCode.ai](https://opencode.ai)
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
### macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/MiniMax-AI/skills.git ~/.minimax-skills
|
||||||
|
|
||||||
|
mkdir -p ~/.config/opencode/skills
|
||||||
|
for skill in ~/.minimax-skills/skills/*/; do
|
||||||
|
skill_name=$(basename "$skill")
|
||||||
|
ln -s "$skill" ~/.config/opencode/skills/minimax-"$skill_name"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git clone https://github.com/MiniMax-AI/skills.git "$env:USERPROFILE\.minimax-skills"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.config\opencode\skills"
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.minimax-skills\skills" -Directory | ForEach-Object {
|
||||||
|
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.config\opencode\skills\minimax-$($_.Name)" -Target $_.FullName
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意:** 在 Windows 上创建符号链接可能需要管理员权限或启用开发者模式。
|
||||||
|
|
||||||
|
重启 OpenCode 以发现技能。
|
||||||
|
|
||||||
|
验证方法:询问"列出可用技能"
|
||||||
|
|
||||||
|
## 可用技能
|
||||||
|
|
||||||
|
- **frontend-dev** — 前端开发,包含 UI 设计、动画、AI 生成媒体资源
|
||||||
|
- **fullstack-dev** — 全栈后端架构和前后端集成
|
||||||
|
- **android-native-dev** — Android 原生应用开发,采用 Material Design 3
|
||||||
|
- **ios-application-dev** — iOS 应用开发,包含 UIKit、SnapKit 和 SwiftUI
|
||||||
|
- **shader-dev** — GLSL 着色器技术,用于创建惊艳的视觉效果(兼容 ShaderToy)
|
||||||
|
- **gif-sticker-maker** — 将照片转换为动画 GIF 贴纸(Funko Pop / Pop Mart 风格)
|
||||||
|
- **minimax-pdf** — 使用基于令牌的设计系统生成、填写和重新格式化 PDF 文档
|
||||||
|
- **pptx-generator** — 生成、编辑和读取 PowerPoint 演示文稿
|
||||||
|
- **minimax-xlsx** — 打开、创建、读取、分析、编辑或验证 Excel/电子表格文件
|
||||||
|
- **minimax-docx** — 使用 OpenXML SDK 专业创建、编辑和格式化 Word 文档
|
||||||
|
|
||||||
|
## 更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.minimax-skills && git pull
|
||||||
|
```
|
||||||
|
|
||||||
|
符号链接将自动指向更新后的内容,无需重新链接。
|
||||||
|
|
||||||
|
## 卸载
|
||||||
|
|
||||||
|
### macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f ~/.config/opencode/skills/minimax-*
|
||||||
|
rm -rf ~/.minimax-skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.config\opencode\skills\minimax-*" | Remove-Item -Force
|
||||||
|
Remove-Item -Recurse -Force "$env:USERPROFILE\.minimax-skills"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 找不到技能
|
||||||
|
|
||||||
|
1. 验证符号链接是否存在:`ls -la ~/.config/opencode/skills/`
|
||||||
|
2. 每个技能文件夹应包含 `SKILL.md` 文件
|
||||||
|
3. 安装后重启 OpenCode
|
||||||
|
|
||||||
|
## 获取帮助
|
||||||
|
|
||||||
|
- 问题反馈:https://github.com/MiniMax-AI/skills/issues
|
||||||
21
README.md
21
README.md
@@ -14,13 +14,16 @@ Development skills for AI coding agents. Plug into your favorite AI coding tool
|
|||||||
| `fullstack-dev` | Full-stack backend architecture and frontend-backend integration. REST API design, auth flows (JWT, session, OAuth), real-time features (SSE, WebSocket), database integration (SQL / NoSQL), production hardening, and release checklist. Guided workflow: requirements → architecture → implementation. | Official |
|
| `fullstack-dev` | Full-stack backend architecture and frontend-backend integration. REST API design, auth flows (JWT, session, OAuth), real-time features (SSE, WebSocket), database integration (SQL / NoSQL), production hardening, and release checklist. Guided workflow: requirements → architecture → implementation. | Official |
|
||||||
| `android-native-dev` | Android native application development with Material Design 3. Kotlin / Jetpack Compose, adaptive layouts, Gradle configuration, accessibility (WCAG), build troubleshooting, performance optimization, and motion system. | Official |
|
| `android-native-dev` | Android native application development with Material Design 3. Kotlin / Jetpack Compose, adaptive layouts, Gradle configuration, accessibility (WCAG), build troubleshooting, performance optimization, and motion system. | Official |
|
||||||
| `ios-application-dev` | iOS application development guide covering UIKit, SnapKit, and SwiftUI. Touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, and Apple HIG compliance. | Official |
|
| `ios-application-dev` | iOS application development guide covering UIKit, SnapKit, and SwiftUI. Touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, and Apple HIG compliance. | Official |
|
||||||
|
| `flutter-dev` | Flutter cross-platform development covering widget patterns, Riverpod/Bloc state management, GoRouter navigation, performance optimization, and testing strategies. | Official |
|
||||||
|
| `react-native-dev` | React Native and Expo development guide covering components, styling, animations, navigation, state management, forms, networking, performance optimization, testing, native capabilities, and engineering (project structure, deployment, SDK upgrades, CI/CD). | Official |
|
||||||
| `shader-dev` | Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. ShaderToy-compatible. | Official |
|
| `shader-dev` | Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. ShaderToy-compatible. | Official |
|
||||||
| `gif-sticker-maker` | Convert photos (people, pets, objects, logos) into 4 animated GIF stickers with captions. Funko Pop / Pop Mart style, powered by MiniMax Image & Video Generation API. | Official |
|
| `gif-sticker-maker` | Convert photos (people, pets, objects, logos) into 4 animated GIF stickers with captions. Funko Pop / Pop Mart style, powered by MiniMax Image & Video Generation API. | Official |
|
||||||
| `minimax-pdf` | Generate, fill, and reformat PDF documents with a token-based design system. CREATE polished PDFs from scratch (15 cover styles), FILL existing form fields, or REFORMAT documents into a new design. Print-ready output with typography and color derived from document type. | Official |
|
| `minimax-pdf` | Generate, fill, and reformat PDF documents with a token-based design system. CREATE polished PDFs from scratch (15 cover styles), FILL existing form fields, or REFORMAT documents into a new design. Print-ready output with typography and color derived from document type. | Official |
|
||||||
| `pptx-generator` | Generate, edit, and read PowerPoint presentations. Create from scratch with PptxGenJS (cover, TOC, content, section divider, summary slides), edit existing PPTX via XML workflows, or extract text with markitdown. | Official |
|
| `pptx-generator` | Generate, edit, and read PowerPoint presentations. Create from scratch with PptxGenJS (cover, TOC, content, section divider, summary slides), edit existing PPTX via XML workflows, or extract text with markitdown. | Official |
|
||||||
| `minimax-xlsx` | Open, create, read, analyze, edit, or validate Excel/spreadsheet files (.xlsx, .xlsm, .csv, .tsv). Covers creating new xlsx from scratch via XML templates, reading and analyzing with pandas, editing existing files with zero format loss, formula recalculation, validation, and professional financial formatting. | Official |
|
| `minimax-xlsx` | Open, create, read, analyze, edit, or validate Excel/spreadsheet files (.xlsx, .xlsm, .csv, .tsv). Covers creating new xlsx from scratch via XML templates, reading and analyzing with pandas, editing existing files with zero format loss, formula recalculation, validation, and professional financial formatting. | Official |
|
||||||
| `minimax-docx` | Professional DOCX document creation, editing, and formatting using OpenXML SDK (.NET). Three pipelines: create new documents from scratch, fill/edit content in existing documents, or apply template formatting with XSD validation gate-check. | Official |
|
| `minimax-docx` | Professional DOCX document creation, editing, and formatting using OpenXML SDK (.NET). Three pipelines: create new documents from scratch, fill/edit content in existing documents, or apply template formatting with XSD validation gate-check. | Official |
|
||||||
| `minimax-multimodal-toolkit` | Generate voice, music, video, and image content via MiniMax APIs — the unified entry for MiniMax multimodal use cases. Covers TTS (text-to-speech, voice cloning, voice design, multi-segment), music (songs, instrumentals), video (text-to-video, image-to-video, start-end frame, subject reference, templates, long-form multi-scene), image (text-to-image, image-to-image with character reference), and media processing (convert, concat, trim, extract) via FFmpeg. | Community |
|
| `vision-analysis` | Analyze, describe, and extract information from images using vision AI models. Supports describe, OCR, UI mockup review, chart data extraction, and object detection. Powered by MiniMax VL API with OpenAI GPT-4V fallback. | Community |
|
||||||
|
| `minimax-multimodal-toolkit` | Generate voice, music, video, and image content via MiniMax APIs — the unified entry for MiniMax multimodal use cases. Covers TTS (text-to-speech, voice cloning, voice design, multi-segment), music (songs, instrumentals), video (text-to-video, image-to-video, start-end frame, subject reference, templates, long-form multi-scene), image (text-to-image, image-to-image with character reference), and media processing (convert, concat, trim, extract) via FFmpeg. | Official |
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ git clone https://github.com/MiniMax-AI/skills.git ~/.cursor/minimax-skills
|
|||||||
```
|
```
|
||||||
|
|
||||||
Add to your Cursor settings — point the skills path to `~/.cursor/minimax-skills/skills/`.
|
Add to your Cursor settings — point the skills path to `~/.cursor/minimax-skills/skills/`.
|
||||||
|
For Windows setup and verification, see [`.cursor-plugin/INSTALL.md`](.cursor-plugin/INSTALL.md).
|
||||||
|
|
||||||
### Codex
|
### Codex
|
||||||
|
|
||||||
@@ -61,6 +65,17 @@ ln -s ~/.minimax-skills/skills/* ~/.config/opencode/skills/
|
|||||||
|
|
||||||
Restart OpenCode to discover the skills. See [`.opencode/INSTALL.md`](.opencode/INSTALL.md) for details.
|
Restart OpenCode to discover the skills. See [`.opencode/INSTALL.md`](.opencode/INSTALL.md) for details.
|
||||||
|
|
||||||
|
### VS Code
|
||||||
|
|
||||||
|
This repository does not currently ship a standalone VS Code extension.
|
||||||
|
|
||||||
|
If you use VS Code, the supported approach is to run one of the supported CLI tools inside the integrated terminal:
|
||||||
|
- Codex
|
||||||
|
- Claude Code
|
||||||
|
- OpenCode
|
||||||
|
|
||||||
|
If you want native local-skills configuration from this repo, use Cursor and follow [`.cursor-plugin/INSTALL.md`](.cursor-plugin/INSTALL.md).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
We welcome contributions! Before submitting a PR, please read:
|
We welcome contributions! Before submitting a PR, please read:
|
||||||
@@ -74,6 +89,10 @@ You can run the validation script locally before submitting:
|
|||||||
python .claude/skills/pr-review/scripts/validate_skills.py
|
python .claude/skills/pr-review/scripts/validate_skills.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ⭐ Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#MiniMax-AI/skills&Date)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[MIT](./LICENSE)
|
[MIT](./LICENSE)
|
||||||
|
|||||||
23
README_zh.md
23
README_zh.md
@@ -14,13 +14,16 @@
|
|||||||
| `fullstack-dev` | 全栈后端架构与前后端集成。REST API 设计、认证流程(JWT、Session、OAuth)、实时功能(SSE、WebSocket)、数据库集成(SQL / NoSQL)、生产环境加固与发布清单。引导式工作流:需求收集 → 架构决策 → 实现。 | Official |
|
| `fullstack-dev` | 全栈后端架构与前后端集成。REST API 设计、认证流程(JWT、Session、OAuth)、实时功能(SSE、WebSocket)、数据库集成(SQL / NoSQL)、生产环境加固与发布清单。引导式工作流:需求收集 → 架构决策 → 实现。 | Official |
|
||||||
| `android-native-dev` | 基于 Material Design 3 的 Android 原生应用开发。Kotlin / Jetpack Compose、自适应布局、Gradle 配置、无障碍(WCAG)、构建问题排查、性能优化与动效系统。 | Official |
|
| `android-native-dev` | 基于 Material Design 3 的 Android 原生应用开发。Kotlin / Jetpack Compose、自适应布局、Gradle 配置、无障碍(WCAG)、构建问题排查、性能优化与动效系统。 | Official |
|
||||||
| `ios-application-dev` | iOS 应用开发指南,涵盖 UIKit、SnapKit 和 SwiftUI。触控目标、安全区域、导航模式、Dynamic Type、深色模式、无障碍、集合视图,符合 Apple HIG 规范。 | Official |
|
| `ios-application-dev` | iOS 应用开发指南,涵盖 UIKit、SnapKit 和 SwiftUI。触控目标、安全区域、导航模式、Dynamic Type、深色模式、无障碍、集合视图,符合 Apple HIG 规范。 | Official |
|
||||||
|
| `flutter-dev` | Flutter 跨平台开发指南,涵盖 Widget 模式、Riverpod/Bloc 状态管理、GoRouter 导航、性能优化与测试策略。 | Official |
|
||||||
|
| `react-native-dev` | React Native 与 Expo 开发指南,涵盖组件、样式、动画、导航、状态管理、表单、网络请求、性能优化、测试、原生能力及工程化(项目结构、部署、SDK 升级、CI/CD)。 | Official |
|
||||||
| `shader-dev` | 全面的 GLSL 着色器技术,用于创建惊艳的视觉效果 — 光线行进、SDF 建模、流体模拟、粒子系统、程序化生成、光照、后处理等。兼容 ShaderToy。 | Official |
|
| `shader-dev` | 全面的 GLSL 着色器技术,用于创建惊艳的视觉效果 — 光线行进、SDF 建模、流体模拟、粒子系统、程序化生成、光照、后处理等。兼容 ShaderToy。 | Official |
|
||||||
| `gif-sticker-maker` | 将照片(人物、宠物、物品、Logo)转换为 4 张带字幕的动画 GIF 贴纸。Funko Pop / Pop Mart 盲盒风格,基于 MiniMax 图片与视频生成 API。 | Official |
|
| `gif-sticker-maker` | 将照片(人物、宠物、物品、Logo)转换为 4 张带字幕的动画 GIF 贴纸。Funko Pop / Pop Mart 盲盒风格,基于 MiniMax 图片与视频生成 API。 | Official |
|
||||||
| `minimax-pdf` | 基于 token 化设计系统生成、填写和重排 PDF 文档。支持三种模式:CREATE(从零生成,15 种封面风格)、FILL(填写现有表单字段)、REFORMAT(将已有文档重排为新设计)。排版与配色由文档类型自动推导,输出即可打印。 | Official |
|
| `minimax-pdf` | 基于 token 化设计系统生成、填写和重排 PDF 文档。支持三种模式:CREATE(从零生成,15 种封面风格)、FILL(填写现有表单字段)、REFORMAT(将已有文档重排为新设计)。排版与配色由文档类型自动推导,输出即可打印。 | Official |
|
||||||
| `pptx-generator` | 生成、编辑和读取 PowerPoint 演示文稿。支持用 PptxGenJS 从零创建(封面、目录、内容、分节页、总结页),通过 XML 工作流编辑现有 PPTX,或用 markitdown 提取文本。 | Official |
|
| `pptx-generator` | 生成、编辑和读取 PowerPoint 演示文稿。支持用 PptxGenJS 从零创建(封面、目录、内容、分节页、总结页),通过 XML 工作流编辑现有 PPTX,或用 markitdown 提取文本。 | Official |
|
||||||
| `minimax-xlsx` | 打开、创建、读取、分析、编辑或验证 Excel/电子表格文件(.xlsx、.xlsm、.csv、.tsv)。支持通过 XML 模板从零创建 xlsx、使用 pandas 读取分析、零格式损失编辑现有文件、公式重算与验证、专业财务格式化。 | Official |
|
| `minimax-xlsx` | 打开、创建、读取、分析、编辑或验证 Excel/电子表格文件(.xlsx、.xlsm、.csv、.tsv)。支持通过 XML 模板从零创建 xlsx、使用 pandas 读取分析、零格式损失编辑现有文件、公式重算与验证、专业财务格式化。 | Official |
|
||||||
| `minimax-docx` | 基于 OpenXML SDK(.NET)的专业 DOCX 文档创建、编辑与排版。三条流水线:从零创建新文档、填写/编辑现有文档内容、应用模板格式并通过 XSD 验证门控检查。 | Official |
|
| `minimax-docx` | 基于 OpenXML SDK(.NET)的专业 DOCX 文档创建、编辑与排版。三条流水线:从零创建新文档、填写/编辑现有文档内容、应用模板格式并通过 XSD 验证门控检查。 | Official |
|
||||||
| `minimax-multimodal-toolkit` | 通过 MiniMax API 生成语音、音乐、视频和图片内容 — MiniMax 多模态使用场景的统一入口。涵盖 TTS(文字转语音、声音克隆、声音设计、多段合成)、音乐(带词歌曲、纯音乐)、视频(文生视频、图生视频、首尾帧、主体参考、模板、长视频多场景)、图片(文生图、图生图含角色参考),以及基于 FFmpeg 的媒体处理(格式转换、拼接、裁剪、提取)。 | Community |
|
| `vision-analysis` | 使用视觉 AI 模型分析、描述和提取图像信息。支持描述、OCR 文字识别、UI 界面审查、图表数据提取和物体检测。基于 MiniMax VL API,OpenAI GPT-4V 作为备选。 | Community |
|
||||||
|
| `minimax-multimodal-toolkit` | 通过 MiniMax API 生成语音、音乐、视频和图片内容 — MiniMax 多模态使用场景的统一入口。涵盖 TTS(文字转语音、声音克隆、声音设计、多段合成)、音乐(带词歌曲、纯音乐)、视频(文生视频、图生视频、首尾帧、主体参考、模板、长视频多场景)、图片(文生图、图生图含角色参考),以及基于 FFmpeg 的媒体处理(格式转换、拼接、裁剪、提取)。 | Official |
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ git clone https://github.com/MiniMax-AI/skills.git ~/.cursor/minimax-skills
|
|||||||
```
|
```
|
||||||
|
|
||||||
在 Cursor 设置中将 skills 路径指向 `~/.cursor/minimax-skills/skills/`。
|
在 Cursor 设置中将 skills 路径指向 `~/.cursor/minimax-skills/skills/`。
|
||||||
|
Windows 安装与校验方式见 [`.cursor-plugin/INSTALL.md`](.cursor-plugin/INSTALL.md)。
|
||||||
|
|
||||||
### Codex
|
### Codex
|
||||||
|
|
||||||
@@ -59,7 +63,18 @@ mkdir -p ~/.config/opencode/skills
|
|||||||
ln -s ~/.minimax-skills/skills/* ~/.config/opencode/skills/
|
ln -s ~/.minimax-skills/skills/* ~/.config/opencode/skills/
|
||||||
```
|
```
|
||||||
|
|
||||||
重启 OpenCode 以发现技能。详见 [`.opencode/INSTALL.md`](.opencode/INSTALL.md)。
|
重启 OpenCode 以发现技能。详见 [`.opencode/INSTALL_zh.md`](.opencode/INSTALL_zh.md)。
|
||||||
|
|
||||||
|
### VS Code
|
||||||
|
|
||||||
|
当前仓库还没有提供独立的 VS Code 扩展。
|
||||||
|
|
||||||
|
如果你使用 VS Code,推荐方式是在集成终端里运行已支持的 CLI 工具:
|
||||||
|
- Codex
|
||||||
|
- Claude Code
|
||||||
|
- OpenCode
|
||||||
|
|
||||||
|
如果你希望直接使用本仓库的本地 skills 配置,建议使用 Cursor,并参考 [`.cursor-plugin/INSTALL.md`](.cursor-plugin/INSTALL.md)。
|
||||||
|
|
||||||
## 贡献
|
## 贡献
|
||||||
|
|
||||||
@@ -74,6 +89,10 @@ ln -s ~/.minimax-skills/skills/* ~/.config/opencode/skills/
|
|||||||
python .claude/skills/pr-review/scripts/validate_skills.py
|
python .claude/skills/pr-review/scripts/validate_skills.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ⭐ Star History
|
||||||
|
|
||||||
|
[](https://star-history.com/#MiniMax-AI/skills&Date)
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
[MIT](./LICENSE)
|
[MIT](./LICENSE)
|
||||||
|
|||||||
@@ -780,3 +780,104 @@ See [Design Style Guide](references/design-style-guide.md) for detailed style pr
|
|||||||
| Privacy & Security | [Privacy & Security](references/privacy-security.md) |
|
| Privacy & Security | [Privacy & Security](references/privacy-security.md) |
|
||||||
| Audio, Video, Notifications | [Functional Requirements](references/functional-requirements.md) |
|
| Audio, Video, Notifications | [Functional Requirements](references/functional-requirements.md) |
|
||||||
| App Style by Category | [Design Style Guide](references/design-style-guide.md) |
|
| App Style by Category | [Design Style Guide](references/design-style-guide.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Testing
|
||||||
|
|
||||||
|
> **Note**: Only add test dependencies when the user explicitly asks for testing.
|
||||||
|
|
||||||
|
A well-tested Android app uses layered testing: fast local unit tests for logic, instrumentation tests for UI and integration, and Gradle Managed Devices to run emulators reproducibly on any machine — including CI.
|
||||||
|
|
||||||
|
### 8.1 Test Dependencies
|
||||||
|
|
||||||
|
Before adding test dependencies, inspect the project's existing versions to avoid conflicts:
|
||||||
|
|
||||||
|
1. Check `gradle/libs.versions.toml` — if present, add test deps using the project's version catalog style
|
||||||
|
2. Check existing `build.gradle.kts` for already-pinned dependency versions
|
||||||
|
3. Match version families using the table below
|
||||||
|
|
||||||
|
**Version Alignment Rules**:
|
||||||
|
|
||||||
|
| Test Dependency | Must Align With | How to Check |
|
||||||
|
|----------------------------------------------|--------------------------------------------------|-----------------------------------------------------------------------|
|
||||||
|
| `kotlinx-coroutines-test` | Project's `kotlinx-coroutines-core` version | Search for `kotlinx-coroutines` in build files or version catalog |
|
||||||
|
| `compose-ui-test-junit4` | Project's Compose BOM or `compose-compiler` | Search for `compose-bom` or `compose.compiler` in build files |
|
||||||
|
| `espresso-*` | All Espresso artifacts must use the same version | Search for `espresso` in build files |
|
||||||
|
| `androidx.test:runner`, `rules`, `ext:junit` | Should use compatible AndroidX Test versions | Search for `androidx.test` in build files |
|
||||||
|
| `mockk` | Must support the project's Kotlin version | Check `kotlin` version in root `build.gradle.kts` or version catalog |
|
||||||
|
|
||||||
|
**Dependencies Reference** — add only the groups you need:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
dependencies {
|
||||||
|
// --- Local unit tests (src/test/) ---
|
||||||
|
testImplementation("junit:junit:<version>") // 4.13.2+
|
||||||
|
testImplementation("org.robolectric:robolectric:<version>") // 4.16.1+
|
||||||
|
testImplementation("io.mockk:mockk:<version>") // match Kotlin version
|
||||||
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:<version>") // match coroutines-core
|
||||||
|
testImplementation("androidx.arch.core:core-testing:<version>") // InstantTaskExecutorRule for LiveData
|
||||||
|
testImplementation("app.cash.turbine:turbine:<version>") // Flow/StateFlow testing
|
||||||
|
|
||||||
|
// --- Instrumentation tests (src/androidTest/) ---
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:<version>")
|
||||||
|
androidTestImplementation("androidx.test:runner:<version>")
|
||||||
|
androidTestImplementation("androidx.test:rules:<version>")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:<version>")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-contrib:<version>") // RecyclerView, Drawer
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-intents:<version>") // Intent verification
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-idling-resource:<version>")
|
||||||
|
androidTestImplementation("androidx.test.uiautomator:uiautomator:<version>")
|
||||||
|
|
||||||
|
// --- Compose UI tests (only if project uses Compose) ---
|
||||||
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4") // version from Compose BOM
|
||||||
|
debugImplementation("androidx.compose.ui:ui-test-manifest") // required for createComposeRule
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note**: If the project uses a Compose BOM, `ui-test-junit4` and `ui-test-manifest` don't need explicit versions — the BOM manages them.
|
||||||
|
|
||||||
|
Enable Robolectric resource support in the `android` block:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
android {
|
||||||
|
testOptions {
|
||||||
|
unitTests.isIncludeAndroidResources = true // required for Robolectric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Testing by Layer
|
||||||
|
|
||||||
|
| Layer | Location | Runs On | Speed | Use For |
|
||||||
|
|--------------------|--------------------|-------------------------|----------------------|--------------------------------------------------|
|
||||||
|
| Unit (JUnit) | `src/test/` | JVM | ~ms | ViewModels, repos, mappers, validators |
|
||||||
|
| Unit + Robolectric | `src/test/` | JVM + simulated Android | ~100ms | Code needing Context, resources, SharedPrefs |
|
||||||
|
| Compose UI (local) | `src/test/` | JVM + Robolectric | ~100ms | Composable rendering & interaction |
|
||||||
|
| Espresso | `src/androidTest/` | Device/Emulator | ~seconds | View-based UI flows, Intents, DB integration |
|
||||||
|
| Compose UI (device)| `src/androidTest/` | Device/Emulator | ~seconds | Full Compose UI flows with real rendering |
|
||||||
|
| UI Automator | `src/androidTest/` | Device/Emulator | ~seconds | System dialogs, notifications, multi-app |
|
||||||
|
| Managed Device | `src/androidTest/` | Gradle-managed AVD | ~minutes (first run) | CI, matrix testing across API levels |
|
||||||
|
|
||||||
|
See [Testing](references/testing.md) for detailed examples, code patterns, and Gradle Managed Device configuration.
|
||||||
|
|
||||||
|
### 8.3 Testing Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local unit tests (fast, no emulator)
|
||||||
|
./gradlew test # all modules
|
||||||
|
./gradlew :app:testDebugUnitTest # app module, debug variant
|
||||||
|
|
||||||
|
# Single test class
|
||||||
|
./gradlew :app:testDebugUnitTest --tests "com.example.myapp.CounterViewModelTest"
|
||||||
|
|
||||||
|
# Instrumentation tests (requires device or managed device)
|
||||||
|
./gradlew connectedDebugAndroidTest # on connected device
|
||||||
|
./gradlew pixel6Api34DebugAndroidTest # on managed device
|
||||||
|
|
||||||
|
# Both together
|
||||||
|
./gradlew test connectedDebugAndroidTest
|
||||||
|
|
||||||
|
# Test with coverage report (JaCoCo)
|
||||||
|
./gradlew testDebugUnitTest jacocoTestReport
|
||||||
|
```
|
||||||
|
|||||||
554
skills/android-native-dev/references/testing.md
Normal file
554
skills/android-native-dev/references/testing.md
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
Detailed examples and patterns for each Android test layer. Read the section relevant to the layer you're working with.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Local Unit Tests (JUnit + Robolectric)](#1-local-unit-tests-junit--robolectric)
|
||||||
|
2. [Instrumentation Tests (Espresso)](#2-instrumentation-tests-espresso)
|
||||||
|
3. [UI Automator (Cross-App & System UI)](#3-ui-automator-cross-app--system-ui)
|
||||||
|
4. [Compose UI Testing](#4-compose-ui-testing)
|
||||||
|
5. [Gradle Managed Devices](#5-gradle-managed-devices)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Local Unit Tests (JUnit + Robolectric)
|
||||||
|
|
||||||
|
Local tests live in `src/test/` and run on the JVM — no emulator needed, so they're fast (milliseconds each). Use them for ViewModels, Repositories, mappers, validators, and any pure logic.
|
||||||
|
|
||||||
|
### Basic ViewModel Test
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
class CounterViewModelTest {
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule() // see below
|
||||||
|
|
||||||
|
private lateinit var viewModel: CounterViewModel
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
viewModel = CounterViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `increment updates count`() = runTest {
|
||||||
|
viewModel.increment()
|
||||||
|
assertEquals(1, viewModel.uiState.value.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Coroutines (Critical)
|
||||||
|
|
||||||
|
The Main dispatcher doesn't exist on the JVM. Replace it with `TestDispatcher` or tests crash with `IllegalStateException`.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Reusable rule — put in a shared test-util module
|
||||||
|
class MainDispatcherRule(
|
||||||
|
private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
|
||||||
|
) : TestWatcher() {
|
||||||
|
override fun starting(description: Description) {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
}
|
||||||
|
override fun finished(description: Description) {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ❌ Wrong: No Main dispatcher replacement → crash
|
||||||
|
@Test
|
||||||
|
fun `load data`() = runTest {
|
||||||
|
val vm = MyViewModel(repo)
|
||||||
|
vm.load() // launches on Dispatchers.Main → IllegalStateException
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Correct: Use MainDispatcherRule
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `load data`() = runTest {
|
||||||
|
val vm = MyViewModel(repo)
|
||||||
|
vm.load()
|
||||||
|
assertEquals(UiState.Success, vm.uiState.value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing StateFlow with Turbine
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `loading then success states`() = runTest {
|
||||||
|
val vm = MyViewModel(fakeRepo)
|
||||||
|
|
||||||
|
vm.uiState.test { // Turbine extension
|
||||||
|
assertEquals(UiState.Idle, awaitItem())
|
||||||
|
vm.load()
|
||||||
|
assertEquals(UiState.Loading, awaitItem())
|
||||||
|
assertEquals(UiState.Success(data), awaitItem())
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mocking with MockK
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `repository calls api and caches`() = runTest {
|
||||||
|
val api = mockk<UserApi>()
|
||||||
|
coEvery { api.getUser("42") } returns User("42", "Alice")
|
||||||
|
|
||||||
|
val repo = UserRepository(api)
|
||||||
|
val user = repo.getUser("42")
|
||||||
|
|
||||||
|
assertEquals("Alice", user.name)
|
||||||
|
coVerify(exactly = 1) { api.getUser("42") }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| MockK Function | Purpose |
|
||||||
|
|----------------|------------------------|
|
||||||
|
| `mockk<T>()` | Create mock instance |
|
||||||
|
| `every { }` | Stub synchronous calls |
|
||||||
|
| `coEvery { }` | Stub suspend functions |
|
||||||
|
| `verify { }` | Verify call happened |
|
||||||
|
| `coVerify { }` | Verify suspend call |
|
||||||
|
| `slot<T>()` | Capture argument value |
|
||||||
|
|
||||||
|
### Robolectric — When You Need Android Classes
|
||||||
|
|
||||||
|
Robolectric simulates the Android framework on the JVM, so tests stay fast while accessing `Context`, `SharedPreferences`, resources, etc.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(sdk = [34])
|
||||||
|
class PreferencesManagerTest {
|
||||||
|
|
||||||
|
private lateinit var context: Context
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `saves and reads theme preference`() {
|
||||||
|
val prefs = PreferencesManager(context)
|
||||||
|
prefs.setDarkMode(true)
|
||||||
|
assertTrue(prefs.isDarkMode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Local Test Mistakes
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ❌ Wrong: Testing implementation details (fragile)
|
||||||
|
@Test
|
||||||
|
fun `check internal cache map size`() {
|
||||||
|
repo.load()
|
||||||
|
assertEquals(1, repo.cacheMap.size) // breaks if cache strategy changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Correct: Test observable behavior
|
||||||
|
@Test
|
||||||
|
fun `second call returns cached result without network`() = runTest {
|
||||||
|
coEvery { api.fetch() } returns data
|
||||||
|
|
||||||
|
repo.load()
|
||||||
|
repo.load()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { api.fetch() } // only one network call
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Instrumentation Tests (Espresso)
|
||||||
|
|
||||||
|
Instrumentation tests live in `src/androidTest/` and run on a real device or emulator. Slower than local tests, but they exercise the actual Android stack — use them for UI flows, database integration, and cross-component interaction.
|
||||||
|
|
||||||
|
### Test Runner Setup
|
||||||
|
|
||||||
|
In `app/build.gradle.kts`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
android {
|
||||||
|
defaultConfig {
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Espresso Basics
|
||||||
|
|
||||||
|
Espresso's API follows a consistent pattern: **find → act → assert**.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class LoginScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validLogin_navigatesToHome() {
|
||||||
|
// Find and act
|
||||||
|
onView(withId(R.id.email_input))
|
||||||
|
.perform(typeText("user@example.com"), closeSoftKeyboard())
|
||||||
|
onView(withId(R.id.password_input))
|
||||||
|
.perform(typeText("secret123"), closeSoftKeyboard())
|
||||||
|
onView(withId(R.id.login_button))
|
||||||
|
.perform(click())
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
onView(withId(R.id.home_container))
|
||||||
|
.check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Category | Common Matchers / Actions |
|
||||||
|
|------------|------------------------------------------------------------------------------------|
|
||||||
|
| **Find** | `withId(R.id.x)`, `withText("x")`, `withContentDescription("x")`, `withHint("x")` |
|
||||||
|
| **Act** | `click()`, `typeText("x")`, `clearText()`, `scrollTo()`, `swipeUp()` |
|
||||||
|
| **Assert** | `isDisplayed()`, `withText("x")`, `isEnabled()`, `isChecked()`, `doesNotExist()` |
|
||||||
|
|
||||||
|
### Testing Intents
|
||||||
|
|
||||||
|
Espresso-Intents lets you verify outgoing Intents and stub responses (e.g., camera, file picker).
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@get:Rule
|
||||||
|
val intentsRule = IntentsRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shareButton_launchesShareIntent() {
|
||||||
|
onView(withId(R.id.share_button)).perform(click())
|
||||||
|
|
||||||
|
intended(allOf(
|
||||||
|
hasAction(Intent.ACTION_SEND),
|
||||||
|
hasType("text/plain")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cameraButton_handlesResult() {
|
||||||
|
val resultData = Intent().apply { putExtra("photo_uri", "content://mock") }
|
||||||
|
intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(RESULT_OK, resultData))
|
||||||
|
|
||||||
|
onView(withId(R.id.camera_button)).perform(click())
|
||||||
|
onView(withId(R.id.photo_preview)).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IdlingResource for Async Operations
|
||||||
|
|
||||||
|
Espresso waits for the UI thread and AsyncTask by default, but not for custom async work (Retrofit, coroutines, etc.). `IdlingResource` tells Espresso when your app is busy.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// In production code (thin wrapper)
|
||||||
|
object NetworkIdlingResource {
|
||||||
|
private val counter = CountingIdlingResource("Network")
|
||||||
|
fun increment() = counter.increment()
|
||||||
|
fun decrement() = counter.decrement()
|
||||||
|
fun get(): IdlingResource = counter
|
||||||
|
}
|
||||||
|
|
||||||
|
// In test setup
|
||||||
|
@Before
|
||||||
|
fun registerIdling() {
|
||||||
|
IdlingRegistry.getInstance().register(NetworkIdlingResource.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun unregisterIdling() {
|
||||||
|
IdlingRegistry.getInstance().unregister(NetworkIdlingResource.get())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. UI Automator (Cross-App & System UI)
|
||||||
|
|
||||||
|
UI Automator can interact with any visible UI — system dialogs, notifications, other apps. Use it when Espresso can't reach outside your app's process.
|
||||||
|
|
||||||
|
| Use Case | Why UI Automator |
|
||||||
|
|------------------------------|----------------------------------------|
|
||||||
|
| Runtime permission dialogs | System UI, outside app process |
|
||||||
|
| Notification actions | System notification shade |
|
||||||
|
| Device settings interaction | Settings app |
|
||||||
|
| Multi-app workflows | e.g., share to another app and return |
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PermissionFlowTest {
|
||||||
|
|
||||||
|
private lateinit var device: UiDevice
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun grantsCameraPermission_andOpensCamera() {
|
||||||
|
// Trigger permission request from within your app
|
||||||
|
onView(withId(R.id.camera_button)).perform(click())
|
||||||
|
|
||||||
|
// Handle the system permission dialog via UI Automator
|
||||||
|
val allowButton = device.findObject(
|
||||||
|
By.res("com.android.permissioncontroller:id/permission_allow_foreground_only_button")
|
||||||
|
)
|
||||||
|
allowButton?.click()
|
||||||
|
|
||||||
|
// Back in Espresso territory — verify the camera view appeared
|
||||||
|
onView(withId(R.id.camera_preview)).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notificationTap_opensDetail() {
|
||||||
|
// Open notification shade
|
||||||
|
device.openNotification()
|
||||||
|
device.wait(Until.hasObject(By.textStartsWith("New message")), 5000)
|
||||||
|
|
||||||
|
// Tap the notification
|
||||||
|
val notification = device.findObject(By.textStartsWith("New message"))
|
||||||
|
notification.click()
|
||||||
|
|
||||||
|
// Verify deep-link target
|
||||||
|
onView(withId(R.id.message_detail)).check(matches(isDisplayed()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Compose UI Testing
|
||||||
|
|
||||||
|
Compose has its own testing framework that works with the semantic tree rather than the view hierarchy. Tests can run as local tests (with Robolectric) or instrumentation tests — the API is the same.
|
||||||
|
|
||||||
|
### Basic Setup
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class GreetingScreenTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun displaysGreeting_andRespondsToClick() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
MyAppTheme {
|
||||||
|
GreetingScreen(name = "World")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Hello, World!")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Say Hi")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Hi back!")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finders, Assertions & Actions
|
||||||
|
|
||||||
|
| Category | API | Example |
|
||||||
|
|------------|----------------------------------------------|---------------------------------|
|
||||||
|
| **Find** | `onNodeWithText("x")` | Matches visible text |
|
||||||
|
| | `onNodeWithTag("x")` | Matches `Modifier.testTag("x")` |
|
||||||
|
| | `onNodeWithContentDescription("x")` | Matches semantics label |
|
||||||
|
| | `onAllNodesWithTag("x")` | Returns list of matches |
|
||||||
|
| **Assert** | `assertIsDisplayed()` | Node is visible |
|
||||||
|
| | `assertTextEquals("x")` | Exact text match |
|
||||||
|
| | `assertIsEnabled()` / `assertIsNotEnabled()` | Enabled state |
|
||||||
|
| | `assertDoesNotExist()` | Node not in tree |
|
||||||
|
| | `assertCountEquals(n)` | For `onAllNodes` |
|
||||||
|
| **Act** | `performClick()` | Tap |
|
||||||
|
| | `performTextInput("x")` | Type into text field |
|
||||||
|
| | `performScrollTo()` | Scroll node into view |
|
||||||
|
| | `performTouchInput { swipeUp() }` | Gestures |
|
||||||
|
|
||||||
|
### Using testTag for Reliable Selectors
|
||||||
|
|
||||||
|
Text-based finders break with localization or copy changes. Use `testTag` for stable selectors:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ❌ Fragile: breaks if text changes or app is localized
|
||||||
|
composeTestRule.onNodeWithText("Submit Order").performClick()
|
||||||
|
|
||||||
|
// ✅ Stable: testTag doesn't change with locale
|
||||||
|
composeTestRule.onNodeWithTag("submit_order_button").performClick()
|
||||||
|
```
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// In production Composable
|
||||||
|
Button(
|
||||||
|
onClick = { /* ... */ },
|
||||||
|
modifier = Modifier.testTag("submit_order_button")
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.submit_order))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing with Activity Context
|
||||||
|
|
||||||
|
When your Composable needs a `ComponentActivity` (e.g., for `viewModel()` or navigation), use `createAndroidComposeRule`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullScreen_endToEnd() {
|
||||||
|
// Activity is already launched — interact with the real content
|
||||||
|
composeTestRule.onNodeWithTag("login_email")
|
||||||
|
.performTextInput("user@test.com")
|
||||||
|
composeTestRule.onNodeWithTag("login_password")
|
||||||
|
.performTextInput("pass123")
|
||||||
|
composeTestRule.onNodeWithTag("login_submit")
|
||||||
|
.performClick()
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 5000) {
|
||||||
|
composeTestRule.onAllNodesWithTag("home_screen")
|
||||||
|
.fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("home_screen")
|
||||||
|
.assertIsDisplayed()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Navigation
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun navigatesToDetail_onItemClick() {
|
||||||
|
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
navController.navigatorProvider.addNavigator(ComposeNavigator())
|
||||||
|
MyAppNavHost(navController = navController)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click item on list screen
|
||||||
|
composeTestRule.onNodeWithTag("item_0").performClick()
|
||||||
|
|
||||||
|
// Verify navigation destination
|
||||||
|
assertEquals("detail/0", navController.currentBackStackEntry?.destination?.route)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Compose Test Mistakes
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// ❌ Wrong: Asserting immediately after async operation
|
||||||
|
composeTestRule.onNodeWithTag("submit").performClick()
|
||||||
|
composeTestRule.onNodeWithText("Success").assertIsDisplayed() // may fail — UI hasn't updated yet
|
||||||
|
|
||||||
|
// ✅ Correct: Wait for the UI to settle
|
||||||
|
composeTestRule.onNodeWithTag("submit").performClick()
|
||||||
|
composeTestRule.waitForIdle()
|
||||||
|
composeTestRule.onNodeWithText("Success").assertIsDisplayed()
|
||||||
|
|
||||||
|
// ✅ Also correct: waitUntil for longer async work
|
||||||
|
composeTestRule.onNodeWithTag("submit").performClick()
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = 3000) {
|
||||||
|
composeTestRule.onAllNodesWithText("Success")
|
||||||
|
.fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Gradle Managed Devices
|
||||||
|
|
||||||
|
Define emulator profiles in `build.gradle.kts` so anyone (including CI) can run instrumentation tests without manually creating AVDs. Gradle downloads the system image, creates the emulator, runs tests, and tears it down automatically.
|
||||||
|
|
||||||
|
### Device Configuration
|
||||||
|
|
||||||
|
In `app/build.gradle.kts`:
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
android {
|
||||||
|
testOptions {
|
||||||
|
managedDevices {
|
||||||
|
localDevices {
|
||||||
|
create("pixel6Api34") {
|
||||||
|
device = "Pixel 6"
|
||||||
|
apiLevel = 34
|
||||||
|
systemImageSource = "aosp-atd" // ATD = faster, headless
|
||||||
|
}
|
||||||
|
create("pixel4Api30") {
|
||||||
|
device = "Pixel 4"
|
||||||
|
apiLevel = 30
|
||||||
|
systemImageSource = "aosp-atd"
|
||||||
|
}
|
||||||
|
create("smallTabletApi34") {
|
||||||
|
device = "Nexus 7"
|
||||||
|
apiLevel = 34
|
||||||
|
systemImageSource = "google" // full Google APIs image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group devices for matrix testing
|
||||||
|
groups {
|
||||||
|
create("phoneTests") {
|
||||||
|
targetDevices.add(devices["pixel6Api34"])
|
||||||
|
targetDevices.add(devices["pixel4Api30"])
|
||||||
|
}
|
||||||
|
create("allDevices") {
|
||||||
|
targetDevices.add(devices["pixel6Api34"])
|
||||||
|
targetDevices.add(devices["pixel4Api30"])
|
||||||
|
targetDevices.add(devices["smallTabletApi34"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### System Image Sources
|
||||||
|
|
||||||
|
| Source | Description | Best For |
|
||||||
|
|----------------|---------------------------------------------------|------------------------------|
|
||||||
|
| `"aosp-atd"` | Automated Test Device — minimal, no Play Services | Fast CI, pure logic tests |
|
||||||
|
| `"google-atd"` | ATD with Google APIs | Tests needing Maps, Firebase |
|
||||||
|
| `"aosp"` | Full AOSP image | Standard emulator testing |
|
||||||
|
| `"google"` | Full image with Google Play Services | Play Services integration |
|
||||||
|
|
||||||
|
ATD images boot faster and consume less memory because they strip out UI chrome and preinstalled apps irrelevant to testing. Prefer `aosp-atd` or `google-atd` for CI pipelines.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run on a single managed device
|
||||||
|
./gradlew pixel6Api34DebugAndroidTest
|
||||||
|
|
||||||
|
# Run on a device group (all devices in parallel if hardware allows)
|
||||||
|
./gradlew phoneTestsGroupDebugAndroidTest
|
||||||
|
./gradlew allDevicesGroupDebugAndroidTest
|
||||||
|
|
||||||
|
# With specific flavor
|
||||||
|
./gradlew pixel6Api34DevDebugAndroidTest
|
||||||
|
|
||||||
|
# Enable test sharding across devices (speeds up large suites)
|
||||||
|
./gradlew allDevicesGroupDebugAndroidTest \
|
||||||
|
-Pandroid.experimental.androidTest.numManagedDeviceShards=2
|
||||||
|
|
||||||
|
# Generate HTML test report
|
||||||
|
./gradlew pixel6Api34DebugAndroidTest \
|
||||||
|
--continue # don't stop on first failure
|
||||||
|
```
|
||||||
|
|
||||||
|
Test results are written to `app/build/reports/androidTests/managedDevice/`.
|
||||||
128
skills/flutter-dev/SKILL.md
Normal file
128
skills/flutter-dev/SKILL.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
name: flutter-dev
|
||||||
|
description: |
|
||||||
|
Flutter cross-platform development guide covering widget patterns, Riverpod/Bloc state management, GoRouter navigation, performance optimization, and platform-specific implementations. Includes const optimization, responsive layouts, testing strategies, and DevTools profiling.
|
||||||
|
Use when: building Flutter apps, implementing state management (Riverpod/Bloc), setting up GoRouter navigation, creating custom widgets, optimizing performance, writing widget tests, cross-platform development.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
category: mobile
|
||||||
|
sources:
|
||||||
|
- Flutter Documentation
|
||||||
|
- Riverpod Documentation
|
||||||
|
- Bloc Library Documentation
|
||||||
|
---
|
||||||
|
|
||||||
|
# Flutter Development Guide
|
||||||
|
|
||||||
|
A practical guide for building cross-platform applications with Flutter 3 and Dart. Focuses on proven patterns, state management, and performance optimization.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Widget Patterns
|
||||||
|
|
||||||
|
| Purpose | Component |
|
||||||
|
|---------|-----------|
|
||||||
|
| State management (simple) | `StateProvider` + `ConsumerWidget` |
|
||||||
|
| State management (complex) | `NotifierProvider` / `Bloc` |
|
||||||
|
| Async data | `FutureProvider` / `AsyncNotifierProvider` |
|
||||||
|
| Real-time streams | `StreamProvider` |
|
||||||
|
| Navigation | `GoRouter` + `context.go/push` |
|
||||||
|
| Responsive layout | `LayoutBuilder` + breakpoints |
|
||||||
|
| List display | `ListView.builder` |
|
||||||
|
| Complex scrolling | `CustomScrollView` + Slivers |
|
||||||
|
| Hooks | `HookWidget` + `useState/useEffect` |
|
||||||
|
| Forms | `Form` + `TextFormField` + validation |
|
||||||
|
|
||||||
|
### Performance Patterns
|
||||||
|
|
||||||
|
| Purpose | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| Prevent rebuilds | `const` constructors |
|
||||||
|
| Selective updates | `ref.watch(provider.select(...))` |
|
||||||
|
| Isolate repaints | `RepaintBoundary` |
|
||||||
|
| Lazy lists | `ListView.builder` |
|
||||||
|
| Heavy computation | `compute()` isolate |
|
||||||
|
| Image caching | `cached_network_image` |
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### Widget Optimization
|
||||||
|
- Use `const` constructors wherever possible
|
||||||
|
- Extract static widgets to separate const classes
|
||||||
|
- Use `Key` for list items (ValueKey, ObjectKey)
|
||||||
|
- Prefer `ConsumerWidget` over `StatefulWidget` for state
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Riverpod for dependency injection and simple state
|
||||||
|
- Bloc/Cubit for event-driven workflows and complex logic
|
||||||
|
- Never mutate state directly (create new instances)
|
||||||
|
- Use `select()` to minimize rebuilds
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- 8pt spacing increments (8, 16, 24, 32, 48)
|
||||||
|
- Responsive breakpoints: mobile (<650), tablet (650-1100), desktop (>1100)
|
||||||
|
- Support all screen sizes with flexible layouts
|
||||||
|
- Follow Material 3 / Cupertino design guidelines
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Profile with DevTools before optimizing
|
||||||
|
- Target <16ms frame time for 60fps
|
||||||
|
- Use `RepaintBoundary` for complex animations
|
||||||
|
- Offload heavy work with `compute()`
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
### Widget Best Practices
|
||||||
|
- [ ] `const` constructors on all static widgets
|
||||||
|
- [ ] Proper `Key` on list items
|
||||||
|
- [ ] `ConsumerWidget` for state-dependent widgets
|
||||||
|
- [ ] No widget building inside `build()` method
|
||||||
|
- [ ] Extract reusable widgets to separate files
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- [ ] Immutable state objects
|
||||||
|
- [ ] `select()` for granular rebuilds
|
||||||
|
- [ ] Proper provider scoping
|
||||||
|
- [ ] Dispose controllers and subscriptions
|
||||||
|
- [ ] Handle loading/error states
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- [ ] GoRouter with typed routes
|
||||||
|
- [ ] Auth guards via redirect
|
||||||
|
- [ ] Deep linking support
|
||||||
|
- [ ] State preservation across routes
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] Profile mode testing (`flutter run --profile`)
|
||||||
|
- [ ] <16ms frame rendering time
|
||||||
|
- [ ] No unnecessary rebuilds (DevTools check)
|
||||||
|
- [ ] Images cached and resized
|
||||||
|
- [ ] Heavy computation in isolates
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Widget tests for UI components
|
||||||
|
- [ ] Unit tests for business logic
|
||||||
|
- [ ] Integration tests for user flows
|
||||||
|
- [ ] Bloc tests with `blocTest()`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
| Topic | Reference |
|
||||||
|
|-------|-----------|
|
||||||
|
| Widget patterns, const optimization, responsive layout | [Widget Patterns](references/widget-patterns.md) |
|
||||||
|
| Riverpod providers, notifiers, async state | [Riverpod State Management](references/riverpod-state.md) |
|
||||||
|
| Bloc, Cubit, event-driven state | [Bloc State Management](references/bloc-state.md) |
|
||||||
|
| GoRouter setup, routes, deep linking | [GoRouter Navigation](references/gorouter-navigation.md) |
|
||||||
|
| Feature-based structure, dependencies | [Project Structure](references/project-structure.md) |
|
||||||
|
| Profiling, const optimization, DevTools | [Performance Optimization](references/performance.md) |
|
||||||
|
| Widget tests, integration tests, mocking | [Testing Strategies](references/testing.md) |
|
||||||
|
| iOS/Android/Web specific implementations | [Platform Integration](references/platform-specific.md) |
|
||||||
|
| Implicit/explicit animations, Hero, transitions | [Animations](references/animations.md) |
|
||||||
|
| Dio, interceptors, error handling, caching | [Networking](references/networking.md) |
|
||||||
|
| Form validation, FormField, input formatters | [Forms](references/forms.md) |
|
||||||
|
| i18n, flutter_localizations, intl | [Localization](references/localization.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Flutter, Dart, Material Design, and Cupertino are trademarks of Google LLC and Apple Inc. respectively. Riverpod, Bloc, and GoRouter are open-source packages by their respective maintainers.
|
||||||
497
skills/flutter-dev/references/animations.md
Normal file
497
skills/flutter-dev/references/animations.md
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
# Animations
|
||||||
|
|
||||||
|
Flutter animation patterns covering implicit animations, explicit animations, Hero transitions, and page transitions.
|
||||||
|
|
||||||
|
## Implicit Animations
|
||||||
|
|
||||||
|
Use implicit animations for simple property changes:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ImplicitAnimationExample extends StatefulWidget {
|
||||||
|
const ImplicitAnimationExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImplicitAnimationExample> createState() => _ImplicitAnimationExampleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImplicitAnimationExampleState extends State<ImplicitAnimationExample> {
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
width: _expanded ? 200 : 100,
|
||||||
|
height: _expanded ? 200 : 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _expanded ? Colors.blue : Colors.red,
|
||||||
|
borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
|
||||||
|
),
|
||||||
|
child: const Center(child: Text('Tap me')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Implicit Widgets
|
||||||
|
|
||||||
|
| Widget | Animates |
|
||||||
|
|--------|----------|
|
||||||
|
| `AnimatedContainer` | Size, color, padding, decoration |
|
||||||
|
| `AnimatedOpacity` | Opacity |
|
||||||
|
| `AnimatedPadding` | Padding |
|
||||||
|
| `AnimatedPositioned` | Position in Stack |
|
||||||
|
| `AnimatedAlign` | Alignment |
|
||||||
|
| `AnimatedCrossFade` | Cross-fade between two widgets |
|
||||||
|
| `AnimatedSwitcher` | Transition between child widgets |
|
||||||
|
| `AnimatedDefaultTextStyle` | Text style |
|
||||||
|
| `AnimatedScale` | Scale transform |
|
||||||
|
| `AnimatedRotation` | Rotation transform |
|
||||||
|
| `AnimatedSlide` | Slide offset |
|
||||||
|
|
||||||
|
### AnimatedSwitcher
|
||||||
|
|
||||||
|
```dart
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(animation),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _showFirst
|
||||||
|
? const Icon(Icons.check, key: ValueKey('check'))
|
||||||
|
: const Icon(Icons.close, key: ValueKey('close')),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Explicit Animations
|
||||||
|
|
||||||
|
Use explicit animations for complex, custom, or controlled animations:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ExplicitAnimationExample extends StatefulWidget {
|
||||||
|
const ExplicitAnimationExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExplicitAnimationExample> createState() => _ExplicitAnimationExampleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExplicitAnimationExampleState extends State<ExplicitAnimationExample>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final Animation<double> _scaleAnimation;
|
||||||
|
late final Animation<double> _rotationAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||||
|
);
|
||||||
|
|
||||||
|
_rotationAnimation = Tween<double>(begin: 0, end: 0.1).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.elasticOut),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: (_) => _controller.forward(),
|
||||||
|
onTapUp: (_) => _controller.reverse(),
|
||||||
|
onTapCancel: () => _controller.reverse(),
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: _rotationAnimation.value,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Card(child: Padding(padding: EdgeInsets.all(24), child: Text('Press me'))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation with Hooks
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
class AnimatedButtonHook extends HookWidget {
|
||||||
|
const AnimatedButtonHook({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = useAnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
);
|
||||||
|
final scale = useAnimation(
|
||||||
|
Tween<double>(begin: 1.0, end: 0.95).animate(
|
||||||
|
CurvedAnimation(parent: controller, curve: Curves.easeInOut),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: (_) => controller.forward(),
|
||||||
|
onTapUp: (_) => controller.reverse(),
|
||||||
|
onTapCancel: () => controller.reverse(),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: scale,
|
||||||
|
child: const Card(child: Text('Animated Button')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Staggered Animations
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class StaggeredAnimation extends StatefulWidget {
|
||||||
|
const StaggeredAnimation({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StaggeredAnimation> createState() => _StaggeredAnimationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StaggeredAnimationState extends State<StaggeredAnimation>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final List<Animation<double>> _itemAnimations;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 1500),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_itemAnimations = List.generate(5, (index) {
|
||||||
|
final start = index * 0.1;
|
||||||
|
final end = start + 0.4;
|
||||||
|
return Tween<double>(begin: 0, end: 1).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Interval(start, end.clamp(0, 1), curve: Curves.easeOut),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: List.generate(5, (index) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _itemAnimations[index],
|
||||||
|
builder: (context, child) {
|
||||||
|
return Opacity(
|
||||||
|
opacity: _itemAnimations[index].value,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, 20 * (1 - _itemAnimations[index].value)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: ListTile(title: Text('Item $index')),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hero Animations
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class HeroSourcePage extends StatelessWidget {
|
||||||
|
const HeroSourcePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GridView.builder(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
),
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => context.push('/detail/${item.id}'),
|
||||||
|
child: Hero(
|
||||||
|
tag: 'hero-${item.id}',
|
||||||
|
child: Image.network(item.imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HeroDetailPage extends StatelessWidget {
|
||||||
|
final String itemId;
|
||||||
|
|
||||||
|
const HeroDetailPage({super.key, required this.itemId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final item = getItem(itemId);
|
||||||
|
return Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Hero(
|
||||||
|
tag: 'hero-${item.id}',
|
||||||
|
child: Image.network(item.imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(item.title, style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hero with Custom Flight
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Hero(
|
||||||
|
tag: 'avatar-$userId',
|
||||||
|
flightShuttleBuilder: (
|
||||||
|
flightContext,
|
||||||
|
animation,
|
||||||
|
flightDirection,
|
||||||
|
fromHeroContext,
|
||||||
|
toHeroContext,
|
||||||
|
) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: lerpDouble(24, 48, animation.value),
|
||||||
|
backgroundImage: NetworkImage(avatarUrl),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: CircleAvatar(radius: 24, backgroundImage: NetworkImage(avatarUrl)),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Page Transitions
|
||||||
|
|
||||||
|
### GoRouter Custom Transitions
|
||||||
|
|
||||||
|
```dart
|
||||||
|
GoRoute(
|
||||||
|
path: '/detail/:id',
|
||||||
|
pageBuilder: (context, state) {
|
||||||
|
return CustomTransitionPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: DetailPage(id: state.pathParameters['id']!),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.05),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Transition Patterns
|
||||||
|
|
||||||
|
```dart
|
||||||
|
extension PageTransitions on CustomTransitionPage {
|
||||||
|
static CustomTransitionPage<T> fade<T>({
|
||||||
|
required LocalKey key,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return CustomTransitionPage<T>(
|
||||||
|
key: key,
|
||||||
|
child: child,
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return FadeTransition(opacity: animation, child: child);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static CustomTransitionPage<T> slideUp<T>({
|
||||||
|
required LocalKey key,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return CustomTransitionPage<T>(
|
||||||
|
key: key,
|
||||||
|
child: child,
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
)),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static CustomTransitionPage<T> scale<T>({
|
||||||
|
required LocalKey key,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return CustomTransitionPage<T>(
|
||||||
|
key: key,
|
||||||
|
child: child,
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return ScaleTransition(
|
||||||
|
scale: Tween<double>(begin: 0.9, end: 1).animate(
|
||||||
|
CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||||
|
),
|
||||||
|
child: FadeTransition(opacity: animation, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Axis Transition
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:animations/animations.dart';
|
||||||
|
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings',
|
||||||
|
pageBuilder: (context, state) {
|
||||||
|
return CustomTransitionPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const SettingsPage(),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return SharedAxisTransition(
|
||||||
|
animation: animation,
|
||||||
|
secondaryAnimation: secondaryAnimation,
|
||||||
|
transitionType: SharedAxisTransitionType.horizontal,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Curves
|
||||||
|
|
||||||
|
| Curve | Usage |
|
||||||
|
|-------|-------|
|
||||||
|
| `Curves.easeInOut` | General purpose (default) |
|
||||||
|
| `Curves.easeOut` | Deceleration (entering) |
|
||||||
|
| `Curves.easeIn` | Acceleration (exiting) |
|
||||||
|
| `Curves.elasticOut` | Bouncy effect |
|
||||||
|
| `Curves.bounceOut` | Bounce at end |
|
||||||
|
| `Curves.fastOutSlowIn` | Material standard |
|
||||||
|
| `Curves.easeOutCubic` | Smooth deceleration |
|
||||||
|
|
||||||
|
## Animation Performance
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class PerformantAnimation extends StatelessWidget {
|
||||||
|
const PerformantAnimation({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(animation.value * 100, 0),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const ExpensiveWidget(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Tips
|
||||||
|
|
||||||
|
| Tip | Implementation |
|
||||||
|
|-----|----------------|
|
||||||
|
| Use `child` parameter | Pass static content to `child` in `AnimatedBuilder` |
|
||||||
|
| `RepaintBoundary` | Isolate animated widgets |
|
||||||
|
| Avoid `Opacity` widget | Use `FadeTransition` instead |
|
||||||
|
| Prefer transforms | `Transform` is cheaper than layout changes |
|
||||||
|
| Pre-compute values | Calculate in `initState`, not `build` |
|
||||||
|
|
||||||
|
## Animation Checklist
|
||||||
|
|
||||||
|
| Item | Implementation |
|
||||||
|
|------|----------------|
|
||||||
|
| Simple animations | Use implicit widgets |
|
||||||
|
| Complex sequences | Use `AnimationController` |
|
||||||
|
| Widget transitions | `AnimatedSwitcher` with key |
|
||||||
|
| Cross-page elements | `Hero` with unique tags |
|
||||||
|
| Page transitions | `CustomTransitionPage` |
|
||||||
|
| Performance | `RepaintBoundary` + `child` parameter |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter and Material Design are trademarks of Google LLC.*
|
||||||
281
skills/flutter-dev/references/bloc-state.md
Normal file
281
skills/flutter-dev/references/bloc-state.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Bloc State Management
|
||||||
|
|
||||||
|
Bloc state management guide covering events, states, Cubit, and widget integration for complex business logic.
|
||||||
|
|
||||||
|
## When to Use Bloc
|
||||||
|
|
||||||
|
Use **Bloc/Cubit** when you need:
|
||||||
|
- Explicit event → state transitions
|
||||||
|
- Complex business logic with multiple events
|
||||||
|
- Predictable, testable state flows
|
||||||
|
- Clear separation between UI and logic
|
||||||
|
|
||||||
|
| Use Case | Recommended |
|
||||||
|
|----------|-------------|
|
||||||
|
| Simple mutable state | Riverpod |
|
||||||
|
| Computed values | Riverpod |
|
||||||
|
| Event-driven workflows | Bloc |
|
||||||
|
| Forms, auth, wizards | Bloc |
|
||||||
|
| Feature modules with complex logic | Bloc |
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| Event | User or system input that triggers state change |
|
||||||
|
| State | Immutable representation of UI state |
|
||||||
|
| Bloc | Maps events to new states |
|
||||||
|
| Cubit | Simplified Bloc without events |
|
||||||
|
|
||||||
|
## Cubit (Recommended for Simpler Logic)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
class CounterCubit extends Cubit<int> {
|
||||||
|
CounterCubit() : super(0);
|
||||||
|
|
||||||
|
void increment() => emit(state + 1);
|
||||||
|
void decrement() => emit(state - 1);
|
||||||
|
void reset() => emit(0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Bloc Setup
|
||||||
|
|
||||||
|
### Event Definition
|
||||||
|
|
||||||
|
```dart
|
||||||
|
sealed class CounterEvent {}
|
||||||
|
|
||||||
|
final class CounterIncremented extends CounterEvent {}
|
||||||
|
final class CounterDecremented extends CounterEvent {}
|
||||||
|
final class CounterReset extends CounterEvent {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Definition
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterState {
|
||||||
|
final int value;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const CounterState({
|
||||||
|
required this.value,
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
CounterState copyWith({int? value, bool? isLoading}) {
|
||||||
|
return CounterState(
|
||||||
|
value: value ?? this.value,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bloc Implementation
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterBloc extends Bloc<CounterEvent, CounterState> {
|
||||||
|
CounterBloc() : super(const CounterState(value: 0)) {
|
||||||
|
on<CounterIncremented>(_onIncremented);
|
||||||
|
on<CounterDecremented>(_onDecremented);
|
||||||
|
on<CounterReset>(_onReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onIncremented(CounterIncremented event, Emitter<CounterState> emit) {
|
||||||
|
emit(state.copyWith(value: state.value + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDecremented(CounterDecremented event, Emitter<CounterState> emit) {
|
||||||
|
emit(state.copyWith(value: state.value - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onReset(CounterReset event, Emitter<CounterState> emit) {
|
||||||
|
emit(const CounterState(value: 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Providing Bloc to Widget Tree
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Single bloc
|
||||||
|
BlocProvider(
|
||||||
|
create: (_) => CounterBloc(),
|
||||||
|
child: const CounterScreen(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Multiple blocs
|
||||||
|
MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider(create: (_) => AuthBloc()),
|
||||||
|
BlocProvider(create: (_) => ProfileBloc()),
|
||||||
|
BlocProvider(create: (_) => SettingsBloc()),
|
||||||
|
],
|
||||||
|
child: const AppRoot(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Bloc in Widgets
|
||||||
|
|
||||||
|
### BlocBuilder (UI Rebuilds)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterScreen extends StatelessWidget {
|
||||||
|
const CounterScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<CounterBloc, CounterState>(
|
||||||
|
buildWhen: (prev, curr) => prev.value != curr.value,
|
||||||
|
builder: (context, state) {
|
||||||
|
return Text(
|
||||||
|
state.value.toString(),
|
||||||
|
style: Theme.of(context).textTheme.displayLarge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### BlocListener (Side Effects)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocListener<AuthBloc, AuthState>(
|
||||||
|
listenWhen: (prev, curr) => prev.status != curr.status,
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == AuthStatus.failure) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(state.errorMessage ?? 'Error')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status == AuthStatus.authenticated) {
|
||||||
|
context.go('/home');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const LoginForm(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### BlocConsumer (Builder + Listener)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocConsumer<FormBloc, FormState>(
|
||||||
|
listenWhen: (prev, curr) => prev.status != curr.status,
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == FormStatus.success) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buildWhen: (prev, curr) => prev.isValid != curr.isValid,
|
||||||
|
builder: (context, state) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: state.isValid
|
||||||
|
? () => context.read<FormBloc>().add(FormSubmitted())
|
||||||
|
: null,
|
||||||
|
child: const Text('Submit'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### BlocSelector (Granular Rebuilds)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocSelector<UserBloc, UserState, String>(
|
||||||
|
selector: (state) => state.user.name,
|
||||||
|
builder: (context, name) {
|
||||||
|
return Text('Hello, $name');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Async Bloc Pattern
|
||||||
|
|
||||||
|
```dart
|
||||||
|
on<UserRequested>((event, emit) async {
|
||||||
|
emit(state.copyWith(status: UserStatus.loading));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final user = await repository.fetchUser(event.userId);
|
||||||
|
emit(state.copyWith(status: UserStatus.success, user: user));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(status: UserStatus.failure, error: e.toString()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bloc + GoRouter Auth Guard
|
||||||
|
|
||||||
|
```dart
|
||||||
|
redirect: (context, state) {
|
||||||
|
final authState = context.read<AuthBloc>().state;
|
||||||
|
final isAuthRoute = state.matchedLocation.startsWith('/auth');
|
||||||
|
|
||||||
|
if (authState.status != AuthStatus.authenticated && !isAuthRoute) {
|
||||||
|
return '/auth/login';
|
||||||
|
}
|
||||||
|
if (authState.status == AuthStatus.authenticated && isAuthRoute) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Bloc
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:bloc_test/bloc_test.dart';
|
||||||
|
|
||||||
|
blocTest<CounterBloc, CounterState>(
|
||||||
|
'emits incremented value when CounterIncremented added',
|
||||||
|
build: () => CounterBloc(),
|
||||||
|
act: (bloc) => bloc.add(CounterIncremented()),
|
||||||
|
expect: () => [const CounterState(value: 1)],
|
||||||
|
);
|
||||||
|
|
||||||
|
blocTest<CounterBloc, CounterState>(
|
||||||
|
'emits multiple states',
|
||||||
|
build: () => CounterBloc(),
|
||||||
|
act: (bloc) {
|
||||||
|
bloc.add(CounterIncremented());
|
||||||
|
bloc.add(CounterIncremented());
|
||||||
|
bloc.add(CounterDecremented());
|
||||||
|
},
|
||||||
|
expect: () => [
|
||||||
|
const CounterState(value: 1),
|
||||||
|
const CounterState(value: 2),
|
||||||
|
const CounterState(value: 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Keep states immutable | Mutate state directly |
|
||||||
|
| Use small, focused blocs | Create "god blocs" with everything |
|
||||||
|
| One feature = one bloc | Share blocs across unrelated features |
|
||||||
|
| Use Cubit for simple cases | Overcomplicate with Bloc unnecessarily |
|
||||||
|
| Test all state transitions | Skip bloc testing |
|
||||||
|
| Use `buildWhen`/`listenWhen` | Rebuild on every state change |
|
||||||
|
|
||||||
|
## Widget Reference
|
||||||
|
|
||||||
|
| Widget | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `BlocBuilder` | UI rebuilds based on state |
|
||||||
|
| `BlocListener` | Side effects (navigation, snackbar) |
|
||||||
|
| `BlocConsumer` | Both builder and listener |
|
||||||
|
| `BlocSelector` | Granular state selection |
|
||||||
|
| `BlocProvider` | Dependency injection |
|
||||||
|
| `MultiBlocProvider` | Multiple bloc injection |
|
||||||
|
| `RepositoryProvider` | Repository injection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Bloc is an open-source state management library by Felix Angelov.*
|
||||||
656
skills/flutter-dev/references/forms.md
Normal file
656
skills/flutter-dev/references/forms.md
Normal file
@@ -0,0 +1,656 @@
|
|||||||
|
# Forms
|
||||||
|
|
||||||
|
Form validation, FormField patterns, input formatting, and reusable form components for Flutter.
|
||||||
|
|
||||||
|
## Basic Form Setup
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class LoginForm extends StatefulWidget {
|
||||||
|
const LoginForm({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginForm> createState() => _LoginFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginFormState extends State<LoginForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authService.login(
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
prefixIcon: Icon(Icons.email_outlined),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autocorrect: false,
|
||||||
|
validator: Validators.email,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Password',
|
||||||
|
prefixIcon: Icon(Icons.lock_outlined),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => _submit(),
|
||||||
|
validator: Validators.password,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Login'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validators
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class Validators {
|
||||||
|
static String? required(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'This field is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? email(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Email is required';
|
||||||
|
}
|
||||||
|
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||||
|
if (!regex.hasMatch(value.trim())) {
|
||||||
|
return 'Enter a valid email address';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? password(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Password is required';
|
||||||
|
}
|
||||||
|
if (value.length < 8) {
|
||||||
|
return 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? strongPassword(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Password is required';
|
||||||
|
}
|
||||||
|
if (value.length < 8) {
|
||||||
|
return 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
if (!RegExp(r'[A-Z]').hasMatch(value)) {
|
||||||
|
return 'Password must contain an uppercase letter';
|
||||||
|
}
|
||||||
|
if (!RegExp(r'[a-z]').hasMatch(value)) {
|
||||||
|
return 'Password must contain a lowercase letter';
|
||||||
|
}
|
||||||
|
if (!RegExp(r'[0-9]').hasMatch(value)) {
|
||||||
|
return 'Password must contain a number';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? phone(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Phone number is required';
|
||||||
|
}
|
||||||
|
final digits = value.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.length < 10 || digits.length > 15) {
|
||||||
|
return 'Enter a valid phone number';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? minLength(int min) {
|
||||||
|
return (String? value) {
|
||||||
|
if (value == null || value.length < min) {
|
||||||
|
return 'Must be at least $min characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? maxLength(int max) {
|
||||||
|
return (String? value) {
|
||||||
|
if (value != null && value.length > max) {
|
||||||
|
return 'Must be at most $max characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? Function(String?) combine(List<String? Function(String?)> validators) {
|
||||||
|
return (String? value) {
|
||||||
|
for (final validator in validators) {
|
||||||
|
final error = validator(value);
|
||||||
|
if (error != null) return error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? match(String pattern, String message) {
|
||||||
|
return (String? value) {
|
||||||
|
if (value != null && !RegExp(pattern).hasMatch(value)) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? confirmPassword(TextEditingController passwordController) {
|
||||||
|
return (String? value) {
|
||||||
|
if (value != passwordController.text) {
|
||||||
|
return 'Passwords do not match';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Input Formatters
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class PhoneInputFormatter extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
for (int i = 0; i < digits.length && i < 10; i++) {
|
||||||
|
if (i == 3 || i == 6) buffer.write('-');
|
||||||
|
buffer.write(digits[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: buffer.toString(),
|
||||||
|
selection: TextSelection.collapsed(offset: buffer.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreditCardFormatter extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
for (int i = 0; i < digits.length && i < 16; i++) {
|
||||||
|
if (i > 0 && i % 4 == 0) buffer.write(' ');
|
||||||
|
buffer.write(digits[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: buffer.toString(),
|
||||||
|
selection: TextSelection.collapsed(offset: buffer.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CurrencyInputFormatter extends TextInputFormatter {
|
||||||
|
final int decimalPlaces;
|
||||||
|
|
||||||
|
CurrencyInputFormatter({this.decimalPlaces = 2});
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
if (newValue.text.isEmpty) return newValue;
|
||||||
|
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'[^\d]'), '');
|
||||||
|
if (digits.isEmpty) return const TextEditingValue(text: '');
|
||||||
|
|
||||||
|
final value = int.parse(digits) / 100;
|
||||||
|
final formatted = value.toStringAsFixed(decimalPlaces);
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: formatted.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpperCaseFormatter extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
return newValue.copyWith(text: newValue.text.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Formatters
|
||||||
|
|
||||||
|
```dart
|
||||||
|
TextFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Phone'),
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
PhoneInputFormatter(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Amount'),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[\d.]')),
|
||||||
|
CurrencyInputFormatter(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom FormFields
|
||||||
|
|
||||||
|
### Dropdown FormField
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DropdownFormField<T> extends FormField<T> {
|
||||||
|
DropdownFormField({
|
||||||
|
super.key,
|
||||||
|
required List<DropdownMenuItem<T>> items,
|
||||||
|
super.initialValue,
|
||||||
|
super.validator,
|
||||||
|
super.onSaved,
|
||||||
|
String? labelText,
|
||||||
|
String? hintText,
|
||||||
|
ValueChanged<T?>? onChanged,
|
||||||
|
}) : super(
|
||||||
|
builder: (state) {
|
||||||
|
return InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: labelText,
|
||||||
|
errorText: state.errorText,
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<T>(
|
||||||
|
value: state.value,
|
||||||
|
hint: hintText != null ? Text(hintText) : null,
|
||||||
|
isExpanded: true,
|
||||||
|
items: items,
|
||||||
|
onChanged: (value) {
|
||||||
|
state.didChange(value);
|
||||||
|
onChanged?.call(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkbox FormField
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CheckboxFormField extends FormField<bool> {
|
||||||
|
CheckboxFormField({
|
||||||
|
super.key,
|
||||||
|
required Widget label,
|
||||||
|
super.initialValue = false,
|
||||||
|
super.validator,
|
||||||
|
super.onSaved,
|
||||||
|
}) : super(
|
||||||
|
builder: (state) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: state.value ?? false,
|
||||||
|
onChanged: state.didChange,
|
||||||
|
),
|
||||||
|
Expanded(child: GestureDetector(
|
||||||
|
onTap: () => state.didChange(!(state.value ?? false)),
|
||||||
|
child: label,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (state.hasError)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12, top: 4),
|
||||||
|
child: Text(
|
||||||
|
state.errorText!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(state.context).colorScheme.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Picker FormField
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DatePickerFormField extends FormField<DateTime> {
|
||||||
|
DatePickerFormField({
|
||||||
|
super.key,
|
||||||
|
super.initialValue,
|
||||||
|
super.validator,
|
||||||
|
super.onSaved,
|
||||||
|
String? labelText,
|
||||||
|
DateTime? firstDate,
|
||||||
|
DateTime? lastDate,
|
||||||
|
}) : super(
|
||||||
|
builder: (state) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: state.context,
|
||||||
|
initialDate: state.value ?? DateTime.now(),
|
||||||
|
firstDate: firstDate ?? DateTime(1900),
|
||||||
|
lastDate: lastDate ?? DateTime(2100),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
state.didChange(picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: labelText,
|
||||||
|
errorText: state.errorText,
|
||||||
|
suffixIcon: const Icon(Icons.calendar_today),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
state.value != null
|
||||||
|
? DateFormat.yMMMd().format(state.value!)
|
||||||
|
: 'Select date',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form with Hooks
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
class HookLoginForm extends HookWidget {
|
||||||
|
const HookLoginForm({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final formKey = useMemoized(GlobalKey<FormState>.new);
|
||||||
|
final emailController = useTextEditingController();
|
||||||
|
final passwordController = useTextEditingController();
|
||||||
|
final emailFocus = useFocusNode();
|
||||||
|
final passwordFocus = useFocusNode();
|
||||||
|
final isLoading = useState(false);
|
||||||
|
|
||||||
|
Future<void> submit() async {
|
||||||
|
if (!formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
await authService.login(
|
||||||
|
email: emailController.text.trim(),
|
||||||
|
password: passwordController.text,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: emailController,
|
||||||
|
focusNode: emailFocus,
|
||||||
|
decoration: const InputDecoration(labelText: 'Email'),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onFieldSubmitted: (_) => passwordFocus.requestFocus(),
|
||||||
|
validator: Validators.email,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: passwordController,
|
||||||
|
focusNode: passwordFocus,
|
||||||
|
decoration: const InputDecoration(labelText: 'Password'),
|
||||||
|
obscureText: true,
|
||||||
|
onFieldSubmitted: (_) => submit(),
|
||||||
|
validator: Validators.password,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoading.value ? null : submit,
|
||||||
|
child: isLoading.value
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: const Text('Login'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Side Validation
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ServerValidationForm extends StatefulWidget {
|
||||||
|
const ServerValidationForm({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ServerValidationForm> createState() => _ServerValidationFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerValidationFormState extends State<ServerValidationForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
Map<String, List<String>> _serverErrors = {};
|
||||||
|
|
||||||
|
String? _emailValidator(String? value) {
|
||||||
|
final clientError = Validators.email(value);
|
||||||
|
if (clientError != null) return clientError;
|
||||||
|
|
||||||
|
final serverError = _serverErrors['email'];
|
||||||
|
if (serverError != null && serverError.isNotEmpty) {
|
||||||
|
return serverError.first;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
setState(() => _serverErrors = {});
|
||||||
|
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.register(email: _emailController.text);
|
||||||
|
} on ValidationException catch (e) {
|
||||||
|
setState(() => _serverErrors = e.errors);
|
||||||
|
_formKey.currentState!.validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Email'),
|
||||||
|
validator: _emailValidator,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_serverErrors.containsKey('email')) {
|
||||||
|
setState(() => _serverErrors.remove('email'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _submit,
|
||||||
|
child: const Text('Register'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-Save Form
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AutoSaveForm extends StatefulWidget {
|
||||||
|
const AutoSaveForm({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AutoSaveForm> createState() => _AutoSaveFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AutoSaveFormState extends State<AutoSaveForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
Timer? _debounce;
|
||||||
|
bool _hasChanges = false;
|
||||||
|
|
||||||
|
void _onChanged() {
|
||||||
|
setState(() => _hasChanges = true);
|
||||||
|
|
||||||
|
_debounce?.cancel();
|
||||||
|
_debounce = Timer(const Duration(seconds: 2), _autoSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _autoSave() async {
|
||||||
|
if (!_hasChanges) return;
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
_formKey.currentState!.save();
|
||||||
|
await saveToServer();
|
||||||
|
setState(() => _hasChanges = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounce?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
onChanged: _onChanged,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (_hasChanges)
|
||||||
|
const Text('Saving...', style: TextStyle(color: Colors.grey)),
|
||||||
|
TextFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Title'),
|
||||||
|
onSaved: (value) => saveField('title', value),
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Description'),
|
||||||
|
maxLines: 3,
|
||||||
|
onSaved: (value) => saveField('description', value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Keyboard Types
|
||||||
|
|
||||||
|
| Type | Usage |
|
||||||
|
|------|-------|
|
||||||
|
| `TextInputType.text` | General text |
|
||||||
|
| `TextInputType.emailAddress` | Email with @ keyboard |
|
||||||
|
| `TextInputType.phone` | Phone number pad |
|
||||||
|
| `TextInputType.number` | Numeric keyboard |
|
||||||
|
| `TextInputType.numberWithOptions(decimal: true)` | Numbers with decimal |
|
||||||
|
| `TextInputType.multiline` | Multi-line text |
|
||||||
|
| `TextInputType.url` | URL with shortcuts |
|
||||||
|
|
||||||
|
## Form Checklist
|
||||||
|
|
||||||
|
| Item | Implementation |
|
||||||
|
|------|----------------|
|
||||||
|
| GlobalKey | `GlobalKey<FormState>()` for form |
|
||||||
|
| Dispose controllers | Clean up in `dispose()` |
|
||||||
|
| Validation | Client + server-side |
|
||||||
|
| Input formatters | Phone, currency, etc. |
|
||||||
|
| Keyboard types | Match input type |
|
||||||
|
| Text actions | `textInputAction` for flow |
|
||||||
|
| Loading state | Disable during submission |
|
||||||
|
| Error display | Show below fields |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter and Material Design are trademarks of Google LLC.*
|
||||||
257
skills/flutter-dev/references/gorouter-navigation.md
Normal file
257
skills/flutter-dev/references/gorouter-navigation.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# GoRouter Navigation
|
||||||
|
|
||||||
|
GoRouter navigation guide covering route setup, guards, deep linking, and shell routes.
|
||||||
|
|
||||||
|
## Basic Setup
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
final goRouter = GoRouter(
|
||||||
|
initialLocation: '/',
|
||||||
|
debugLogDiagnostics: true,
|
||||||
|
redirect: (context, state) {
|
||||||
|
final isLoggedIn = /* check auth state */;
|
||||||
|
final isAuthRoute = state.matchedLocation.startsWith('/auth');
|
||||||
|
|
||||||
|
if (!isLoggedIn && !isAuthRoute) {
|
||||||
|
return '/auth/login';
|
||||||
|
}
|
||||||
|
if (isLoggedIn && isAuthRoute) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
builder: (context, state) => const HomeScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'details/:id',
|
||||||
|
name: 'details',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
final extra = state.extra as Map<String, dynamic>?;
|
||||||
|
return DetailsScreen(id: id, title: extra?['title']);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/auth/login',
|
||||||
|
name: 'login',
|
||||||
|
builder: (context, state) => const LoginScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### App Integration
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp.router(
|
||||||
|
routerConfig: goRouter,
|
||||||
|
theme: AppTheme.light,
|
||||||
|
darkTheme: AppTheme.dark,
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Navigation Methods
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Navigate and replace entire stack
|
||||||
|
context.go('/details/123');
|
||||||
|
|
||||||
|
// Navigate and add to stack (can go back)
|
||||||
|
context.push('/details/123');
|
||||||
|
|
||||||
|
// Go back
|
||||||
|
context.pop();
|
||||||
|
|
||||||
|
// Go back with result
|
||||||
|
context.pop(result);
|
||||||
|
|
||||||
|
// Replace current route
|
||||||
|
context.pushReplacement('/home');
|
||||||
|
|
||||||
|
// Navigate with extra data
|
||||||
|
context.push('/details/123', extra: {'title': 'Item Title'});
|
||||||
|
|
||||||
|
// Navigate by name
|
||||||
|
context.goNamed('details', pathParameters: {'id': '123'});
|
||||||
|
context.pushNamed('details', pathParameters: {'id': '123'}, extra: data);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation Reference
|
||||||
|
|
||||||
|
| Method | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `context.go()` | Navigate, replace entire stack |
|
||||||
|
| `context.push()` | Navigate, add to stack |
|
||||||
|
| `context.pop()` | Go back one level |
|
||||||
|
| `context.pushReplacement()` | Replace current route |
|
||||||
|
| `context.goNamed()` | Navigate by route name |
|
||||||
|
| `context.canPop()` | Check if can go back |
|
||||||
|
|
||||||
|
## Shell Routes (Persistent UI)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final goRouter = GoRouter(
|
||||||
|
routes: [
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) {
|
||||||
|
return ScaffoldWithNavBar(child: child);
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/home',
|
||||||
|
builder: (_, __) => const HomeScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/search',
|
||||||
|
builder: (_, __) => const SearchScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/profile',
|
||||||
|
builder: (_, __) => const ProfileScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
class ScaffoldWithNavBar extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ScaffoldWithNavBar({super.key, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: child,
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: _calculateSelectedIndex(context),
|
||||||
|
onDestinationSelected: (index) => _onItemTapped(index, context),
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
|
||||||
|
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
|
||||||
|
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _calculateSelectedIndex(BuildContext context) {
|
||||||
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
|
if (location.startsWith('/home')) return 0;
|
||||||
|
if (location.startsWith('/search')) return 1;
|
||||||
|
if (location.startsWith('/profile')) return 2;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemTapped(int index, BuildContext context) {
|
||||||
|
switch (index) {
|
||||||
|
case 0: context.go('/home');
|
||||||
|
case 1: context.go('/search');
|
||||||
|
case 2: context.go('/profile');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
```dart
|
||||||
|
GoRoute(
|
||||||
|
path: '/search',
|
||||||
|
builder: (context, state) {
|
||||||
|
final query = state.uri.queryParameters['q'] ?? '';
|
||||||
|
final page = int.tryParse(state.uri.queryParameters['page'] ?? '1') ?? 1;
|
||||||
|
return SearchScreen(query: query, page: page);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Navigate with query params
|
||||||
|
context.go('/search?q=flutter&page=2');
|
||||||
|
context.goNamed('search', queryParameters: {'q': 'flutter', 'page': '2'});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Riverpod Integration
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
|
return GoRouter(
|
||||||
|
refreshListenable: authState,
|
||||||
|
redirect: (context, state) {
|
||||||
|
final isLoggedIn = authState.isAuthenticated;
|
||||||
|
final isAuthRoute = state.matchedLocation.startsWith('/auth');
|
||||||
|
|
||||||
|
if (!isLoggedIn && !isAuthRoute) return '/auth/login';
|
||||||
|
if (isLoggedIn && isAuthRoute) return '/';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [...],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// In app.dart
|
||||||
|
class MyApp extends ConsumerWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
return MaterialApp.router(routerConfig: router);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final goRouter = GoRouter(
|
||||||
|
errorBuilder: (context, state) {
|
||||||
|
return ErrorScreen(error: state.error);
|
||||||
|
},
|
||||||
|
routes: [...],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deep Linking
|
||||||
|
|
||||||
|
Deep links work automatically when routes are configured with path parameters:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// URL: myapp://details/123
|
||||||
|
// or: https://myapp.com/details/123
|
||||||
|
GoRoute(
|
||||||
|
path: '/details/:id',
|
||||||
|
builder: (context, state) => DetailsScreen(id: state.pathParameters['id']!),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Use named routes for maintainability | Hardcode paths everywhere |
|
||||||
|
| Use `push()` for detail screens | Use `go()` for all navigation |
|
||||||
|
| Pass simple data via `extra` | Pass complex objects via URL |
|
||||||
|
| Use redirect for auth guards | Check auth in every screen |
|
||||||
|
| Use ShellRoute for persistent UI | Rebuild nav bar in every screen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*GoRouter is an open-source navigation package for Flutter.*
|
||||||
510
skills/flutter-dev/references/localization.md
Normal file
510
skills/flutter-dev/references/localization.md
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
# Localization
|
||||||
|
|
||||||
|
Internationalization (i18n) patterns using flutter_localizations and intl package for Flutter applications.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# pubspec.yaml
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
|
intl: ^0.19.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
generate: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### l10n Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# l10n.yaml
|
||||||
|
arb-dir: lib/l10n
|
||||||
|
template-arb-file: app_en.arb
|
||||||
|
output-localization-file: app_localizations.dart
|
||||||
|
output-class: AppLocalizations
|
||||||
|
nullable-getter: false
|
||||||
|
```
|
||||||
|
|
||||||
|
## ARB Files
|
||||||
|
|
||||||
|
### English (Template)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// lib/l10n/app_en.arb
|
||||||
|
{
|
||||||
|
"@@locale": "en",
|
||||||
|
"appTitle": "My App",
|
||||||
|
"@appTitle": {
|
||||||
|
"description": "The application title"
|
||||||
|
},
|
||||||
|
"hello": "Hello",
|
||||||
|
"welcome": "Welcome, {name}!",
|
||||||
|
"@welcome": {
|
||||||
|
"description": "Welcome message with user name",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"example": "John"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
|
||||||
|
"@itemCount": {
|
||||||
|
"description": "Number of items",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lastUpdated": "Last updated: {date}",
|
||||||
|
"@lastUpdated": {
|
||||||
|
"description": "Last update timestamp",
|
||||||
|
"placeholders": {
|
||||||
|
"date": {
|
||||||
|
"type": "DateTime",
|
||||||
|
"format": "yMMMd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"price": "Price: {amount}",
|
||||||
|
"@price": {
|
||||||
|
"description": "Product price",
|
||||||
|
"placeholders": {
|
||||||
|
"amount": {
|
||||||
|
"type": "double",
|
||||||
|
"format": "currency",
|
||||||
|
"optionalParameters": {
|
||||||
|
"symbol": "$",
|
||||||
|
"decimalDigits": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gender": "{gender, select, male{He} female{She} other{They}} liked this",
|
||||||
|
"@gender": {
|
||||||
|
"description": "Gender-specific message",
|
||||||
|
"placeholders": {
|
||||||
|
"gender": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chinese
|
||||||
|
|
||||||
|
```json
|
||||||
|
// lib/l10n/app_zh.arb
|
||||||
|
{
|
||||||
|
"@@locale": "zh",
|
||||||
|
"appTitle": "我的应用",
|
||||||
|
"hello": "你好",
|
||||||
|
"welcome": "欢迎,{name}!",
|
||||||
|
"itemCount": "{count, plural, =0{没有项目} other{{count} 个项目}}",
|
||||||
|
"lastUpdated": "最后更新:{date}",
|
||||||
|
"price": "价格:{amount}",
|
||||||
|
"gender": "{gender, select, male{他} female{她} other{Ta}}喜欢了这个"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Japanese
|
||||||
|
|
||||||
|
```json
|
||||||
|
// lib/l10n/app_ja.arb
|
||||||
|
{
|
||||||
|
"@@locale": "ja",
|
||||||
|
"appTitle": "マイアプリ",
|
||||||
|
"hello": "こんにちは",
|
||||||
|
"welcome": "ようこそ、{name}さん!",
|
||||||
|
"itemCount": "{count, plural, =0{アイテムなし} other{{count}件}}",
|
||||||
|
"lastUpdated": "最終更新:{date}",
|
||||||
|
"price": "価格:{amount}",
|
||||||
|
"gender": "{gender, select, male{彼} female{彼女} other{その人}}がいいねしました"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Configuration
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'My App',
|
||||||
|
localizationsDelegates: const [
|
||||||
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [
|
||||||
|
Locale('en'),
|
||||||
|
Locale('zh'),
|
||||||
|
Locale('ja'),
|
||||||
|
],
|
||||||
|
locale: const Locale('en'),
|
||||||
|
home: const HomePage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Translations
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class HomePage extends StatelessWidget {
|
||||||
|
const HomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(l10n.appTitle)),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Text(l10n.hello),
|
||||||
|
Text(l10n.welcome('John')),
|
||||||
|
Text(l10n.itemCount(5)),
|
||||||
|
Text(l10n.lastUpdated(DateTime.now())),
|
||||||
|
Text(l10n.price(29.99)),
|
||||||
|
Text(l10n.gender('female')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extension for Convenience
|
||||||
|
|
||||||
|
```dart
|
||||||
|
extension LocalizationExtension on BuildContext {
|
||||||
|
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
Text(context.l10n.hello)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Locale Switching
|
||||||
|
|
||||||
|
### With Riverpod
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class LocaleNotifier extends _$LocaleNotifier {
|
||||||
|
@override
|
||||||
|
Locale build() {
|
||||||
|
final saved = ref.watch(sharedPreferencesProvider).getString('locale');
|
||||||
|
if (saved != null) {
|
||||||
|
return Locale(saved);
|
||||||
|
}
|
||||||
|
return const Locale('en');
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLocale(Locale locale) {
|
||||||
|
ref.read(sharedPreferencesProvider).setString('locale', locale.languageCode);
|
||||||
|
state = locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyApp extends ConsumerWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final locale = ref.watch(localeNotifierProvider);
|
||||||
|
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: locale,
|
||||||
|
home: const HomePage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Language Selector
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class LanguageSelector extends ConsumerWidget {
|
||||||
|
const LanguageSelector({super.key});
|
||||||
|
|
||||||
|
static const languages = [
|
||||||
|
(Locale('en'), 'English'),
|
||||||
|
(Locale('zh'), '中文'),
|
||||||
|
(Locale('ja'), '日本語'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final currentLocale = ref.watch(localeNotifierProvider);
|
||||||
|
|
||||||
|
return PopupMenuButton<Locale>(
|
||||||
|
initialValue: currentLocale,
|
||||||
|
onSelected: (locale) {
|
||||||
|
ref.read(localeNotifierProvider.notifier).setLocale(locale);
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => languages.map((lang) {
|
||||||
|
return PopupMenuItem(
|
||||||
|
value: lang.$1,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (currentLocale == lang.$1)
|
||||||
|
const Icon(Icons.check, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(lang.$2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
child: const Icon(Icons.language),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Date and Number Formatting
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class FormattingUtils {
|
||||||
|
static String formatDate(DateTime date, String locale) {
|
||||||
|
return DateFormat.yMMMd(locale).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatDateTime(DateTime dateTime, String locale) {
|
||||||
|
return DateFormat.yMMMd(locale).add_jm().format(dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatRelativeTime(DateTime dateTime, String locale) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(dateTime);
|
||||||
|
|
||||||
|
if (diff.inDays > 7) {
|
||||||
|
return DateFormat.yMMMd(locale).format(dateTime);
|
||||||
|
} else if (diff.inDays > 0) {
|
||||||
|
return '${diff.inDays}d ago';
|
||||||
|
} else if (diff.inHours > 0) {
|
||||||
|
return '${diff.inHours}h ago';
|
||||||
|
} else if (diff.inMinutes > 0) {
|
||||||
|
return '${diff.inMinutes}m ago';
|
||||||
|
} else {
|
||||||
|
return 'Just now';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatCurrency(double amount, String locale, {String? symbol}) {
|
||||||
|
return NumberFormat.currency(
|
||||||
|
locale: locale,
|
||||||
|
symbol: symbol,
|
||||||
|
decimalDigits: 2,
|
||||||
|
).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatNumber(num number, String locale) {
|
||||||
|
return NumberFormat.decimalPattern(locale).format(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatPercent(double value, String locale) {
|
||||||
|
return NumberFormat.percentPattern(locale).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatCompact(num number, String locale) {
|
||||||
|
return NumberFormat.compact(locale: locale).format(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage with Locale
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class FormattedContent extends StatelessWidget {
|
||||||
|
const FormattedContent({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(FormattingUtils.formatDate(DateTime.now(), locale)),
|
||||||
|
Text(FormattingUtils.formatCurrency(1234.56, locale, symbol: '\$')),
|
||||||
|
Text(FormattingUtils.formatNumber(1234567, locale)),
|
||||||
|
Text(FormattingUtils.formatPercent(0.75, locale)),
|
||||||
|
Text(FormattingUtils.formatCompact(1500000, locale)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## RTL Support
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class RtlAwareWidget extends StatelessWidget {
|
||||||
|
const RtlAwareWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isRtl = Directionality.of(context) == TextDirection.rtl;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(isRtl ? Icons.arrow_back : Icons.arrow_forward),
|
||||||
|
const Expanded(child: Text('Content')),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsetsDirectional.only(start: 16),
|
||||||
|
child: const Icon(Icons.settings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Directional Widgets
|
||||||
|
|
||||||
|
| Standard | Directional |
|
||||||
|
|----------|-------------|
|
||||||
|
| `EdgeInsets` | `EdgeInsetsDirectional` |
|
||||||
|
| `Padding` | `Padding` with `EdgeInsetsDirectional` |
|
||||||
|
| `Align` | `AlignmentDirectional` |
|
||||||
|
| `Positioned` | `PositionedDirectional` |
|
||||||
|
| `BorderRadius` | `BorderRadiusDirectional` |
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Use directional
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
|
||||||
|
child: child,
|
||||||
|
)
|
||||||
|
|
||||||
|
Container(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: child,
|
||||||
|
)
|
||||||
|
|
||||||
|
Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
borderRadius: BorderRadiusDirectional.only(
|
||||||
|
topStart: Radius.circular(8),
|
||||||
|
bottomStart: Radius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Organized Translations
|
||||||
|
|
||||||
|
### Split by Feature
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
l10n/
|
||||||
|
app_en.arb # Common translations
|
||||||
|
app_zh.arb
|
||||||
|
features/
|
||||||
|
auth_en.arb # Auth feature translations
|
||||||
|
auth_zh.arb
|
||||||
|
settings_en.arb # Settings feature translations
|
||||||
|
settings_zh.arb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Namespaced Keys
|
||||||
|
|
||||||
|
```json
|
||||||
|
// app_en.arb
|
||||||
|
{
|
||||||
|
"auth_login": "Login",
|
||||||
|
"auth_logout": "Logout",
|
||||||
|
"auth_forgotPassword": "Forgot Password?",
|
||||||
|
|
||||||
|
"settings_title": "Settings",
|
||||||
|
"settings_language": "Language",
|
||||||
|
"settings_theme": "Theme",
|
||||||
|
|
||||||
|
"error_network": "Network error. Please try again.",
|
||||||
|
"error_unknown": "An unknown error occurred."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void main() {
|
||||||
|
testWidgets('shows localized text', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('en'),
|
||||||
|
home: const HomePage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Hello'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('switches locale', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('zh'),
|
||||||
|
home: const HomePage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('你好'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ARB Placeholders Reference
|
||||||
|
|
||||||
|
| Type | Format Options |
|
||||||
|
|------|----------------|
|
||||||
|
| `String` | None |
|
||||||
|
| `int` | `compact`, `compactCurrency`, `compactLong`, `compactSimpleCurrency` |
|
||||||
|
| `double` | `compact`, `compactCurrency`, `currency`, `decimalPattern`, `decimalPercentPattern`, `percentPattern`, `scientificPattern`, `simpleCurrency` |
|
||||||
|
| `DateTime` | Any `DateFormat` pattern (yMd, yMMMd, jm, etc.) |
|
||||||
|
| `num` | Same as `int` and `double` |
|
||||||
|
|
||||||
|
## Localization Checklist
|
||||||
|
|
||||||
|
| Item | Implementation |
|
||||||
|
|------|----------------|
|
||||||
|
| Dependencies | `flutter_localizations`, `intl` |
|
||||||
|
| l10n.yaml | Configure ARB paths and output |
|
||||||
|
| ARB files | Create for each supported locale |
|
||||||
|
| App config | Add delegates and supported locales |
|
||||||
|
| Generate | Run `flutter gen-l10n` |
|
||||||
|
| Use translations | `AppLocalizations.of(context)` |
|
||||||
|
| Date/number formatting | Use `intl` formatters with locale |
|
||||||
|
| RTL support | Use directional widgets |
|
||||||
|
| Persist preference | Save user's locale choice |
|
||||||
|
| Testing | Test with different locales |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter is a trademark of Google LLC. intl is an open-source package by the Dart team.*
|
||||||
566
skills/flutter-dev/references/networking.md
Normal file
566
skills/flutter-dev/references/networking.md
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
# Networking
|
||||||
|
|
||||||
|
Dio configuration, interceptors, error handling, and caching strategies for Flutter network requests.
|
||||||
|
|
||||||
|
## Dio Setup
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
static final ApiClient _instance = ApiClient._internal();
|
||||||
|
factory ApiClient() => _instance;
|
||||||
|
|
||||||
|
late final Dio dio;
|
||||||
|
|
||||||
|
ApiClient._internal() {
|
||||||
|
dio = Dio(BaseOptions(
|
||||||
|
baseUrl: 'https://api.example.com/v1',
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 30),
|
||||||
|
sendTimeout: const Duration(seconds: 30),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
dio.interceptors.addAll([
|
||||||
|
AuthInterceptor(),
|
||||||
|
LoggingInterceptor(),
|
||||||
|
RetryInterceptor(dio: dio),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interceptors
|
||||||
|
|
||||||
|
### Auth Interceptor
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AuthInterceptor extends Interceptor {
|
||||||
|
final TokenStorage _tokenStorage;
|
||||||
|
|
||||||
|
AuthInterceptor({TokenStorage? tokenStorage})
|
||||||
|
: _tokenStorage = tokenStorage ?? TokenStorage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||||
|
final token = await _tokenStorage.getAccessToken();
|
||||||
|
if (token != null) {
|
||||||
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
|
if (err.response?.statusCode == 401) {
|
||||||
|
try {
|
||||||
|
final newToken = await _refreshToken();
|
||||||
|
if (newToken != null) {
|
||||||
|
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
|
||||||
|
final response = await Dio().fetch(err.requestOptions);
|
||||||
|
return handler.resolve(response);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await _tokenStorage.clearTokens();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _refreshToken() async {
|
||||||
|
final refreshToken = await _tokenStorage.getRefreshToken();
|
||||||
|
if (refreshToken == null) return null;
|
||||||
|
|
||||||
|
final response = await Dio().post(
|
||||||
|
'https://api.example.com/v1/auth/refresh',
|
||||||
|
data: {'refresh_token': refreshToken},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final newToken = response.data['access_token'];
|
||||||
|
await _tokenStorage.saveAccessToken(newToken);
|
||||||
|
return newToken;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging Interceptor
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class LoggingInterceptor extends Interceptor {
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
debugPrint('→ ${options.method} ${options.uri}');
|
||||||
|
if (options.data != null) {
|
||||||
|
debugPrint(' Body: ${options.data}');
|
||||||
|
}
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||||
|
debugPrint('← ${response.statusCode} ${response.requestOptions.uri}');
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
|
debugPrint('✗ ${err.response?.statusCode} ${err.requestOptions.uri}');
|
||||||
|
debugPrint(' Error: ${err.message}');
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retry Interceptor
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class RetryInterceptor extends Interceptor {
|
||||||
|
final Dio dio;
|
||||||
|
final int maxRetries;
|
||||||
|
final Duration retryDelay;
|
||||||
|
|
||||||
|
RetryInterceptor({
|
||||||
|
required this.dio,
|
||||||
|
this.maxRetries = 3,
|
||||||
|
this.retryDelay = const Duration(seconds: 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
|
final retryCount = err.requestOptions.extra['retryCount'] ?? 0;
|
||||||
|
|
||||||
|
if (_shouldRetry(err) && retryCount < maxRetries) {
|
||||||
|
await Future.delayed(retryDelay * (retryCount + 1));
|
||||||
|
|
||||||
|
err.requestOptions.extra['retryCount'] = retryCount + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await dio.fetch(err.requestOptions);
|
||||||
|
return handler.resolve(response);
|
||||||
|
} catch (e) {
|
||||||
|
return handler.next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldRetry(DioException err) {
|
||||||
|
return err.type == DioExceptionType.connectionTimeout ||
|
||||||
|
err.type == DioExceptionType.sendTimeout ||
|
||||||
|
err.type == DioExceptionType.receiveTimeout ||
|
||||||
|
(err.response?.statusCode ?? 0) >= 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Custom Exception
|
||||||
|
|
||||||
|
```dart
|
||||||
|
sealed class ApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int? statusCode;
|
||||||
|
final dynamic data;
|
||||||
|
|
||||||
|
const ApiException({
|
||||||
|
required this.message,
|
||||||
|
this.statusCode,
|
||||||
|
this.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class NetworkException extends ApiException {
|
||||||
|
const NetworkException({super.message = 'Network connection failed'});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServerException extends ApiException {
|
||||||
|
const ServerException({
|
||||||
|
required super.message,
|
||||||
|
super.statusCode,
|
||||||
|
super.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnauthorizedException extends ApiException {
|
||||||
|
const UnauthorizedException({super.message = 'Authentication required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ValidationException extends ApiException {
|
||||||
|
final Map<String, List<String>> errors;
|
||||||
|
|
||||||
|
const ValidationException({
|
||||||
|
required this.errors,
|
||||||
|
super.message = 'Validation failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handler
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ApiErrorHandler {
|
||||||
|
static ApiException handle(DioException error) {
|
||||||
|
switch (error.type) {
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
return const NetworkException(message: 'Connection timeout');
|
||||||
|
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
return const NetworkException(message: 'No internet connection');
|
||||||
|
|
||||||
|
case DioExceptionType.badResponse:
|
||||||
|
return _handleResponse(error.response);
|
||||||
|
|
||||||
|
case DioExceptionType.cancel:
|
||||||
|
return const ApiException(message: 'Request cancelled');
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ApiException(message: error.message ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ApiException _handleResponse(Response? response) {
|
||||||
|
final statusCode = response?.statusCode ?? 0;
|
||||||
|
final data = response?.data;
|
||||||
|
|
||||||
|
switch (statusCode) {
|
||||||
|
case 400:
|
||||||
|
if (data is Map && data.containsKey('errors')) {
|
||||||
|
return ValidationException(
|
||||||
|
errors: Map<String, List<String>>.from(
|
||||||
|
(data['errors'] as Map).map(
|
||||||
|
(k, v) => MapEntry(k.toString(), List<String>.from(v)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ServerException(
|
||||||
|
message: data?['message'] ?? 'Bad request',
|
||||||
|
statusCode: statusCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
case 401:
|
||||||
|
return const UnauthorizedException();
|
||||||
|
|
||||||
|
case 403:
|
||||||
|
return const ServerException(
|
||||||
|
message: 'Access denied',
|
||||||
|
statusCode: 403,
|
||||||
|
);
|
||||||
|
|
||||||
|
case 404:
|
||||||
|
return const ServerException(
|
||||||
|
message: 'Resource not found',
|
||||||
|
statusCode: 404,
|
||||||
|
);
|
||||||
|
|
||||||
|
case 422:
|
||||||
|
return ValidationException(
|
||||||
|
errors: _parseValidationErrors(data),
|
||||||
|
);
|
||||||
|
|
||||||
|
case >= 500:
|
||||||
|
return ServerException(
|
||||||
|
message: 'Server error',
|
||||||
|
statusCode: statusCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ServerException(
|
||||||
|
message: data?['message'] ?? 'Unknown error',
|
||||||
|
statusCode: statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, List<String>> _parseValidationErrors(dynamic data) {
|
||||||
|
if (data is! Map) return {};
|
||||||
|
final errors = data['errors'];
|
||||||
|
if (errors is! Map) return {};
|
||||||
|
return errors.map((k, v) => MapEntry(
|
||||||
|
k.toString(),
|
||||||
|
v is List ? v.map((e) => e.toString()).toList() : [v.toString()],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Pattern
|
||||||
|
|
||||||
|
```dart
|
||||||
|
abstract class BaseRepository {
|
||||||
|
final Dio dio;
|
||||||
|
|
||||||
|
BaseRepository(this.dio);
|
||||||
|
|
||||||
|
Future<T> safeCall<T>(Future<T> Function() call) async {
|
||||||
|
try {
|
||||||
|
return await call();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw ApiErrorHandler.handle(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserRepository extends BaseRepository {
|
||||||
|
UserRepository(super.dio);
|
||||||
|
|
||||||
|
Future<User> getUser(String id) => safeCall(() async {
|
||||||
|
final response = await dio.get('/users/$id');
|
||||||
|
return User.fromJson(response.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<User>> getUsers({int page = 1, int limit = 20}) => safeCall(() async {
|
||||||
|
final response = await dio.get('/users', queryParameters: {
|
||||||
|
'page': page,
|
||||||
|
'limit': limit,
|
||||||
|
});
|
||||||
|
return (response.data['data'] as List)
|
||||||
|
.map((e) => User.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<User> updateUser(String id, Map<String, dynamic> data) => safeCall(() async {
|
||||||
|
final response = await dio.patch('/users/$id', data: data);
|
||||||
|
return User.fromJson(response.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
### Memory Cache
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CacheInterceptor extends Interceptor {
|
||||||
|
final Map<String, CacheEntry> _cache = {};
|
||||||
|
final Duration maxAge;
|
||||||
|
|
||||||
|
CacheInterceptor({this.maxAge = const Duration(minutes: 5)});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
if (options.method != 'GET') {
|
||||||
|
handler.next(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final key = _cacheKey(options);
|
||||||
|
final cached = _cache[key];
|
||||||
|
|
||||||
|
if (cached != null && !cached.isExpired) {
|
||||||
|
return handler.resolve(Response(
|
||||||
|
requestOptions: options,
|
||||||
|
data: cached.data,
|
||||||
|
statusCode: 200,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||||
|
if (response.requestOptions.method == 'GET') {
|
||||||
|
final key = _cacheKey(response.requestOptions);
|
||||||
|
_cache[key] = CacheEntry(
|
||||||
|
data: response.data,
|
||||||
|
expiry: DateTime.now().add(maxAge),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cacheKey(RequestOptions options) {
|
||||||
|
return '${options.uri}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void invalidate(String pattern) {
|
||||||
|
_cache.removeWhere((key, _) => key.contains(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() => _cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
class CacheEntry {
|
||||||
|
final dynamic data;
|
||||||
|
final DateTime expiry;
|
||||||
|
|
||||||
|
CacheEntry({required this.data, required this.expiry});
|
||||||
|
|
||||||
|
bool get isExpired => DateTime.now().isAfter(expiry);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disk Cache with Hive
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
|
||||||
|
class DiskCacheInterceptor extends Interceptor {
|
||||||
|
static const String _boxName = 'api_cache';
|
||||||
|
final Duration maxAge;
|
||||||
|
|
||||||
|
DiskCacheInterceptor({this.maxAge = const Duration(hours: 1)});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||||
|
if (options.method != 'GET') {
|
||||||
|
handler.next(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final box = await Hive.openBox(_boxName);
|
||||||
|
final key = _cacheKey(options);
|
||||||
|
final cached = box.get(key);
|
||||||
|
|
||||||
|
if (cached != null) {
|
||||||
|
final entry = CachedResponse.fromJson(cached);
|
||||||
|
if (!entry.isExpired) {
|
||||||
|
return handler.resolve(Response(
|
||||||
|
requestOptions: options,
|
||||||
|
data: entry.data,
|
||||||
|
statusCode: 200,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) async {
|
||||||
|
if (response.requestOptions.method == 'GET') {
|
||||||
|
final box = await Hive.openBox(_boxName);
|
||||||
|
final key = _cacheKey(response.requestOptions);
|
||||||
|
await box.put(key, CachedResponse(
|
||||||
|
data: response.data,
|
||||||
|
expiry: DateTime.now().add(maxAge),
|
||||||
|
).toJson());
|
||||||
|
}
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cacheKey(RequestOptions options) => options.uri.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachedResponse {
|
||||||
|
final dynamic data;
|
||||||
|
final DateTime expiry;
|
||||||
|
|
||||||
|
CachedResponse({required this.data, required this.expiry});
|
||||||
|
|
||||||
|
bool get isExpired => DateTime.now().isAfter(expiry);
|
||||||
|
|
||||||
|
factory CachedResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CachedResponse(
|
||||||
|
data: json['data'],
|
||||||
|
expiry: DateTime.parse(json['expiry']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'data': data,
|
||||||
|
'expiry': expiry.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Riverpod Integration
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
Dio dio(Ref ref) {
|
||||||
|
return ApiClient().dio;
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
UserRepository userRepository(Ref ref) {
|
||||||
|
return UserRepository(ref.watch(dioProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<User> user(Ref ref, String id) async {
|
||||||
|
final repository = ref.watch(userRepositoryProvider);
|
||||||
|
return repository.getUser(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class Users extends _$Users {
|
||||||
|
@override
|
||||||
|
Future<List<User>> build() => _fetch();
|
||||||
|
|
||||||
|
Future<List<User>> _fetch() async {
|
||||||
|
final repository = ref.watch(userRepositoryProvider);
|
||||||
|
return repository.getUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(_fetch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Cancellation
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class SearchRepository extends BaseRepository {
|
||||||
|
CancelToken? _searchToken;
|
||||||
|
|
||||||
|
SearchRepository(super.dio);
|
||||||
|
|
||||||
|
Future<List<SearchResult>> search(String query) async {
|
||||||
|
_searchToken?.cancel();
|
||||||
|
_searchToken = CancelToken();
|
||||||
|
|
||||||
|
return safeCall(() async {
|
||||||
|
final response = await dio.get(
|
||||||
|
'/search',
|
||||||
|
queryParameters: {'q': query},
|
||||||
|
cancelToken: _searchToken,
|
||||||
|
);
|
||||||
|
return (response.data as List)
|
||||||
|
.map((e) => SearchResult.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
| Pattern | Usage |
|
||||||
|
|---------|-------|
|
||||||
|
| Singleton client | Single Dio instance across app |
|
||||||
|
| Interceptor chain | Auth → Retry → Cache → Logging |
|
||||||
|
| Repository layer | Abstract API from business logic |
|
||||||
|
| Error mapping | Convert DioException to app exceptions |
|
||||||
|
| Cancel tokens | Debounce/cancel previous requests |
|
||||||
|
| Cache invalidation | Clear cache on mutations |
|
||||||
|
|
||||||
|
## Networking Checklist
|
||||||
|
|
||||||
|
| Item | Implementation |
|
||||||
|
|------|----------------|
|
||||||
|
| Base configuration | Timeouts, headers, base URL |
|
||||||
|
| Auth handling | Token injection, refresh on 401 |
|
||||||
|
| Error handling | Typed exceptions, user messages |
|
||||||
|
| Retry logic | Exponential backoff for transient errors |
|
||||||
|
| Request logging | Debug interceptor |
|
||||||
|
| Caching | Memory/disk cache for GET requests |
|
||||||
|
| Cancellation | Cancel tokens for search/debounce |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dio is an open-source package by the Flutter China community.*
|
||||||
306
skills/flutter-dev/references/performance.md
Normal file
306
skills/flutter-dev/references/performance.md
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
# Performance Optimization
|
||||||
|
|
||||||
|
Flutter performance guide covering profiling, const optimization, and DevTools analysis.
|
||||||
|
|
||||||
|
## Profiling Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run in profile mode (required for accurate measurements)
|
||||||
|
flutter run --profile
|
||||||
|
|
||||||
|
# Analyze code issues
|
||||||
|
flutter analyze
|
||||||
|
|
||||||
|
# Launch DevTools
|
||||||
|
flutter pub global activate devtools
|
||||||
|
flutter pub global run devtools
|
||||||
|
|
||||||
|
# Build release for testing
|
||||||
|
flutter build apk --release
|
||||||
|
flutter build ios --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Const Widget Optimization
|
||||||
|
|
||||||
|
The most important optimization for preventing unnecessary rebuilds:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD - Creates new objects every build
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(16), // New object each time
|
||||||
|
child: Text('Hello'), // New widget each time
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD - Const prevents rebuilds
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: const Text('Hello'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extracting Const Widgets
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD - Inline static content
|
||||||
|
class MyScreen extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.star, size: 48),
|
||||||
|
Text('Welcome'),
|
||||||
|
Text('Description text here'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD - Extract to const classes
|
||||||
|
class MyScreen extends StatelessWidget {
|
||||||
|
const MyScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Column(
|
||||||
|
children: [
|
||||||
|
_Header(),
|
||||||
|
_Description(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Header extends StatelessWidget {
|
||||||
|
const _Header();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.star, size: 48),
|
||||||
|
Text('Welcome'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Selective Provider Watching
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD - Rebuilds on any user change
|
||||||
|
class UserAvatar extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final user = ref.watch(userProvider);
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundImage: NetworkImage(user.avatarUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD - Only rebuilds when avatarUrl changes
|
||||||
|
class UserAvatar extends ConsumerWidget {
|
||||||
|
const UserAvatar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final avatarUrl = ref.watch(userProvider.select((u) => u.avatarUrl));
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundImage: NetworkImage(avatarUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## RepaintBoundary
|
||||||
|
|
||||||
|
Isolate expensive widgets to prevent unnecessary repaints:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Isolate complex animated widgets
|
||||||
|
RepaintBoundary(
|
||||||
|
child: ComplexAnimatedWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Isolate frequently updating widgets
|
||||||
|
RepaintBoundary(
|
||||||
|
child: StreamBuilder<int>(
|
||||||
|
stream: counterStream,
|
||||||
|
builder: (context, snapshot) => Text('${snapshot.data}'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Optimization
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD - Builds all items upfront
|
||||||
|
ListView(
|
||||||
|
children: items.map((item) => ItemWidget(item: item)).toList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// GOOD - Lazy loading with builder
|
||||||
|
ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return ItemWidget(
|
||||||
|
key: ValueKey(items[index].id),
|
||||||
|
item: items[index],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// For heterogeneous content
|
||||||
|
ListView.separated(
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(),
|
||||||
|
itemBuilder: (context, index) => ItemWidget(item: items[index]),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Image Optimization
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Use cached_network_image for network images
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: url,
|
||||||
|
placeholder: (_, __) => const ShimmerPlaceholder(),
|
||||||
|
errorWidget: (_, __, ___) => const Icon(Icons.error),
|
||||||
|
memCacheWidth: 200,
|
||||||
|
memCacheHeight: 200,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resize images in memory
|
||||||
|
Image.network(
|
||||||
|
url,
|
||||||
|
cacheWidth: 200, // Decode at smaller size
|
||||||
|
cacheHeight: 200, // Saves memory
|
||||||
|
)
|
||||||
|
|
||||||
|
// Precache images
|
||||||
|
precacheImage(NetworkImage(url), context);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Heavy Computation
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD - Blocks UI thread
|
||||||
|
void processData() {
|
||||||
|
final result = heavyComputation(data); // UI freezes
|
||||||
|
updateUI(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD - Run in isolate
|
||||||
|
Future<void> processData() async {
|
||||||
|
final result = await compute(heavyComputation, data);
|
||||||
|
updateUI(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For multiple operations
|
||||||
|
Future<void> processMultiple() async {
|
||||||
|
final results = await Future.wait([
|
||||||
|
compute(process1, data1),
|
||||||
|
compute(process2, data2),
|
||||||
|
compute(process3, data3),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Animation Performance
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Use AnimatedBuilder for custom animations
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.rotate(
|
||||||
|
angle: controller.value * 2 * pi,
|
||||||
|
child: child, // Child not rebuilt
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const ExpensiveWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefer implicit animations for simple cases
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
width: expanded ? 200 : 100,
|
||||||
|
child: const Content(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## DevTools Analysis
|
||||||
|
|
||||||
|
### Key Metrics
|
||||||
|
|
||||||
|
| Metric | Target | Action if Exceeded |
|
||||||
|
|--------|--------|-------------------|
|
||||||
|
| Frame time | < 16ms (60fps) | Profile build/paint |
|
||||||
|
| Build time | < 8ms | Add const, extract widgets |
|
||||||
|
| Paint time | < 8ms | Add RepaintBoundary |
|
||||||
|
| Memory | Stable | Check for leaks |
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Issue | Symptom | Solution |
|
||||||
|
|-------|---------|----------|
|
||||||
|
| Expensive builds | High build time | Extract const widgets |
|
||||||
|
| Excessive repaints | High paint time | Add RepaintBoundary |
|
||||||
|
| Memory leaks | Growing memory | Dispose controllers |
|
||||||
|
| Jank | Dropped frames | Use compute() |
|
||||||
|
|
||||||
|
## Performance Checklist
|
||||||
|
|
||||||
|
| Check | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Unnecessary rebuilds | Add `const`, use `select()` |
|
||||||
|
| Large lists | Use `ListView.builder` |
|
||||||
|
| Image loading | Use `cached_network_image` |
|
||||||
|
| Heavy computation | Use `compute()` |
|
||||||
|
| Jank in animations | Use `RepaintBoundary` |
|
||||||
|
| Memory leaks | Dispose controllers, cancel subscriptions |
|
||||||
|
| Network calls | Cache responses, debounce requests |
|
||||||
|
| Startup time | Defer initialization, lazy loading |
|
||||||
|
|
||||||
|
## Dispose Pattern
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MyWidget extends StatefulWidget {
|
||||||
|
const MyWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyWidget> createState() => _MyWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyWidgetState extends State<MyWidget> {
|
||||||
|
late final TextEditingController _controller;
|
||||||
|
late final StreamSubscription _subscription;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController();
|
||||||
|
_subscription = stream.listen(handleData);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_subscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter and DevTools are trademarks of Google LLC.*
|
||||||
|
|
||||||
417
skills/flutter-dev/references/platform-specific.md
Normal file
417
skills/flutter-dev/references/platform-specific.md
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
# Platform Integration
|
||||||
|
|
||||||
|
Flutter platform-specific implementations for iOS, Android, Web, and Desktop.
|
||||||
|
|
||||||
|
## Platform Detection
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
|
||||||
|
bool get isIOS => !kIsWeb && Platform.isIOS;
|
||||||
|
bool get isAndroid => !kIsWeb && Platform.isAndroid;
|
||||||
|
bool get isWeb => kIsWeb;
|
||||||
|
bool get isDesktop => !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux);
|
||||||
|
bool get isMobile => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adaptive Widgets
|
||||||
|
|
||||||
|
### Platform-Aware Components
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AdaptiveButton extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
const AdaptiveButton({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return CupertinoButton.filled(
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Text(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Text(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adaptive Dialog
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<bool?> showAdaptiveConfirmDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String content,
|
||||||
|
}) async {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return showCupertinoDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => CupertinoAlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(content),
|
||||||
|
actions: [
|
||||||
|
CupertinoDialogAction(
|
||||||
|
isDestructiveAction: true,
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
CupertinoDialogAction(
|
||||||
|
isDefaultAction: true,
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(content),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adaptive Scaffold
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AdaptiveScaffold extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget body;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
|
||||||
|
const AdaptiveScaffold({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
this.actions,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return CupertinoPageScaffold(
|
||||||
|
navigationBar: CupertinoNavigationBar(
|
||||||
|
middle: Text(title),
|
||||||
|
trailing: actions != null
|
||||||
|
? Row(mainAxisSize: MainAxisSize.min, children: actions!)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: SafeArea(child: body),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(title), actions: actions),
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Channels
|
||||||
|
|
||||||
|
### Method Channel (Dart Side)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class NativeBridge {
|
||||||
|
static const _channel = MethodChannel('com.example.app/native');
|
||||||
|
|
||||||
|
static Future<String> getPlatformVersion() async {
|
||||||
|
final version = await _channel.invokeMethod<String>('getPlatformVersion');
|
||||||
|
return version ?? 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> triggerHaptic() async {
|
||||||
|
await _channel.invokeMethod('triggerHaptic');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> getDeviceInfo() async {
|
||||||
|
final result = await _channel.invokeMethod<Map>('getDeviceInfo');
|
||||||
|
return Map<String, dynamic>.from(result ?? {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### iOS Implementation (Swift)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ios/Runner/AppDelegate.swift
|
||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
|
) -> Bool {
|
||||||
|
let controller = window?.rootViewController as! FlutterViewController
|
||||||
|
let channel = FlutterMethodChannel(
|
||||||
|
name: "com.example.app/native",
|
||||||
|
binaryMessenger: controller.binaryMessenger
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.setMethodCallHandler { (call, result) in
|
||||||
|
switch call.method {
|
||||||
|
case "getPlatformVersion":
|
||||||
|
result("iOS " + UIDevice.current.systemVersion)
|
||||||
|
case "triggerHaptic":
|
||||||
|
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||||
|
generator.impactOccurred()
|
||||||
|
result(nil)
|
||||||
|
case "getDeviceInfo":
|
||||||
|
result([
|
||||||
|
"model": UIDevice.current.model,
|
||||||
|
"name": UIDevice.current.name,
|
||||||
|
"systemVersion": UIDevice.current.systemVersion
|
||||||
|
])
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Android Implementation (Kotlin)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// android/app/src/main/kotlin/.../MainActivity.kt
|
||||||
|
package com.example.app
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.content.Context
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity() {
|
||||||
|
private val CHANNEL = "com.example.app/native"
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"getPlatformVersion" -> {
|
||||||
|
result.success("Android ${Build.VERSION.RELEASE}")
|
||||||
|
}
|
||||||
|
"triggerHaptic" -> {
|
||||||
|
val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
vibrator.vibrate(
|
||||||
|
VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
vibrator.vibrate(50)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getDeviceInfo" -> {
|
||||||
|
result.success(mapOf(
|
||||||
|
"model" to Build.MODEL,
|
||||||
|
"manufacturer" to Build.MANUFACTURER,
|
||||||
|
"version" to Build.VERSION.RELEASE
|
||||||
|
))
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## iOS-Specific Configuration
|
||||||
|
|
||||||
|
### Info.plist Permissions
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- ios/Runner/Info.plist -->
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>This app needs camera access to take photos</string>
|
||||||
|
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>This app needs photo library access to save images</string>
|
||||||
|
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>This app needs location access to show nearby places</string>
|
||||||
|
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>This app needs microphone access for voice recording</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
### iOS App Icons and Launch Screen
|
||||||
|
|
||||||
|
```
|
||||||
|
ios/Runner/Assets.xcassets/
|
||||||
|
├── AppIcon.appiconset/
|
||||||
|
│ ├── Contents.json
|
||||||
|
│ └── Icon-App-*.png
|
||||||
|
└── LaunchImage.imageset/
|
||||||
|
├── Contents.json
|
||||||
|
└── LaunchImage*.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android-Specific Configuration
|
||||||
|
|
||||||
|
### AndroidManifest.xml Permissions
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- android/app/src/main/AndroidManifest.xml -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="My App"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<!-- ... -->
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Gradle Configuration
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
// android/app/build.gradle
|
||||||
|
android {
|
||||||
|
compileSdkVersion 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion 21
|
||||||
|
targetSdkVersion 34
|
||||||
|
multiDexEnabled true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
minifyEnabled true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web-Specific
|
||||||
|
|
||||||
|
### Conditional Imports
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/services/storage_service.dart
|
||||||
|
export 'storage_service_stub.dart'
|
||||||
|
if (dart.library.io) 'storage_service_native.dart'
|
||||||
|
if (dart.library.html) 'storage_service_web.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/services/storage_service_web.dart
|
||||||
|
import 'dart:html' as html;
|
||||||
|
|
||||||
|
class StorageService {
|
||||||
|
void save(String key, String value) {
|
||||||
|
html.window.localStorage[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? load(String key) {
|
||||||
|
return html.window.localStorage[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Index Configuration
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- web/index.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>My App</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform-Specific Styling
|
||||||
|
|
||||||
|
```dart
|
||||||
|
ThemeData get theme {
|
||||||
|
final baseTheme = ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||||
|
useMaterial3: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return baseTheme.copyWith(
|
||||||
|
// iOS-style page transitions
|
||||||
|
pageTransitionsTheme: const PageTransitionsTheme(
|
||||||
|
builders: {
|
||||||
|
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseTheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Reference
|
||||||
|
|
||||||
|
| Feature | iOS | Android | Web |
|
||||||
|
|---------|-----|---------|-----|
|
||||||
|
| Navigation | Cupertino style | Material style | URL-based |
|
||||||
|
| Haptics | UIFeedbackGenerator | Vibrator | Not available |
|
||||||
|
| Storage | NSUserDefaults | SharedPreferences | localStorage |
|
||||||
|
| Deep links | Universal Links | App Links | URL routing |
|
||||||
|
| Notifications | APNs | FCM | Web Push |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter, iOS, Android, and their respective logos are trademarks of Google LLC and Apple Inc.*
|
||||||
274
skills/flutter-dev/references/project-structure.md
Normal file
274
skills/flutter-dev/references/project-structure.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# Project Structure
|
||||||
|
|
||||||
|
Flutter project architecture guide covering feature-based structure, dependencies, and entry point setup.
|
||||||
|
|
||||||
|
## Feature-Based Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── main.dart # Entry point
|
||||||
|
├── app.dart # App widget, MaterialApp.router
|
||||||
|
├── core/
|
||||||
|
│ ├── constants/
|
||||||
|
│ │ ├── app_colors.dart
|
||||||
|
│ │ ├── app_strings.dart
|
||||||
|
│ │ └── app_sizes.dart
|
||||||
|
│ ├── theme/
|
||||||
|
│ │ ├── app_theme.dart
|
||||||
|
│ │ └── text_styles.dart
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── extensions.dart
|
||||||
|
│ │ └── validators.dart
|
||||||
|
│ └── errors/
|
||||||
|
│ └── failures.dart
|
||||||
|
├── features/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── data/
|
||||||
|
│ │ │ ├── repositories/
|
||||||
|
│ │ │ │ └── auth_repository_impl.dart
|
||||||
|
│ │ │ └── datasources/
|
||||||
|
│ │ │ ├── auth_remote_datasource.dart
|
||||||
|
│ │ │ └── auth_local_datasource.dart
|
||||||
|
│ │ ├── domain/
|
||||||
|
│ │ │ ├── entities/
|
||||||
|
│ │ │ │ └── user.dart
|
||||||
|
│ │ │ ├── repositories/
|
||||||
|
│ │ │ │ └── auth_repository.dart
|
||||||
|
│ │ │ └── usecases/
|
||||||
|
│ │ │ ├── login.dart
|
||||||
|
│ │ │ └── logout.dart
|
||||||
|
│ │ ├── presentation/
|
||||||
|
│ │ │ ├── screens/
|
||||||
|
│ │ │ │ ├── login_screen.dart
|
||||||
|
│ │ │ │ └── register_screen.dart
|
||||||
|
│ │ │ └── widgets/
|
||||||
|
│ │ │ └── auth_form.dart
|
||||||
|
│ │ └── providers/
|
||||||
|
│ │ └── auth_provider.dart
|
||||||
|
│ └── home/
|
||||||
|
│ ├── data/
|
||||||
|
│ ├── domain/
|
||||||
|
│ ├── presentation/
|
||||||
|
│ └── providers/
|
||||||
|
├── shared/
|
||||||
|
│ ├── widgets/
|
||||||
|
│ │ ├── buttons/
|
||||||
|
│ │ │ └── primary_button.dart
|
||||||
|
│ │ ├── inputs/
|
||||||
|
│ │ │ └── text_input.dart
|
||||||
|
│ │ └── cards/
|
||||||
|
│ │ └── info_card.dart
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── api_service.dart
|
||||||
|
│ │ └── storage_service.dart
|
||||||
|
│ └── models/
|
||||||
|
│ └── api_response.dart
|
||||||
|
└── routes/
|
||||||
|
└── app_router.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Layer Responsibilities
|
||||||
|
|
||||||
|
| Layer | Responsibility |
|
||||||
|
|-------|----------------|
|
||||||
|
| **data/** | API calls, local storage, DTOs, repository implementations |
|
||||||
|
| **domain/** | Business logic, entities, abstract repositories, use cases |
|
||||||
|
| **presentation/** | UI screens, widgets, view logic |
|
||||||
|
| **providers/** | Riverpod providers or Bloc definitions |
|
||||||
|
|
||||||
|
## pubspec.yaml Essentials
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: my_app
|
||||||
|
description: A Flutter application.
|
||||||
|
version: 1.0.0+1
|
||||||
|
publish_to: 'none'
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# State Management (choose one)
|
||||||
|
flutter_riverpod: ^2.5.0
|
||||||
|
riverpod_annotation: ^2.3.0
|
||||||
|
# OR
|
||||||
|
flutter_bloc: ^8.1.0
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
go_router: ^14.0.0
|
||||||
|
|
||||||
|
# Networking
|
||||||
|
dio: ^5.4.0
|
||||||
|
|
||||||
|
# Code Generation
|
||||||
|
freezed_annotation: ^2.4.0
|
||||||
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
shared_preferences: ^2.2.0
|
||||||
|
hive_flutter: ^1.1.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
flutter_hooks: ^0.20.0
|
||||||
|
cached_network_image: ^3.3.0
|
||||||
|
intl: ^0.19.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# Code Generation
|
||||||
|
build_runner: ^2.4.0
|
||||||
|
riverpod_generator: ^2.4.0
|
||||||
|
freezed: ^2.5.0
|
||||||
|
json_serializable: ^6.8.0
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
bloc_test: ^9.1.0
|
||||||
|
mocktail: ^1.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Main Entry Point
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// main.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
await Hive.initFlutter();
|
||||||
|
|
||||||
|
runApp(
|
||||||
|
const ProviderScope(
|
||||||
|
child: MyApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// app.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
class MyApp extends ConsumerWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
|
||||||
|
return MaterialApp.router(
|
||||||
|
title: 'My App',
|
||||||
|
routerConfig: router,
|
||||||
|
theme: AppTheme.light,
|
||||||
|
darkTheme: AppTheme.dark,
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Router Provider
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// routes/app_router.dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
|
return GoRouter(
|
||||||
|
initialLocation: '/',
|
||||||
|
debugLogDiagnostics: true,
|
||||||
|
redirect: (context, state) {
|
||||||
|
// Auth guard logic
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
builder: (context, state) => const HomeScreen(),
|
||||||
|
),
|
||||||
|
// Add more routes
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// core/constants/environment.dart
|
||||||
|
enum Environment { dev, staging, prod }
|
||||||
|
|
||||||
|
class EnvConfig {
|
||||||
|
static Environment current = Environment.dev;
|
||||||
|
|
||||||
|
static String get baseUrl {
|
||||||
|
switch (current) {
|
||||||
|
case Environment.dev:
|
||||||
|
return 'https://dev-api.example.com';
|
||||||
|
case Environment.staging:
|
||||||
|
return 'https://staging-api.example.com';
|
||||||
|
case Environment.prod:
|
||||||
|
return 'https://api.example.com';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Injection with Riverpod
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// shared/services/api_service.dart
|
||||||
|
final apiServiceProvider = Provider<ApiService>((ref) {
|
||||||
|
final dio = Dio(BaseOptions(
|
||||||
|
baseUrl: EnvConfig.baseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add interceptors
|
||||||
|
dio.interceptors.add(AuthInterceptor(ref));
|
||||||
|
dio.interceptors.add(LogInterceptor(responseBody: true));
|
||||||
|
|
||||||
|
return ApiService(dio);
|
||||||
|
});
|
||||||
|
|
||||||
|
// features/auth/providers/auth_provider.dart
|
||||||
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||||
|
final api = ref.watch(apiServiceProvider);
|
||||||
|
final storage = ref.watch(storageServiceProvider);
|
||||||
|
return AuthRepositoryImpl(api: api, storage: storage);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
| Practice | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Feature isolation | Each feature is self-contained |
|
||||||
|
| Dependency inversion | Domain depends on abstractions |
|
||||||
|
| Single responsibility | One class, one purpose |
|
||||||
|
| Naming conventions | Clear, descriptive names |
|
||||||
|
| Barrel exports | One index.dart per folder |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter is a trademark of Google LLC.*
|
||||||
|
|
||||||
232
skills/flutter-dev/references/riverpod-state.md
Normal file
232
skills/flutter-dev/references/riverpod-state.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Riverpod State Management
|
||||||
|
|
||||||
|
Riverpod 2.0 state management guide covering provider types, notifier patterns, and widget integration.
|
||||||
|
|
||||||
|
## Provider Types
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
// Simple computed value
|
||||||
|
final greetingProvider = Provider<String>((ref) {
|
||||||
|
final name = ref.watch(userNameProvider);
|
||||||
|
return 'Hello, $name';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple mutable state
|
||||||
|
final counterProvider = StateProvider<int>((ref) => 0);
|
||||||
|
|
||||||
|
// Async state (API calls)
|
||||||
|
final usersProvider = FutureProvider<List<User>>((ref) async {
|
||||||
|
final api = ref.read(apiProvider);
|
||||||
|
return api.getUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream state (real-time)
|
||||||
|
final messagesProvider = StreamProvider<List<Message>>((ref) {
|
||||||
|
return ref.read(chatServiceProvider).messagesStream;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider Type Reference
|
||||||
|
|
||||||
|
| Provider | Use Case |
|
||||||
|
|----------|----------|
|
||||||
|
| `Provider` | Computed/derived values, dependency injection |
|
||||||
|
| `StateProvider` | Simple mutable state (counter, toggle) |
|
||||||
|
| `FutureProvider` | Async operations (one-time fetch) |
|
||||||
|
| `StreamProvider` | Real-time data streams |
|
||||||
|
| `NotifierProvider` | Complex state with methods |
|
||||||
|
| `AsyncNotifierProvider` | Async state with methods |
|
||||||
|
|
||||||
|
## Notifier Pattern (Riverpod 2.0)
|
||||||
|
|
||||||
|
### Synchronous Notifier
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class TodoList extends _$TodoList {
|
||||||
|
@override
|
||||||
|
List<Todo> build() => [];
|
||||||
|
|
||||||
|
void add(Todo todo) {
|
||||||
|
state = [...state, todo];
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggle(String id) {
|
||||||
|
state = [
|
||||||
|
for (final todo in state)
|
||||||
|
if (todo.id == id)
|
||||||
|
todo.copyWith(completed: !todo.completed)
|
||||||
|
else
|
||||||
|
todo,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String id) {
|
||||||
|
state = state.where((t) => t.id != id).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Notifier
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class UserProfile extends _$UserProfile {
|
||||||
|
@override
|
||||||
|
Future<User> build() async {
|
||||||
|
return ref.read(apiProvider).getCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateName(String name) async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
state = await AsyncValue.guard(() async {
|
||||||
|
final updated = await ref.read(apiProvider).updateUser(name: name);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
await future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in Widgets
|
||||||
|
|
||||||
|
### ConsumerWidget (Recommended)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class TodoScreen extends ConsumerWidget {
|
||||||
|
const TodoScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final todos = ref.watch(todoListProvider);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: todos.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final todo = todos[index];
|
||||||
|
return ListTile(
|
||||||
|
key: ValueKey(todo.id),
|
||||||
|
title: Text(todo.title),
|
||||||
|
leading: Checkbox(
|
||||||
|
value: todo.completed,
|
||||||
|
onChanged: (_) => ref.read(todoListProvider.notifier).toggle(todo.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selective Rebuilds with select
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class UserAvatar extends ConsumerWidget {
|
||||||
|
const UserAvatar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Only rebuilds when avatarUrl changes
|
||||||
|
final avatarUrl = ref.watch(userProvider.select((u) => u?.avatarUrl));
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async State Handling
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class UserProfileScreen extends ConsumerWidget {
|
||||||
|
const UserProfileScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final userAsync = ref.watch(userProfileProvider);
|
||||||
|
|
||||||
|
return userAsync.when(
|
||||||
|
data: (user) => UserProfileContent(user: user),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, stack) => ErrorView(
|
||||||
|
message: err.toString(),
|
||||||
|
onRetry: () => ref.invalidate(userProfileProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consumer for Scoped Rebuilds
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MyScreen extends StatelessWidget {
|
||||||
|
const MyScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Text('Static content'),
|
||||||
|
Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final count = ref.watch(counterProvider);
|
||||||
|
return Text('Count: $count');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provider Modifiers
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Auto-dispose when no longer used
|
||||||
|
@riverpod
|
||||||
|
class AutoDisposeExample extends _$AutoDisposeExample {
|
||||||
|
@override
|
||||||
|
String build() => 'value';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Family - parameterized providers
|
||||||
|
@riverpod
|
||||||
|
Future<User> userById(UserByIdRef ref, String id) async {
|
||||||
|
return ref.read(apiProvider).getUser(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
final user = ref.watch(userByIdProvider('123'));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Use `ref.watch()` in build | Use `ref.watch()` in callbacks |
|
||||||
|
| Use `ref.read()` in callbacks | Use `ref.read()` in build |
|
||||||
|
| Use `select()` for granular rebuilds | Watch entire state unnecessarily |
|
||||||
|
| Create new state instances | Mutate state directly |
|
||||||
|
| Use `AsyncValue.guard()` for errors | Catch errors manually |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Method | When to Use |
|
||||||
|
|--------|-------------|
|
||||||
|
| `ref.watch()` | In build method, rebuilds on change |
|
||||||
|
| `ref.read()` | In callbacks, one-time read |
|
||||||
|
| `ref.listen()` | Side effects on change |
|
||||||
|
| `ref.invalidate()` | Force provider refresh |
|
||||||
|
| `ref.refresh()` | Invalidate and get new value |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Riverpod is an open-source state management library by Remi Rousselet.*
|
||||||
364
skills/flutter-dev/references/testing.md
Normal file
364
skills/flutter-dev/references/testing.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# Testing Strategies
|
||||||
|
|
||||||
|
Flutter testing guide covering widget tests, unit tests, integration tests, and mocking patterns.
|
||||||
|
|
||||||
|
## Test Types
|
||||||
|
|
||||||
|
| Type | Purpose | Speed | Scope |
|
||||||
|
|------|---------|-------|-------|
|
||||||
|
| Unit tests | Business logic, utilities | Fast | Single function/class |
|
||||||
|
| Widget tests | UI components | Medium | Single widget |
|
||||||
|
| Integration tests | Full user flows | Slow | Multiple screens |
|
||||||
|
|
||||||
|
## Widget Tests
|
||||||
|
|
||||||
|
### Basic Widget Test
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Counter increments when button tapped', (tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: CounterScreen()));
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
expect(find.text('0'), findsOneWidget);
|
||||||
|
expect(find.text('1'), findsNothing);
|
||||||
|
|
||||||
|
// Tap the increment button
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify state changed
|
||||||
|
expect(find.text('0'), findsNothing);
|
||||||
|
expect(find.text('1'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing with Riverpod
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('displays user name from provider', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
userProvider.overrideWithValue(
|
||||||
|
AsyncValue.data(User(name: 'Test User')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: UserScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Test User'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows loading indicator', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
userProvider.overrideWithValue(const AsyncValue.loading()),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: UserScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows error message', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
userProvider.overrideWithValue(
|
||||||
|
AsyncValue.error('Network error', StackTrace.current),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: UserScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Network error'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing with Bloc
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:bloc_test/bloc_test.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
class MockCounterBloc extends MockBloc<CounterEvent, CounterState>
|
||||||
|
implements CounterBloc {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockCounterBloc mockBloc;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockBloc = MockCounterBloc();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays current count', (tester) async {
|
||||||
|
when(() => mockBloc.state).thenReturn(const CounterState(value: 42));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: BlocProvider<CounterBloc>.value(
|
||||||
|
value: mockBloc,
|
||||||
|
child: const CounterScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('42'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('calls increment on button tap', (tester) async {
|
||||||
|
when(() => mockBloc.state).thenReturn(const CounterState(value: 0));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: BlocProvider<CounterBloc>.value(
|
||||||
|
value: mockBloc,
|
||||||
|
child: const CounterScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
|
||||||
|
verify(() => mockBloc.add(CounterIncremented())).called(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bloc Tests
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:bloc_test/bloc_test.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
class MockUserRepository extends Mock implements UserRepository {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockUserRepository mockRepository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockRepository = MockUserRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('UserBloc', () {
|
||||||
|
blocTest<UserBloc, UserState>(
|
||||||
|
'emits loading then success when user loaded',
|
||||||
|
setUp: () {
|
||||||
|
when(() => mockRepository.getUser())
|
||||||
|
.thenAnswer((_) async => User(name: 'Test'));
|
||||||
|
},
|
||||||
|
build: () => UserBloc(repository: mockRepository),
|
||||||
|
act: (bloc) => bloc.add(UserRequested()),
|
||||||
|
expect: () => [
|
||||||
|
const UserState(status: UserStatus.loading),
|
||||||
|
UserState(status: UserStatus.success, user: User(name: 'Test')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
blocTest<UserBloc, UserState>(
|
||||||
|
'emits loading then failure when error occurs',
|
||||||
|
setUp: () {
|
||||||
|
when(() => mockRepository.getUser())
|
||||||
|
.thenThrow(Exception('Network error'));
|
||||||
|
},
|
||||||
|
build: () => UserBloc(repository: mockRepository),
|
||||||
|
act: (bloc) => bloc.add(UserRequested()),
|
||||||
|
expect: () => [
|
||||||
|
const UserState(status: UserStatus.loading),
|
||||||
|
isA<UserState>()
|
||||||
|
.having((s) => s.status, 'status', UserStatus.failure),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unit Tests
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Validator', () {
|
||||||
|
test('returns error for empty email', () {
|
||||||
|
expect(Validator.email(''), 'Email is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns error for invalid email', () {
|
||||||
|
expect(Validator.email('invalid'), 'Invalid email format');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for valid email', () {
|
||||||
|
expect(Validator.email('test@example.com'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Calculator', () {
|
||||||
|
late Calculator calculator;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
calculator = Calculator();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds two numbers', () {
|
||||||
|
expect(calculator.add(2, 3), 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on division by zero', () {
|
||||||
|
expect(() => calculator.divide(10, 0), throwsArgumentError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mocking with Mocktail
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
// Create mock classes
|
||||||
|
class MockApiService extends Mock implements ApiService {}
|
||||||
|
class MockStorageService extends Mock implements StorageService {}
|
||||||
|
|
||||||
|
// Register fallback values for complex types
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(User(name: 'fallback'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockApiService mockApi;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockApi = MockApiService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetches user from API', () async {
|
||||||
|
// Arrange
|
||||||
|
when(() => mockApi.getUser(any()))
|
||||||
|
.thenAnswer((_) async => User(name: 'Test'));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final repository = UserRepository(api: mockApi);
|
||||||
|
final user = await repository.getUser('123');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(user.name, 'Test');
|
||||||
|
verify(() => mockApi.getUser('123')).called(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Tests
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// integration_test/app_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:my_app/main.dart' as app;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
testWidgets('complete login flow', (tester) async {
|
||||||
|
app.main();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Navigate to login
|
||||||
|
await tester.tap(find.text('Login'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Enter credentials
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('email_field')),
|
||||||
|
'test@example.com',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('password_field')),
|
||||||
|
'password123',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await tester.tap(find.text('Sign In'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify navigation to home
|
||||||
|
expect(find.text('Welcome'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run integration tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter test integration_test/app_test.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Helpers
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// test/helpers/pump_app.dart
|
||||||
|
extension PumpApp on WidgetTester {
|
||||||
|
Future<void> pumpApp(Widget widget, {List<Override>? overrides}) {
|
||||||
|
return pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: overrides ?? [],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: widget,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
await tester.pumpApp(const MyWidget());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Golden Tests
|
||||||
|
|
||||||
|
```dart
|
||||||
|
testWidgets('matches golden', (tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: MyWidget()));
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
find.byType(MyWidget),
|
||||||
|
matchesGoldenFile('goldens/my_widget.png'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Update goldens:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter test --update-goldens
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
| Test Type | What to Test |
|
||||||
|
|-----------|--------------|
|
||||||
|
| Widget tests | UI rendering, user interactions, state changes |
|
||||||
|
| Bloc tests | Event → state transitions, async operations |
|
||||||
|
| Unit tests | Validators, formatters, utilities, models |
|
||||||
|
| Integration tests | Critical user flows, navigation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter and flutter_test are trademarks of Google LLC.*
|
||||||
233
skills/flutter-dev/references/widget-patterns.md
Normal file
233
skills/flutter-dev/references/widget-patterns.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# Widget Patterns
|
||||||
|
|
||||||
|
Flutter widget best practices covering const optimization, responsive layouts, hooks, and sliver patterns.
|
||||||
|
|
||||||
|
## Optimized Widget Pattern
|
||||||
|
|
||||||
|
Always use `const` constructors for static widgets to prevent unnecessary rebuilds:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class OptimizedCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const OptimizedCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extracting Const Widgets
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MyScreen extends StatelessWidget {
|
||||||
|
const MyScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: const [
|
||||||
|
_Header(),
|
||||||
|
_Body(),
|
||||||
|
_Footer(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Header extends StatelessWidget {
|
||||||
|
const _Header();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Text('Header');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Layout
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ResponsiveLayout extends StatelessWidget {
|
||||||
|
final Widget mobile;
|
||||||
|
final Widget? tablet;
|
||||||
|
final Widget desktop;
|
||||||
|
|
||||||
|
const ResponsiveLayout({
|
||||||
|
super.key,
|
||||||
|
required this.mobile,
|
||||||
|
this.tablet,
|
||||||
|
required this.desktop,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const double mobileBreakpoint = 650;
|
||||||
|
static const double desktopBreakpoint = 1100;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (constraints.maxWidth >= desktopBreakpoint) return desktop;
|
||||||
|
if (constraints.maxWidth >= mobileBreakpoint) return tablet ?? mobile;
|
||||||
|
return mobile;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breakpoint Reference
|
||||||
|
|
||||||
|
| Type | Width | Usage |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Mobile | < 650pt | Single column, bottom nav |
|
||||||
|
| Tablet | 650-1100pt | Two columns, side nav optional |
|
||||||
|
| Desktop | > 1100pt | Multi-column, persistent nav |
|
||||||
|
|
||||||
|
## Custom Hooks (flutter_hooks)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
class CounterWidget extends HookWidget {
|
||||||
|
const CounterWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final counter = useState(0);
|
||||||
|
final controller = useTextEditingController();
|
||||||
|
final isMounted = useIsMounted();
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
debugPrint('Widget mounted');
|
||||||
|
return () {
|
||||||
|
debugPrint('Widget disposed');
|
||||||
|
};
|
||||||
|
}, const []);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text('Count: ${counter.value}'),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => counter.value++,
|
||||||
|
child: const Text('Increment'),
|
||||||
|
),
|
||||||
|
TextField(controller: controller),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Hooks
|
||||||
|
|
||||||
|
| Hook | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `useState` | Local state management |
|
||||||
|
| `useEffect` | Side effects with cleanup |
|
||||||
|
| `useMemoized` | Expensive computation caching |
|
||||||
|
| `useTextEditingController` | Text field controller |
|
||||||
|
| `useAnimationController` | Animation controller |
|
||||||
|
| `useFocusNode` | Focus management |
|
||||||
|
| `useIsMounted` | Check if widget is mounted |
|
||||||
|
|
||||||
|
## Sliver Patterns
|
||||||
|
|
||||||
|
```dart
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 200,
|
||||||
|
pinned: true,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
title: const Text('Title'),
|
||||||
|
background: Image.network(imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => ListTile(
|
||||||
|
key: ValueKey(items[index].id),
|
||||||
|
title: Text(items[index].title),
|
||||||
|
),
|
||||||
|
childCount: items.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text('Footer'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sliver Types
|
||||||
|
|
||||||
|
| Sliver | Usage |
|
||||||
|
|--------|-------|
|
||||||
|
| `SliverAppBar` | Collapsing app bar |
|
||||||
|
| `SliverList` | Lazy list |
|
||||||
|
| `SliverGrid` | Lazy grid |
|
||||||
|
| `SliverToBoxAdapter` | Single non-sliver widget |
|
||||||
|
| `SliverPadding` | Add padding to sliver |
|
||||||
|
| `SliverFillRemaining` | Fill remaining space |
|
||||||
|
|
||||||
|
## Key Usage Patterns
|
||||||
|
|
||||||
|
```dart
|
||||||
|
ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: ListTile(
|
||||||
|
key: ValueKey('tile_${item.id}'),
|
||||||
|
title: Text(item.title),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key Type | When to Use |
|
||||||
|
|----------|-------------|
|
||||||
|
| `ValueKey` | Unique ID available |
|
||||||
|
| `ObjectKey` | Object identity matters |
|
||||||
|
| `UniqueKey` | Force rebuild |
|
||||||
|
| `GlobalKey` | Access state across tree |
|
||||||
|
|
||||||
|
## Optimization Checklist
|
||||||
|
|
||||||
|
| Pattern | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| const widgets | Add `const` to static widgets |
|
||||||
|
| Keys | Use `ValueKey` for list items |
|
||||||
|
| Select | `ref.watch(provider.select(...))` |
|
||||||
|
| RepaintBoundary | Isolate expensive repaints |
|
||||||
|
| ListView.builder | Lazy loading for lists |
|
||||||
|
| const constructors | Always use when possible |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter and Material Design are trademarks of Google LLC.*
|
||||||
@@ -18,7 +18,11 @@ import argparse
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
API_KEY = os.getenv("MINIMAX_API_KEY")
|
API_KEY = os.getenv("MINIMAX_API_KEY")
|
||||||
API_BASE = "https://api.minimax.io/v1"
|
# China Mainland: https://api.minimaxi.com/v1
|
||||||
|
# Overseas: https://api.minimax.io/v1
|
||||||
|
API_BASE = os.getenv("MINIMAX_API_BASE")
|
||||||
|
if not API_BASE:
|
||||||
|
raise SystemExit("ERROR: MINIMAX_API_BASE is not set.")
|
||||||
|
|
||||||
ASPECT_RATIOS = ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]
|
ASPECT_RATIOS = ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import argparse
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
API_KEY = os.getenv("MINIMAX_API_KEY")
|
API_KEY = os.getenv("MINIMAX_API_KEY")
|
||||||
API_BASE = os.getenv("MINIMAX_API_BASE", "https://api.minimax.io/v1")
|
# China Mainland: https://api.minimaxi.com/v1
|
||||||
|
# Overseas: https://api.minimax.io/v1
|
||||||
|
API_BASE = os.getenv("MINIMAX_API_BASE")
|
||||||
|
if not API_BASE:
|
||||||
|
raise SystemExit("ERROR: MINIMAX_API_BASE is not set.")
|
||||||
|
|
||||||
|
|
||||||
def generate_music(
|
def generate_music(
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import argparse
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
API_KEY = os.getenv("MINIMAX_API_KEY")
|
API_KEY = os.getenv("MINIMAX_API_KEY")
|
||||||
API_BASE = os.getenv("MINIMAX_API_BASE", "https://api.minimax.io/v1")
|
# China Mainland: https://api.minimaxi.com/v1
|
||||||
|
# Overseas: https://api.minimax.io/v1
|
||||||
|
API_BASE = os.getenv("MINIMAX_API_BASE")
|
||||||
|
if not API_BASE:
|
||||||
|
raise SystemExit("ERROR: MINIMAX_API_BASE is not set.")
|
||||||
|
|
||||||
|
|
||||||
def tts(
|
def tts(
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import argparse
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
API_KEY = os.getenv("MINIMAX_API_KEY")
|
API_KEY = os.getenv("MINIMAX_API_KEY")
|
||||||
API_BASE = "https://api.minimax.io/v1"
|
# China Mainland: https://api.minimaxi.com/v1
|
||||||
|
# Overseas: https://api.minimax.io/v1
|
||||||
|
API_BASE = os.getenv("MINIMAX_API_BASE")
|
||||||
|
if not API_BASE:
|
||||||
|
raise SystemExit("ERROR: MINIMAX_API_BASE is not set.")
|
||||||
|
|
||||||
|
|
||||||
def _headers():
|
def _headers():
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import argparse
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
API_KEY = os.getenv("MINIMAX_API_KEY")
|
API_KEY = os.getenv("MINIMAX_API_KEY")
|
||||||
API_BASE = "https://api.minimax.io/v1"
|
# China Mainland: https://api.minimaxi.com/v1
|
||||||
|
# Overseas: https://api.minimax.io/v1
|
||||||
|
API_BASE = os.getenv("MINIMAX_API_BASE")
|
||||||
|
if not API_BASE:
|
||||||
|
raise SystemExit("ERROR: MINIMAX_API_BASE is not set.")
|
||||||
|
|
||||||
ASPECT_RATIOS = ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]
|
ASPECT_RATIOS = ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ import argparse
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
API_KEY = os.getenv("MINIMAX_API_KEY")
|
API_KEY = os.getenv("MINIMAX_API_KEY")
|
||||||
API_BASE = "https://api.minimax.io/v1"
|
# China Mainland: https://api.minimaxi.com/v1
|
||||||
|
# Overseas: https://api.minimax.io/v1
|
||||||
|
API_BASE = os.getenv("MINIMAX_API_BASE")
|
||||||
|
if not API_BASE:
|
||||||
|
raise SystemExit("ERROR: MINIMAX_API_BASE is not set.")
|
||||||
|
|
||||||
I2V_MODELS = [
|
I2V_MODELS = [
|
||||||
"MiniMax-Hailuo-2.3",
|
"MiniMax-Hailuo-2.3",
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: minimax-multimodal-toolkit
|
name: minimax-multimodal-toolkit
|
||||||
description: MiniMax multimodal model skill — use MiniMax Multi-Modal models for speech, music, video, and image. Create voice, music, video, and images with MiniMax AI: TTS (text-to-speech, voice cloning, voice design, multi-segment), music (songs, instrumentals), video (text-to-video, image-to-video, start-end frame, subject reference, templates, long-form multi-scene), image (text-to-image, image-to-image with character reference), and media processing (convert, concat, trim, extract). Use when the user mentions MiniMax, multimodal generation, or wants speech/music/video/image AI, MiniMax APIs, or FFmpeg workflows alongside MiniMax outputs.
|
description: >
|
||||||
|
MiniMax multimodal model skill — use MiniMax Multi-Modal models for speech, music, video, and image.
|
||||||
|
Create voice, music, video, and images with MiniMax AI: TTS (text-to-speech, voice cloning, voice design,
|
||||||
|
multi-segment), music (songs, instrumentals), video (text-to-video, image-to-video, start-end frame,
|
||||||
|
subject reference, templates, long-form multi-scene), image (text-to-image, image-to-image with character
|
||||||
|
reference), and media processing (convert, concat, trim, extract).
|
||||||
|
Use when the user mentions MiniMax, multimodal generation, or wants speech/music/video/image AI,
|
||||||
|
MiniMax APIs, or FFmpeg workflows alongside MiniMax outputs.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
version: "1.0"
|
||||||
|
category: media-generation
|
||||||
---
|
---
|
||||||
|
|
||||||
# MiniMax Multi-Modal Toolkit
|
# MiniMax Multi-Modal Toolkit
|
||||||
@@ -65,6 +76,37 @@ Before running any script, check if `MINIMAX_API_KEY` is set in the environment.
|
|||||||
1. Ask the user to provide their MiniMax API key
|
1. Ask the user to provide their MiniMax API key
|
||||||
2. Instruct and help user to set it via `export MINIMAX_API_KEY="sk-..."` in their terminal or add it to their shell profile (`~/.zshrc` / `~/.bashrc`) for persistence
|
2. Instruct and help user to set it via `export MINIMAX_API_KEY="sk-..."` in their terminal or add it to their shell profile (`~/.zshrc` / `~/.bashrc`) for persistence
|
||||||
|
|
||||||
|
## Plan Limits & Quotas
|
||||||
|
|
||||||
|
**IMPORTANT — Always respect the user's plan limits before generating content.** If the user's quota is exhausted or insufficient, warn them before proceeding.
|
||||||
|
|
||||||
|
### Standard Plans
|
||||||
|
|
||||||
|
| Capability | Starter | Plus | Max |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M2.7 (chat) | 600 req/5h | 1,500 req/5h | 4,500 req/5h |
|
||||||
|
| Speech 2.8 | — | 4,000 chars/day | 11,000 chars/day |
|
||||||
|
| image-01 | — | 50 images/day | 120 images/day |
|
||||||
|
| Hailuo-2.3-Fast 768P 6s | — | — | 2 videos/day |
|
||||||
|
| Hailuo-2.3 768P 6s | — | — | 2 videos/day |
|
||||||
|
| Music-2.5 | — | — | 4 songs/day (≤5 min each) |
|
||||||
|
|
||||||
|
### High-Speed Plans
|
||||||
|
|
||||||
|
| Capability | Plus-HS | Max-HS | Ultra-HS |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M2.7-highspeed (chat) | 1,500 req/5h | 4,500 req/5h | 30,000 req/5h |
|
||||||
|
| Speech 2.8 | 9,000 chars/day | 19,000 chars/day | 50,000 chars/day |
|
||||||
|
| image-01 | 100 images/day | 200 images/day | 800 images/day |
|
||||||
|
| Hailuo-2.3-Fast 768P 6s | — | 3 videos/day | 5 videos/day |
|
||||||
|
| Hailuo-2.3 768P 6s | — | 3 videos/day | 5 videos/day |
|
||||||
|
| Music-2.5 | — | 7 songs/day (≤5 min each) | 15 songs/day (≤5 min each) |
|
||||||
|
|
||||||
|
**Key quota constraints:**
|
||||||
|
- **Video resolution: 768P only** — 1080P is not available on any plan
|
||||||
|
- **Video duration: 6s** — all plan quotas are counted in 6-second units
|
||||||
|
- **Video quota is very limited** (2–5/day depending on plan) — always confirm with the user before generating video
|
||||||
|
|
||||||
## Key Capabilities
|
## Key Capabilities
|
||||||
|
|
||||||
| Capability | Description | Entry point |
|
| Capability | Description | Entry point |
|
||||||
@@ -373,40 +415,30 @@ bash scripts/image/generate_image.sh \
|
|||||||
|
|
||||||
| User intent | Script to use |
|
| User intent | Script to use |
|
||||||
|-------------|---------------|
|
|-------------|---------------|
|
||||||
| Default / no special request | `scripts/video/generate_video.sh` (single segment, **10s, 768P**) |
|
| Default / no special request | `scripts/video/generate_video.sh` (single segment, **6s, 768P**) |
|
||||||
| User explicitly asks for "long video", "multi-scene", "story", or duration > 10s | `scripts/video/generate_long_video.sh` (multi-segment) |
|
| User explicitly asks for "long video", "multi-scene", "story", or duration > 10s | `scripts/video/generate_long_video.sh` (multi-segment) |
|
||||||
|
|
||||||
**Default behavior:** Always use single-segment `generate_video.sh` with **duration 10s and resolution 768P** unless the user explicitly asks for a long video, multi-scene video, or specifies a total duration exceeding 10 seconds. Do NOT automatically split into multiple segments — a single 10s video is the standard output. Only use `generate_long_video.sh` when the user clearly needs multi-scene or longer content.
|
**Default behavior:** Always use single-segment `generate_video.sh` with **duration 6s and resolution 768P** unless the user explicitly asks for a long video or multi-scene video. Do NOT automatically split into multiple segments — a single 6s video is the standard output. Only use `generate_long_video.sh` when the user clearly needs multi-scene or longer content.
|
||||||
|
|
||||||
Entry point (single video): `scripts/video/generate_video.sh`
|
Entry point (single video): `scripts/video/generate_video.sh`
|
||||||
Entry point (long/multi-scene): `scripts/video/generate_long_video.sh`
|
Entry point (long/multi-scene): `scripts/video/generate_long_video.sh`
|
||||||
|
|
||||||
### Video Model Constraints (MUST follow)
|
### Video Model Constraints (MUST follow)
|
||||||
|
|
||||||
**Duration limits by model and resolution:**
|
**Supported resolutions and durations by model:**
|
||||||
|
|
||||||
| Model | 720P | 768P | 1080P |
|
| Model | Resolution | Duration |
|
||||||
|-------|------|------|-------|
|
|-------|-----------|----------|
|
||||||
| MiniMax-Hailuo-2.3 | - | 6s or **10s** | 6s only |
|
| MiniMax-Hailuo-2.3 | 768P only | 6s or 10s |
|
||||||
| MiniMax-Hailuo-2.3-Fast | - | 6s or **10s** | 6s only |
|
| MiniMax-Hailuo-2.3-Fast | 768P only | 6s or 10s |
|
||||||
| MiniMax-Hailuo-02 | - | 6s or **10s** | 6s only |
|
| MiniMax-Hailuo-02 | 512P, 768P (default) | 6s or 10s |
|
||||||
| T2V-01 / T2V-01-Director | 6s only | - | - |
|
| T2V-01 / T2V-01-Director | 720P | 6s only |
|
||||||
| I2V-01 / I2V-01-Director / I2V-01-live | 6s only | - | - |
|
| I2V-01 / I2V-01-Director / I2V-01-live | 720P | 6s only |
|
||||||
| S2V-01 (ref) | 6s only | - | - |
|
| S2V-01 (ref) | 720P | 6s only |
|
||||||
|
|
||||||
**Resolution options by model and duration:**
|
|
||||||
|
|
||||||
| Model | 6s | 10s |
|
|
||||||
|-------|-----|-----|
|
|
||||||
| MiniMax-Hailuo-2.3 | 768P (default), 1080P | 768P only |
|
|
||||||
| MiniMax-Hailuo-2.3-Fast | 768P (default), 1080P | 768P only |
|
|
||||||
| MiniMax-Hailuo-02 | 512P, 768P (default), 1080P | 512P, 768P (default) |
|
|
||||||
| Other models | 720P (default) | Not supported |
|
|
||||||
|
|
||||||
**Key rules:**
|
**Key rules:**
|
||||||
- **Default: 10s + 768P** (best balance of length and quality for MiniMax-Hailuo-2.3)
|
- **Default: 6s + 768P** — plan quotas are counted in 6-second units; use 6s unless user explicitly requests 10s
|
||||||
- 1080P only supports 6s duration — if user requests 1080P, set `--duration 6`
|
- **1080P is NOT supported** on any plan — always use 768P for Hailuo-2.3/2.3-Fast
|
||||||
- 10s duration only works with 768P (or 512P on Hailuo-02) — never combine 10s + 1080P
|
|
||||||
- Older models (T2V-01, I2V-01, S2V-01) only support 6s at 720P
|
- Older models (T2V-01, I2V-01, S2V-01) only support 6s at 720P
|
||||||
|
|
||||||
### IMPORTANT: Prompt Optimization (MUST follow before generating any video)
|
### IMPORTANT: Prompt Optimization (MUST follow before generating any video)
|
||||||
@@ -432,19 +464,12 @@ Before calling any video generation script, you MUST optimize the user's prompt
|
|||||||
6. **For multi-segment long videos**: Each segment's prompt must be self-contained and optimized individually. The i2v segments (segment 2+) should describe motion/change relative to the previous segment's ending frame.
|
6. **For multi-segment long videos**: Each segment's prompt must be self-contained and optimized individually. The i2v segments (segment 2+) should describe motion/change relative to the previous segment's ending frame.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Text-to-video (default: 10s, 768P)
|
# Text-to-video (default: 6s, 768P)
|
||||||
bash scripts/video/generate_video.sh \
|
bash scripts/video/generate_video.sh \
|
||||||
--mode t2v \
|
--mode t2v \
|
||||||
--prompt "A golden retriever puppy bounds toward the camera on a sunlit grass path, [跟随] tracking shot, warm golden hour, shallow depth of field, joyful" \
|
--prompt "A golden retriever puppy bounds toward the camera on a sunlit grass path, [跟随] tracking shot, warm golden hour, shallow depth of field, joyful" \
|
||||||
--output minimax-output/puppy.mp4
|
--output minimax-output/puppy.mp4
|
||||||
|
|
||||||
# Text-to-video with 1080P (must use --duration 6)
|
|
||||||
bash scripts/video/generate_video.sh \
|
|
||||||
--mode t2v \
|
|
||||||
--prompt "A golden retriever puppy bounds toward the camera" \
|
|
||||||
--duration 6 --resolution 1080P \
|
|
||||||
--output minimax-output/puppy_hd.mp4
|
|
||||||
|
|
||||||
# Image-to-video (prompt focuses on MOTION, not image content)
|
# Image-to-video (prompt focuses on MOTION, not image content)
|
||||||
bash scripts/video/generate_video.sh \
|
bash scripts/video/generate_video.sh \
|
||||||
--mode i2v \
|
--mode i2v \
|
||||||
@@ -469,7 +494,7 @@ bash scripts/video/generate_video.sh \
|
|||||||
|
|
||||||
### Long-form Video (Multi-scene)
|
### Long-form Video (Multi-scene)
|
||||||
|
|
||||||
Multi-scene long videos chain segments together: the first segment generates via text-to-video (t2v), then each subsequent segment uses the last frame of the previous segment as its first frame (i2v). Segments are joined with crossfade transitions for smooth continuity. Default is 10 seconds per segment.
|
Multi-scene long videos chain segments together: the first segment generates via text-to-video (t2v), then each subsequent segment uses the last frame of the previous segment as its first frame (i2v). Segments are joined with crossfade transitions for smooth continuity. Default is 6 seconds per segment.
|
||||||
|
|
||||||
**Workflow:**
|
**Workflow:**
|
||||||
1. Segment 1: t2v — generated purely from the optimized text prompt
|
1. Segment 1: t2v — generated purely from the optimized text prompt
|
||||||
@@ -482,10 +507,10 @@ Multi-scene long videos chain segments together: the first segment generates via
|
|||||||
- Segment 1 (t2v): Full scene description with subject, scene, camera, atmosphere
|
- Segment 1 (t2v): Full scene description with subject, scene, camera, atmosphere
|
||||||
- Segment 2+ (i2v): Focus on **what changes and moves** from the previous ending frame. Do NOT repeat the visual description — the first frame already provides it
|
- Segment 2+ (i2v): Focus on **what changes and moves** from the previous ending frame. Do NOT repeat the visual description — the first frame already provides it
|
||||||
- Maintain visual consistency: keep lighting, color grading, and style keywords consistent across segments
|
- Maintain visual consistency: keep lighting, color grading, and style keywords consistent across segments
|
||||||
- Each segment covers only 10 seconds of action — keep it focused
|
- Each segment covers only 6 seconds of action — keep it focused
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Example: 3-segment story with optimized per-segment prompts (default: 10s/segment, 768P)
|
# Example: 3-segment story with optimized per-segment prompts (default: 6s/segment, 768P)
|
||||||
bash scripts/video/generate_long_video.sh \
|
bash scripts/video/generate_long_video.sh \
|
||||||
--scenes \
|
--scenes \
|
||||||
"A lone astronaut stands on a red desert planet surface, wind blowing dust particles, [推进] slow push in toward the visor, dramatic rim lighting, cinematic sci-fi atmosphere" \
|
"A lone astronaut stands on a red desert planet surface, wind blowing dust particles, [推进] slow push in toward the visor, dramatic rim lighting, cinematic sci-fi atmosphere" \
|
||||||
@@ -497,7 +522,7 @@ bash scripts/video/generate_long_video.sh \
|
|||||||
# With custom settings
|
# With custom settings
|
||||||
bash scripts/video/generate_long_video.sh \
|
bash scripts/video/generate_long_video.sh \
|
||||||
--scenes "Scene 1 prompt" "Scene 2 prompt" \
|
--scenes "Scene 1 prompt" "Scene 2 prompt" \
|
||||||
--segment-duration 10 \
|
--segment-duration 6 \
|
||||||
--resolution 768P \
|
--resolution 768P \
|
||||||
--crossfade 0.5 \
|
--crossfade 0.5 \
|
||||||
--music-prompt "calm ambient background music" \
|
--music-prompt "calm ambient background music" \
|
||||||
@@ -528,8 +553,8 @@ bash scripts/video/generate_template_video.sh \
|
|||||||
|
|
||||||
| Mode | Default Model | Default Duration | Default Resolution | Notes |
|
| Mode | Default Model | Default Duration | Default Resolution | Notes |
|
||||||
|------|--------------|-----------------|-------------------|-------|
|
|------|--------------|-----------------|-------------------|-------|
|
||||||
| t2v | MiniMax-Hailuo-2.3 | 10s | 768P | Latest text-to-video |
|
| t2v | MiniMax-Hailuo-2.3 | 6s | 768P | Latest text-to-video |
|
||||||
| i2v | MiniMax-Hailuo-2.3 | 10s | 768P | Latest image-to-video |
|
| i2v | MiniMax-Hailuo-2.3 | 6s | 768P | Latest image-to-video |
|
||||||
| sef | MiniMax-Hailuo-02 | 6s | 768P | Start-end frame |
|
| sef | MiniMax-Hailuo-02 | 6s | 768P | Start-end frame |
|
||||||
| ref | S2V-01 | 6s | 720P | Subject reference, 6s only |
|
| ref | S2V-01 | 6s | 720P | Subject reference, 6s only |
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ image_to_data_url() {
|
|||||||
local mime
|
local mime
|
||||||
mime="$(file -b --mime-type "$path" 2>/dev/null)" || mime="image/jpeg"
|
mime="$(file -b --mime-type "$path" 2>/dev/null)" || mime="image/jpeg"
|
||||||
local b64
|
local b64
|
||||||
b64="$(base64 < "$path")"
|
b64="$(base64 -w 0 < "$path")"
|
||||||
echo "data:${mime};base64,${b64}"
|
echo "data:${mime};base64,${b64}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +57,78 @@ resolve_image() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Payload builder — avoids command-line length limits on Windows
|
||||||
|
# Uses temp files for jq when the payload may contain large base64 data.
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Build JSON payload, writing large fields (base64 image data) to temp files
|
||||||
|
# to avoid Windows cmd.exe argument-length limits (~32KB).
|
||||||
|
build_payload() {
|
||||||
|
local model="$1" prompt="$2" response_format="$3" n="$4"
|
||||||
|
local prompt_optimizer="$5" aigc_watermark="$6"
|
||||||
|
local aspect_ratio="$7" width="$8" height="$9" seed="${10:-}"
|
||||||
|
local ref_image="${11:-}"
|
||||||
|
|
||||||
|
# Start with base payload using temp file to avoid long command lines
|
||||||
|
local base_tmp
|
||||||
|
base_tmp="$(mktemp)"
|
||||||
|
trap "rm -f '$base_tmp'" EXIT INT TERM HUP
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg model "$model" \
|
||||||
|
--arg prompt "$prompt" \
|
||||||
|
--arg rf "$response_format" \
|
||||||
|
--argjson n "$n" \
|
||||||
|
--argjson po "$prompt_optimizer" \
|
||||||
|
--argjson aw "$aigc_watermark" \
|
||||||
|
'{model: $model, prompt: $prompt, response_format: $rf, n: $n, prompt_optimizer: $po, aigc_watermark: $aw}' \
|
||||||
|
> "$base_tmp"
|
||||||
|
|
||||||
|
# Add optional fields, each via temp file to stay within Windows arg limits
|
||||||
|
if [[ -n "$aspect_ratio" ]]; then
|
||||||
|
local tmp2; tmp2="$(mktemp)"; trap "rm -f '$base_tmp' '$tmp2'" EXIT INT TERM HUP
|
||||||
|
jq --arg ar "$aspect_ratio" '. + {aspect_ratio: $ar}' "$base_tmp" > "$tmp2"
|
||||||
|
mv "$tmp2" "$base_tmp"
|
||||||
|
fi
|
||||||
|
if [[ -n "$width" ]]; then
|
||||||
|
local tmp2; tmp2="$(mktemp)"; trap "rm -f '$base_tmp' '$tmp2'" EXIT INT TERM HUP
|
||||||
|
jq --argjson w "$width" '. + {width: $w}' "$base_tmp" > "$tmp2"
|
||||||
|
mv "$tmp2" "$base_tmp"
|
||||||
|
fi
|
||||||
|
if [[ -n "$height" ]]; then
|
||||||
|
local tmp2; tmp2="$(mktemp)"; trap "rm -f '$base_tmp' '$tmp2'" EXIT INT TERM HUP
|
||||||
|
jq --argjson h "$height" '. + {height: $h}' "$base_tmp" > "$tmp2"
|
||||||
|
mv "$tmp2" "$base_tmp"
|
||||||
|
fi
|
||||||
|
if [[ -n "$seed" ]]; then
|
||||||
|
local tmp2; tmp2="$(mktemp)"; trap "rm -f '$base_tmp' '$tmp2'" EXIT INT TERM HUP
|
||||||
|
jq --argjson s "$seed" '. + {seed: $s}' "$base_tmp" > "$tmp2"
|
||||||
|
mv "$tmp2" "$base_tmp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Subject reference (i2i mode) — build via temp file to avoid huge command-line args
|
||||||
|
if [[ -n "$ref_image" ]]; then
|
||||||
|
local img_url
|
||||||
|
img_url="$(resolve_image "$ref_image")"
|
||||||
|
# Create temp files and set traps separately to avoid set -u issues
|
||||||
|
local ref_tmp; ref_tmp="$(mktemp)"
|
||||||
|
trap "rm -f '$base_tmp' '$ref_tmp'" EXIT INT TERM HUP
|
||||||
|
local url_tmp; url_tmp="$(mktemp)"; trap "rm -f '$base_tmp' '$ref_tmp' '$url_tmp'" EXIT INT TERM HUP
|
||||||
|
# Write URL to temp file to avoid long-argument issues, then build JSON
|
||||||
|
echo -n "$img_url" > "$url_tmp"
|
||||||
|
# Use jq -s to collect all lines (handles base64 with embedded newlines), take first element
|
||||||
|
jq -Rs 'split("\n")[0] | {type: "character", image_file: .}' "$url_tmp" > "$ref_tmp"
|
||||||
|
local tmp2; tmp2="$(mktemp)"; trap "rm -f '$base_tmp' '$ref_tmp' '$url_tmp' '$tmp2'" EXIT INT TERM HUP
|
||||||
|
jq --slurpfile ref "$ref_tmp" '. + {subject_reference: $ref}' "$base_tmp" > "$tmp2"
|
||||||
|
mv "$tmp2" "$base_tmp"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat "$base_tmp"
|
||||||
|
rm -f "$base_tmp"
|
||||||
|
trap - EXIT INT TERM HUP
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Main
|
# Main
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -107,7 +179,7 @@ Options:
|
|||||||
-n, --count N Number of images to generate (1-9, default: 1)
|
-n, --count N Number of images to generate (1-9, default: 1)
|
||||||
--seed N Random seed for reproducibility
|
--seed N Random seed for reproducibility
|
||||||
--prompt-optimizer Enable automatic prompt optimization
|
--prompt-optimizer Enable automatic prompt optimization
|
||||||
--aigc-watermark Add AIGC watermark to generated images
|
--aigc-watermark Add AIGC watermark to generated images
|
||||||
--ref-image FILE Character reference image (local file or URL, i2i mode)
|
--ref-image FILE Character reference image (local file or URL, i2i mode)
|
||||||
--response-format FMT Response format: url (default), base64
|
--response-format FMT Response format: url (default), base64
|
||||||
--no-download Don't download, just print URL(s)
|
--no-download Don't download, just print URL(s)
|
||||||
@@ -144,31 +216,13 @@ USAGE
|
|||||||
echo "Error: -n must be between 1 and 9" >&2; exit 1
|
echo "Error: -n must be between 1 and 9" >&2; exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build payload
|
# Build payload using temp-file method (avoids Windows cmd.exe arg-length limit)
|
||||||
local payload
|
local payload
|
||||||
payload=$(jq -n \
|
payload=$(build_payload \
|
||||||
--arg model "$model" \
|
"$model" "$prompt" "$response_format" "$n" \
|
||||||
--arg prompt "$prompt" \
|
"$prompt_optimizer" "$aigc_watermark" \
|
||||||
--arg rf "$response_format" \
|
"$aspect_ratio" "$width" "$height" "$seed" \
|
||||||
--argjson n "$n" \
|
"$ref_image")
|
||||||
--argjson po "$prompt_optimizer" \
|
|
||||||
--argjson aw "$aigc_watermark" \
|
|
||||||
'{model: $model, prompt: $prompt, response_format: $rf, n: $n, prompt_optimizer: $po, aigc_watermark: $aw}')
|
|
||||||
|
|
||||||
[[ -n "$aspect_ratio" ]] && payload=$(echo "$payload" | jq --arg ar "$aspect_ratio" '. + {aspect_ratio: $ar}')
|
|
||||||
[[ -n "$width" ]] && payload=$(echo "$payload" | jq --argjson w "$width" '. + {width: $w}')
|
|
||||||
[[ -n "$height" ]] && payload=$(echo "$payload" | jq --argjson h "$height" '. + {height: $h}')
|
|
||||||
[[ -n "$seed" ]] && payload=$(echo "$payload" | jq --argjson s "$seed" '. + {seed: $s}')
|
|
||||||
|
|
||||||
# Subject reference (i2i mode)
|
|
||||||
if [[ "$mode" == "i2i" ]]; then
|
|
||||||
if [[ -z "$ref_image" ]]; then
|
|
||||||
echo "Error: --ref-image is required for i2i mode" >&2; exit 1
|
|
||||||
fi
|
|
||||||
local img_url
|
|
||||||
img_url="$(resolve_image "$ref_image")"
|
|
||||||
payload=$(echo "$payload" | jq --arg img "$img_url" '. + {subject_reference: [{type: "character", image_file: $img}]}')
|
|
||||||
fi
|
|
||||||
|
|
||||||
local api_host="${MINIMAX_API_HOST:-https://api.minimaxi.com}"
|
local api_host="${MINIMAX_API_HOST:-https://api.minimaxi.com}"
|
||||||
local api_url="${api_host}/v1/image_generation"
|
local api_url="${api_host}/v1/image_generation"
|
||||||
@@ -177,13 +231,18 @@ USAGE
|
|||||||
echo "Model: $model"
|
echo "Model: $model"
|
||||||
echo "Generating $n image(s)..."
|
echo "Generating $n image(s)..."
|
||||||
|
|
||||||
|
# Write payload to temp file to avoid command-line length limits
|
||||||
|
local payload_tmp; payload_tmp="$(mktemp)"
|
||||||
|
trap "rm -f '$payload_tmp'" EXIT INT TERM HUP
|
||||||
|
echo -n "$payload" > "$payload_tmp"
|
||||||
|
|
||||||
local raw_output http_code response
|
local raw_output http_code response
|
||||||
raw_output="$(curl -s -w "\n%{http_code}" \
|
raw_output="$(curl -s -w "\n%{http_code}" \
|
||||||
-X POST "$api_url" \
|
-X POST "$api_url" \
|
||||||
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
|
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
--max-time 120 \
|
--max-time 120 \
|
||||||
-d "$payload" 2>/dev/null)" || {
|
-d "@$payload_tmp" 2>/dev/null)" || {
|
||||||
echo "Error: curl request failed" >&2
|
echo "Error: curl request failed" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -203,6 +262,7 @@ USAGE
|
|||||||
local status_msg
|
local status_msg
|
||||||
status_msg="$(echo "$response" | jq -r '.base_resp.status_msg // "Unknown error"')"
|
status_msg="$(echo "$response" | jq -r '.base_resp.status_msg // "Unknown error"')"
|
||||||
echo "Error: API error (code $status_code): $status_msg" >&2
|
echo "Error: API error (code $status_code): $status_msg" >&2
|
||||||
|
echo "Full response: $response" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
149
skills/react-native-dev/SKILL.md
Normal file
149
skills/react-native-dev/SKILL.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
name: react-native-dev
|
||||||
|
description: |
|
||||||
|
React Native and Expo development guide covering components, styling, animations, navigation,
|
||||||
|
state management, forms, networking, performance optimization, testing, native capabilities,
|
||||||
|
and engineering (project structure, deployment, SDK upgrades, CI/CD).
|
||||||
|
Use when: building React Native or Expo apps, implementing animations or native UI, managing
|
||||||
|
state, fetching data, writing tests, optimizing performance, deploying to App Store/Play Store,
|
||||||
|
setting up CI/CD, upgrading Expo SDK, or configuring Tailwind/NativeWind.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
category: mobile
|
||||||
|
sources:
|
||||||
|
- Expo documentation (docs.expo.dev)
|
||||||
|
- React Native documentation (reactnative.dev)
|
||||||
|
- EAS (Expo Application Services) documentation
|
||||||
|
---
|
||||||
|
|
||||||
|
# React Native & Expo Development Guide
|
||||||
|
|
||||||
|
A practical guide for building production-ready React Native and Expo applications. Covers UI, animations, state, testing, performance, and deployment.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
Consult these resources as needed:
|
||||||
|
|
||||||
|
- [references/navigation.md](references/navigation.md) — Expo Router: Stack, Tabs, NativeTabs (`headerLargeTitle`, `headerBackButtonDisplayMode`), links, modals, sheets, context menus
|
||||||
|
- [references/components.md](references/components.md) — FlashList patterns, `expo-image`, safe areas (`contentInsetAdjustmentBehavior`), native controls, blur/glass effects, storage
|
||||||
|
- [references/styling.md](references/styling.md) — StyleSheet, NativeWind/Tailwind, platform styles, theming, dark mode
|
||||||
|
- [references/animations.md](references/animations.md) — Reanimated 3: entering/exiting, shared values, gestures, scroll-driven
|
||||||
|
- [references/state-management.md](references/state-management.md) — Zustand (selectors, persist), Jotai (atoms, derived), React Query, Context
|
||||||
|
- [references/forms.md](references/forms.md) — React Hook Form + Zod: validation, multi-step, dynamic arrays
|
||||||
|
- [references/networking.md](references/networking.md) — fetch wrapper, React Query (optimistic updates), auth tokens, offline, API routes, webhooks
|
||||||
|
- [references/performance.md](references/performance.md) — Profiling workflow, FlashList + `memo`, bundle analysis, TTI, memory leaks, animation perf
|
||||||
|
- [references/testing.md](references/testing.md) — Jest, React Native Testing Library, E2E with Maestro
|
||||||
|
- [references/native-capabilities.md](references/native-capabilities.md) — Camera, location, permissions (`use*Permissions` hooks), haptics, notifications, biometrics
|
||||||
|
- [references/engineering.md](references/engineering.md) — Project layout (`components/ui/`, `stores/`, `services/`), path aliases, SDK upgrades, EAS build/submit, CI/CD, DOM components
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Component Preferences
|
||||||
|
|
||||||
|
| Purpose | Use | Instead of |
|
||||||
|
|---------|-----|------------|
|
||||||
|
| Lists | `FlashList` (`@shopify/flash-list`) + `memo` items | `FlatList` (no view recycling) |
|
||||||
|
| Images | `expo-image` | RN `<Image>` (no cache, no WebP) |
|
||||||
|
| Press | `Pressable` | `TouchableOpacity` (legacy) |
|
||||||
|
| Audio | `expo-audio` | `expo-av` (deprecated) |
|
||||||
|
| Video | `expo-video` | `expo-av` (deprecated) |
|
||||||
|
| Animations | Reanimated 3 | RN Animated API (limited) |
|
||||||
|
| Gestures | Gesture Handler | PanResponder (legacy) |
|
||||||
|
| Platform check | `process.env.EXPO_OS` | `Platform.OS` |
|
||||||
|
| Context | `React.use()` | `React.useContext()` (React 18) |
|
||||||
|
| Safe area scroll | `contentInsetAdjustmentBehavior="automatic"` | `<SafeAreaView>` |
|
||||||
|
| SF Symbols | `expo-image` with `source="sf:name"` | `expo-symbols` |
|
||||||
|
|
||||||
|
### Scaling Up
|
||||||
|
|
||||||
|
| Situation | Consider |
|
||||||
|
|-----------|----------|
|
||||||
|
| Long lists with scroll jank | Virtualized list libraries (e.g. FlashList) |
|
||||||
|
| Want Tailwind-style classes | NativeWind v4 |
|
||||||
|
| High-frequency storage reads | Sync-based storage (e.g. MMKV) |
|
||||||
|
| New project with Expo | Expo Router over bare React Navigation |
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
| State Type | Solution |
|
||||||
|
|------------|----------|
|
||||||
|
| Local UI state | `useState` / `useReducer` |
|
||||||
|
| Shared app state | Zustand or Jotai |
|
||||||
|
| Server / async data | React Query |
|
||||||
|
| Form state | React Hook Form + Zod |
|
||||||
|
|
||||||
|
### Performance Priorities
|
||||||
|
|
||||||
|
| Priority | Issue | Fix |
|
||||||
|
|----------|-------|-----|
|
||||||
|
| CRITICAL | Long list jank | `FlashList` + memoized items |
|
||||||
|
| CRITICAL | Large bundle | Avoid barrel imports, enable R8 |
|
||||||
|
| HIGH | Too many re-renders | Zustand selectors, React Compiler |
|
||||||
|
| HIGH | Slow startup | Disable bundle compression, native nav |
|
||||||
|
| MEDIUM | Animation drops | Only animate `transform`/`opacity` |
|
||||||
|
|
||||||
|
## New Project Init
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create project
|
||||||
|
npx create-expo-app@latest my-app --template blank-typescript
|
||||||
|
cd my-app
|
||||||
|
|
||||||
|
# 2. Install Expo Router + core deps
|
||||||
|
npx expo install expo-router react-native-safe-area-context react-native-screens
|
||||||
|
|
||||||
|
# 3. (Optional) Common extras
|
||||||
|
npx expo install expo-image react-native-reanimated react-native-gesture-handler
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure:
|
||||||
|
|
||||||
|
1. Set entry point in `package.json`: `"main": "expo-router/entry"`
|
||||||
|
2. Add scheme in `app.json`: `"scheme": "my-app"`
|
||||||
|
3. Delete `App.tsx` and `index.ts`
|
||||||
|
4. Create `app/_layout.tsx` as root Stack layout
|
||||||
|
5. Create `app/(tabs)/_layout.tsx` for tab navigation
|
||||||
|
6. Create route files in `app/(tabs)/` (see [navigation.md](references/navigation.md))
|
||||||
|
|
||||||
|
For web support, also install: `npx expo install react-native-web react-dom @expo/metro-runtime`
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
**Consult references before writing**: when implementing navigation, lists, networking, or project setup, read the matching reference file above for patterns and pitfalls.
|
||||||
|
|
||||||
|
**Try Expo Go first** (`npx expo start`). Custom builds (`eas build`) only needed when using local Expo modules, Apple targets, or third-party native modules not in Expo Go.
|
||||||
|
|
||||||
|
**Conditional rendering**: use `{count > 0 && <Text />}` not `{count && <Text />}` (renders "0").
|
||||||
|
|
||||||
|
**Animation rule**: only animate `transform` and `opacity` — GPU-composited, no layout thrash.
|
||||||
|
|
||||||
|
**Imports**: always import directly from source, not barrel files — avoids bundle bloat.
|
||||||
|
|
||||||
|
**Lists and images**: before using `FlatList` or RN `Image`, check the Component Preferences table above — `FlashList` and `expo-image` are almost always the right choice.
|
||||||
|
|
||||||
|
**Route files**: always use kebab-case, never co-locate components/types/utils in `app/`.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
### New Project Setup
|
||||||
|
- [ ] `tsconfig.json` path aliases configured
|
||||||
|
- [ ] `EXPO_PUBLIC_API_URL` env var set per environment
|
||||||
|
- [ ] Root layout has `GestureHandlerRootView` (if using gestures)
|
||||||
|
- [ ] `contentInsetAdjustmentBehavior="automatic"` on all scroll views
|
||||||
|
- [ ] `FlashList` instead of `FlatList` for lists > 20 items
|
||||||
|
|
||||||
|
### Before Shipping
|
||||||
|
- [ ] Profile in `--profile` mode, fix frames > 16ms
|
||||||
|
- [ ] Bundle analyzed (`source-map-explorer`), no barrel imports
|
||||||
|
- [ ] R8 enabled for Android
|
||||||
|
- [ ] Unit + component tests for critical paths
|
||||||
|
- [ ] E2E flows for login, core feature, checkout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Flutter development → see `flutter-dev` skill.
|
||||||
|
iOS native (UIKit/SwiftUI) → see `ios-application-dev` skill.
|
||||||
|
Android native (Kotlin/Compose) → see `android-native-dev` skill.
|
||||||
|
|
||||||
|
*React Native is a trademark of Meta Platforms, Inc. Expo is a trademark of 650 Industries, Inc. All other product names are trademarks of their respective owners.*
|
||||||
254
skills/react-native-dev/references/animations.md
Normal file
254
skills/react-native-dev/references/animations.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# Animations Reference
|
||||||
|
|
||||||
|
Reanimated 3 animations, gestures, and transitions for Expo/React Native.
|
||||||
|
|
||||||
|
## Core Rules
|
||||||
|
|
||||||
|
- **Only animate `transform` and `opacity`** — GPU-composited, no layout recalculation
|
||||||
|
- Use `useDerivedValue` for computed animated values, not inline JS expressions
|
||||||
|
- Use `Gesture.Tap` instead of `Pressable` inside `GestureDetector`
|
||||||
|
- All Reanimated callbacks run as worklets on the UI thread — no async/await
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install react-native-reanimated react-native-gesture-handler
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// babel.config.js
|
||||||
|
module.exports = { presets: ["babel-preset-expo"], plugins: ["react-native-reanimated/plugin"] };
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx — wrap root in GestureHandlerRootView
|
||||||
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
|
export default function RootLayout() {
|
||||||
|
return <GestureHandlerRootView style={{ flex: 1 }}><Stack /></GestureHandlerRootView>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entering / Exiting Animations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Animated, {
|
||||||
|
FadeIn, FadeOut,
|
||||||
|
SlideInRight, SlideOutLeft,
|
||||||
|
ZoomIn, ZoomOut,
|
||||||
|
BounceIn,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
// Basic
|
||||||
|
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
// With options
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(300).delay(100)}
|
||||||
|
exiting={SlideOutLeft.duration(200)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Spring-based
|
||||||
|
<Animated.View entering={ZoomIn.springify().damping(15)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Built-in Presets
|
||||||
|
|
||||||
|
| Category | Entering | Exiting |
|
||||||
|
|----------|----------|---------|
|
||||||
|
| Fade | `FadeIn`, `FadeInUp`, `FadeInDown`, `FadeInLeft`, `FadeInRight` | `FadeOut*` |
|
||||||
|
| Slide | `SlideInUp`, `SlideInDown`, `SlideInLeft`, `SlideInRight` | `SlideOut*` |
|
||||||
|
| Zoom | `ZoomIn`, `ZoomInUp`, `ZoomInDown` | `ZoomOut*` |
|
||||||
|
| Bounce | `BounceIn`, `BounceInUp`, `BounceInDown` | `BounceOut*` |
|
||||||
|
| Flip | `FlipInXUp`, `FlipInYLeft` | `FlipOut*` |
|
||||||
|
| Roll | `RollInLeft`, `RollInRight` | `RollOut*` |
|
||||||
|
| Stretch | `StretchInX`, `StretchInY` | `StretchOut*` |
|
||||||
|
| Pinwheel | `PinwheelIn` | `PinwheelOut` |
|
||||||
|
| Rotate | `RotateInDownLeft` | `RotateOut*` |
|
||||||
|
| LightSpeed | `LightSpeedInLeft` | `LightSpeedOut*` |
|
||||||
|
|
||||||
|
## Shared Values & useAnimatedStyle
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withSpring,
|
||||||
|
withTiming,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
|
Easing,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
const offset = useSharedValue(0);
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
|
||||||
|
const animStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: offset.value }],
|
||||||
|
opacity: opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Animate
|
||||||
|
offset.value = withSpring(100);
|
||||||
|
opacity.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||||
|
|
||||||
|
// Repeat
|
||||||
|
opacity.value = withRepeat(withTiming(0.3, { duration: 800 }), -1, true);
|
||||||
|
|
||||||
|
// Sequence
|
||||||
|
offset.value = withSequence(
|
||||||
|
withTiming(-10, { duration: 100 }),
|
||||||
|
withTiming(10, { duration: 100 }),
|
||||||
|
withSpring(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
<Animated.View style={animStyle} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## useDerivedValue
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDerivedValue } from "react-native-reanimated";
|
||||||
|
|
||||||
|
const progress = useSharedValue(0); // 0–1
|
||||||
|
const rotation = useDerivedValue(() => `${progress.value * 360}deg`);
|
||||||
|
const scale = useDerivedValue(() => 0.5 + progress.value * 0.5);
|
||||||
|
|
||||||
|
const animStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: rotation.value }, { scale: scale.value }],
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout Animations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Layout, LinearTransition, CurvedTransition } from "react-native-reanimated";
|
||||||
|
|
||||||
|
// Item reorder/add/remove animation
|
||||||
|
<Animated.View layout={LinearTransition}>
|
||||||
|
{/* Content that changes size/position */}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
// Spring layout transition
|
||||||
|
<Animated.View layout={LinearTransition.springify()}>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gestures
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
|
import { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
|
||||||
|
|
||||||
|
// Pan gesture
|
||||||
|
const offsetX = useSharedValue(0);
|
||||||
|
const offsetY = useSharedValue(0);
|
||||||
|
|
||||||
|
const panGesture = Gesture.Pan()
|
||||||
|
.onUpdate((e) => {
|
||||||
|
offsetX.value = e.translationX;
|
||||||
|
offsetY.value = e.translationY;
|
||||||
|
})
|
||||||
|
.onEnd(() => {
|
||||||
|
offsetX.value = withSpring(0);
|
||||||
|
offsetY.value = withSpring(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const animStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateX: offsetX.value }, { translateY: offsetY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
<GestureDetector gesture={panGesture}>
|
||||||
|
<Animated.View style={animStyle} />
|
||||||
|
</GestureDetector>
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Tap gesture (use instead of Pressable inside GestureDetector)
|
||||||
|
const tapGesture = Gesture.Tap()
|
||||||
|
.numberOfTaps(1)
|
||||||
|
.onEnd(() => {
|
||||||
|
scale.value = withSequence(withTiming(0.95), withSpring(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pinch gesture
|
||||||
|
const baseScale = useSharedValue(1);
|
||||||
|
const savedScale = useSharedValue(1);
|
||||||
|
|
||||||
|
const pinchGesture = Gesture.Pinch()
|
||||||
|
.onUpdate((e) => { baseScale.value = savedScale.value * e.scale; })
|
||||||
|
.onEnd(() => { savedScale.value = baseScale.value; });
|
||||||
|
|
||||||
|
// Composed gestures
|
||||||
|
const composed = Gesture.Simultaneous(panGesture, pinchGesture);
|
||||||
|
const exclusive = Gesture.Exclusive(tapGesture, panGesture);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scroll-Driven Animations
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedScrollHandler,
|
||||||
|
useSharedValue,
|
||||||
|
interpolate,
|
||||||
|
Extrapolation,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
|
||||||
|
const scrollY = useSharedValue(0);
|
||||||
|
const scrollHandler = useAnimatedScrollHandler((e) => {
|
||||||
|
scrollY.value = e.contentOffset.y;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parallax header
|
||||||
|
const headerStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{
|
||||||
|
translateY: interpolate(scrollY.value, [0, 200], [0, -100], Extrapolation.CLAMP),
|
||||||
|
}],
|
||||||
|
opacity: interpolate(scrollY.value, [0, 200], [1, 0], Extrapolation.CLAMP),
|
||||||
|
}));
|
||||||
|
|
||||||
|
<Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16}>
|
||||||
|
<Animated.View style={headerStyle}>
|
||||||
|
<Text>Parallax Header</Text>
|
||||||
|
</Animated.View>
|
||||||
|
</Animated.ScrollView>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zoom Transitions (Expo Router, iOS 18+)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
|
||||||
|
<Link href="/detail" asChild>
|
||||||
|
<Link.AppleZoom>
|
||||||
|
<Pressable>
|
||||||
|
<Image source={thumbnail} />
|
||||||
|
</Pressable>
|
||||||
|
</Link.AppleZoom>
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding Animations to State Changes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✓ Always add entering/exiting for state-driven UI changes
|
||||||
|
{isVisible && (
|
||||||
|
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)}>
|
||||||
|
<Toast message={message} />
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
// ✓ AnimatedFlatList for list item changes
|
||||||
|
import Animated from "react-native-reanimated";
|
||||||
|
const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Wrong | Right |
|
||||||
|
|-------|-------|
|
||||||
|
| Animate `width`/`height` | Animate `transform: scaleX/scaleY` |
|
||||||
|
| Inline JS math in `useAnimatedStyle` | `useDerivedValue` for computations |
|
||||||
|
| `Pressable` inside `GestureDetector` | `Gesture.Tap()` |
|
||||||
|
| `async` in worklet | Run async outside, update sharedValue in callback |
|
||||||
|
| Frequent `console.log` in worklet | `console.log` works but serializes to JS thread — use sparingly in hot paths |
|
||||||
124
skills/react-native-dev/references/components.md
Normal file
124
skills/react-native-dev/references/components.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Components Reference
|
||||||
|
|
||||||
|
Native UI components, media, visual effects, and storage patterns for Expo/React Native.
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
|
||||||
|
// Always use expo-image — not React Native's built-in Image
|
||||||
|
<Image
|
||||||
|
source={{ uri: "https://example.com/photo.jpg" }}
|
||||||
|
style={{ width: 200, height: 200 }}
|
||||||
|
contentFit="cover"
|
||||||
|
transition={300}
|
||||||
|
placeholder={blurhash}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lists
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
const Item = memo(({ title }: { title: string }) => (
|
||||||
|
<View style={styles.item}><Text>{title}</Text></View>
|
||||||
|
));
|
||||||
|
|
||||||
|
<FlashList
|
||||||
|
data={items}
|
||||||
|
renderItem={({ item }) => <Item title={item.title} />}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
estimatedItemSize={80}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Safe Areas
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
|
||||||
|
// With ScrollView
|
||||||
|
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||||
|
{/* content */}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
// Manual insets
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
<View style={{ paddingBottom: insets.bottom }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native Controls (iOS)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Switch } from "react-native";
|
||||||
|
import SegmentedControl from "@react-native-segmented-control/segmented-control";
|
||||||
|
|
||||||
|
// Switch
|
||||||
|
<Switch value={enabled} onValueChange={setEnabled} />
|
||||||
|
|
||||||
|
// Segmented Control
|
||||||
|
<SegmentedControl
|
||||||
|
values={["Day", "Week", "Month"]}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onChange={(e) => setSelectedIndex(e.nativeEvent.selectedSegmentIndex)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Sheets (Bottom Sheet)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/modal.tsx
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
<Stack.Screen options={{
|
||||||
|
presentation: "formSheet",
|
||||||
|
sheetAllowedDetents: [0.5, 1.0],
|
||||||
|
sheetGrabberVisible: true,
|
||||||
|
}} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Visual Effects
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
|
<BlurView intensity={80} tint="light" style={StyleSheet.absoluteFill} />
|
||||||
|
|
||||||
|
// Liquid glass (iOS 26+, New Architecture only)
|
||||||
|
import { GlassEffect } from "expo-glass-effect";
|
||||||
|
<GlassEffect style={{ borderRadius: 16, padding: 20 }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Using expo-router search bar (iOS only)
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
placeholder: "Search...",
|
||||||
|
onChangeText: (e) => setQuery(e.nativeEvent.text),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [navigation]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
| Need | Solution |
|
||||||
|
|------|----------|
|
||||||
|
| Structured data | `expo-sqlite` |
|
||||||
|
| Simple key-value | `@react-native-async-storage/async-storage` |
|
||||||
|
| Sensitive data | `expo-secure-store` |
|
||||||
|
|
||||||
|
## Media
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
|
import { useAudioPlayer } from "expo-audio";
|
||||||
|
import { useVideoPlayer, VideoView } from "expo-video";
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
```
|
||||||
527
skills/react-native-dev/references/engineering.md
Normal file
527
skills/react-native-dev/references/engineering.md
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
# Engineering Reference
|
||||||
|
|
||||||
|
Project structure, tooling, builds, releases, and platform integration for Expo / React Native.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Standard Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
my-app/
|
||||||
|
app/ File-based routing (Expo Router)
|
||||||
|
_layout.tsx Root layout: providers, fonts, NativeTabs
|
||||||
|
index.tsx → /
|
||||||
|
(tabs)/
|
||||||
|
_layout.tsx Tab navigator
|
||||||
|
home.tsx → /home
|
||||||
|
profile.tsx → /profile
|
||||||
|
(auth)/
|
||||||
|
login.tsx → /login (group, not in URL)
|
||||||
|
register.tsx → /register
|
||||||
|
user/
|
||||||
|
[id].tsx → /user/:id
|
||||||
|
[id]/
|
||||||
|
posts.tsx → /user/:id/posts
|
||||||
|
api/
|
||||||
|
users+api.ts → /api/users (server route)
|
||||||
|
users/[id]+api.ts → /api/users/:id
|
||||||
|
components/ Reusable UI components
|
||||||
|
ui/ Primitive components (Button, Input, Card)
|
||||||
|
shared/ Composed components (UserAvatar, PostCard)
|
||||||
|
hooks/ Custom React hooks
|
||||||
|
stores/ Zustand / Jotai stores
|
||||||
|
services/ API client, external service wrappers
|
||||||
|
utils/ Pure utility functions
|
||||||
|
constants/ App-wide constants (colors, spacing, config)
|
||||||
|
types/ Shared TypeScript types/interfaces
|
||||||
|
assets/ Static assets (images, fonts, icons)
|
||||||
|
scripts/ Build/dev helper scripts
|
||||||
|
app.json Expo config
|
||||||
|
eas.json EAS Build config
|
||||||
|
tsconfig.json TypeScript config with path aliases
|
||||||
|
.env Environment variables
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Conventions
|
||||||
|
|
||||||
|
| File | Route | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `app/index.tsx` | `/` | Home/root |
|
||||||
|
| `app/about.tsx` | `/about` | Static route |
|
||||||
|
| `app/user/[id].tsx` | `/user/:id` | Dynamic segment |
|
||||||
|
| `app/user/[...rest].tsx` | `/user/*` | Catch-all |
|
||||||
|
| `app/(tabs)/home.tsx` | `/home` | Group (not in URL) |
|
||||||
|
| `app/(a,b)/shared.tsx` | Shared between tabs `a` and `b` | Multi-group |
|
||||||
|
| `app/_layout.tsx` | Layout wrapper | No route |
|
||||||
|
| `app/+not-found.tsx` | 404 page | |
|
||||||
|
| `app/api/users+api.ts` | `/api/users` | Server route |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- Routes only in `app/` — no components, types, or utils
|
||||||
|
- Always have a route matching `/`
|
||||||
|
- Use kebab-case filenames (`user-profile.tsx`, not `UserProfile.tsx`)
|
||||||
|
- Remove old route files when restructuring
|
||||||
|
|
||||||
|
### Path Aliases
|
||||||
|
|
||||||
|
```json
|
||||||
|
// tsconfig.json
|
||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"],
|
||||||
|
"@components/*": ["./components/*"],
|
||||||
|
"@hooks/*": ["./hooks/*"],
|
||||||
|
"@stores/*": ["./stores/*"],
|
||||||
|
"@services/*": ["./services/*"],
|
||||||
|
"@utils/*": ["./utils/*"],
|
||||||
|
"@constants/*": ["./constants/*"],
|
||||||
|
"@types/*": ["./types/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✗ Relative imports — fragile, change with file moves
|
||||||
|
import { Button } from "../../../components/ui/Button";
|
||||||
|
|
||||||
|
// ✓ Alias imports — stable
|
||||||
|
import { Button } from "@components/ui/Button";
|
||||||
|
```
|
||||||
|
|
||||||
|
Metro resolves `paths` and `baseUrl` from `tsconfig.json` natively — no extra config needed. If using a non-Metro bundler, install `babel-plugin-module-resolver`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// babel.config.js — only needed for non-Metro bundlers
|
||||||
|
module.exports = {
|
||||||
|
presets: ["babel-preset-expo"],
|
||||||
|
plugins: [
|
||||||
|
["module-resolver", {
|
||||||
|
root: ["./"],
|
||||||
|
alias: {
|
||||||
|
"@": "./",
|
||||||
|
"@components": "./components",
|
||||||
|
"@hooks": "./hooks",
|
||||||
|
"@stores": "./stores",
|
||||||
|
"@services": "./services",
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
ui/ Atomic components
|
||||||
|
Button.tsx
|
||||||
|
Input.tsx
|
||||||
|
Card.tsx
|
||||||
|
Badge.tsx
|
||||||
|
index.ts Barrel export
|
||||||
|
shared/ Composed components
|
||||||
|
UserAvatar.tsx
|
||||||
|
PostCard.tsx
|
||||||
|
EmptyState.tsx
|
||||||
|
layout/ Layout components
|
||||||
|
Screen.tsx SafeArea wrapper
|
||||||
|
Header.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/ui/index.ts — barrel export
|
||||||
|
export { Button } from "./Button";
|
||||||
|
export { Input } from "./Input";
|
||||||
|
export { Card } from "./Card";
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
import { Button, Input, Card } from "@components/ui";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design System
|
||||||
|
|
||||||
|
```
|
||||||
|
constants/
|
||||||
|
colors.ts Color palette + semantic colors
|
||||||
|
spacing.ts 8pt grid spacing values
|
||||||
|
typography.ts Font families, sizes, weights
|
||||||
|
theme.ts Combined theme object
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// constants/colors.ts
|
||||||
|
export const colors = {
|
||||||
|
primary: "#6200EE",
|
||||||
|
secondary: "#03DAC6",
|
||||||
|
background: "#FFFFFF",
|
||||||
|
surface: "#F5F5F5",
|
||||||
|
error: "#B00020",
|
||||||
|
text: { primary: "#000000DE", secondary: "#0000008A" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// constants/spacing.ts — 8pt grid
|
||||||
|
export const spacing = {
|
||||||
|
xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// constants/typography.ts
|
||||||
|
export const typography = {
|
||||||
|
sizes: { xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 },
|
||||||
|
weights: { regular: "400", medium: "500", semibold: "600", bold: "700" },
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Services Layer
|
||||||
|
|
||||||
|
```
|
||||||
|
services/
|
||||||
|
api/
|
||||||
|
client.ts Base fetch client with auth headers
|
||||||
|
users.ts User-related API calls
|
||||||
|
posts.ts Post-related API calls
|
||||||
|
storage/
|
||||||
|
secure-store.ts Wrapper for expo-secure-store
|
||||||
|
async-storage.ts Wrapper for AsyncStorage
|
||||||
|
notifications/
|
||||||
|
push.ts Expo push notification helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// services/api/client.ts
|
||||||
|
const BASE_URL = process.env.EXPO_PUBLIC_API_URL!;
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T,>(path: string, token?: string) =>
|
||||||
|
fetch(`${BASE_URL}${path}`, {
|
||||||
|
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||||
|
}).then(async (r) => {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.json() as Promise<T>;
|
||||||
|
}),
|
||||||
|
// post/put/delete follow same pattern — add method, Content-Type, JSON.stringify(body)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monorepo
|
||||||
|
|
||||||
|
```
|
||||||
|
my-monorepo/
|
||||||
|
apps/
|
||||||
|
mobile/ Expo app (all native deps here)
|
||||||
|
package.json
|
||||||
|
app.json
|
||||||
|
web/ Next.js app
|
||||||
|
package.json
|
||||||
|
packages/
|
||||||
|
ui/ Shared UI components (no native deps)
|
||||||
|
package.json
|
||||||
|
utils/ Shared utilities (no native deps)
|
||||||
|
package.json
|
||||||
|
types/ Shared TypeScript types
|
||||||
|
package.json
|
||||||
|
package.json Root workspace config
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Root package.json
|
||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"workspaces": ["apps/*", "packages/*"],
|
||||||
|
"scripts": {
|
||||||
|
"mobile": "yarn workspace @my/mobile start",
|
||||||
|
"web": "yarn workspace @my/web dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Monorepo rules**:
|
||||||
|
- **Keep native dependencies in the app package** (`apps/mobile`) — never in shared packages
|
||||||
|
- Use a single version of each dependency across all packages
|
||||||
|
- Shared packages should be pure JS/TS only
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env (committed, non-sensitive defaults)
|
||||||
|
EXPO_PUBLIC_APP_NAME=MyApp
|
||||||
|
EXPO_PUBLIC_API_VERSION=v1
|
||||||
|
|
||||||
|
# .env.development (local only, gitignored)
|
||||||
|
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# .env.production (CI/CD only, gitignored)
|
||||||
|
EXPO_PUBLIC_API_URL=https://api.production.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// types/env.d.ts
|
||||||
|
declare global {
|
||||||
|
namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
EXPO_PUBLIC_API_URL: string;
|
||||||
|
EXPO_PUBLIC_APP_NAME: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Fonts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install expo-font
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// app.json — config plugin (preferred over manual linking)
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"plugins": [
|
||||||
|
["expo-font", { "fonts": ["./assets/fonts/Inter-Regular.ttf"] }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx
|
||||||
|
import { useFonts } from "expo-font";
|
||||||
|
import { SplashScreen } from "expo-router";
|
||||||
|
|
||||||
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const [loaded] = useFonts({ "Inter-Regular": require("../assets/fonts/Inter-Regular.ttf") });
|
||||||
|
useEffect(() => { if (loaded) SplashScreen.hideAsync(); }, [loaded]);
|
||||||
|
if (!loaded) return null;
|
||||||
|
return <Stack />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Builds
|
||||||
|
|
||||||
|
Expo Go (`npx expo start`) covers most use cases out of the box. Switch to a custom dev client when your project uses native code that Expo Go doesn't bundle — for example, a local Expo module in `modules/`, an Apple target (widget, app clip), or a community native library that isn't pre-installed in Expo Go.
|
||||||
|
|
||||||
|
### Creating a Dev Client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option A — cloud build, push to TestFlight / internal distribution
|
||||||
|
eas build -p ios --profile development --submit
|
||||||
|
|
||||||
|
# Option B — build locally (requires Xcode / Android Studio)
|
||||||
|
eas build -p ios --profile development --local
|
||||||
|
```
|
||||||
|
|
||||||
|
After installing on the device or simulator, connect with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo start --dev-client
|
||||||
|
```
|
||||||
|
|
||||||
|
### eas.json Profile
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Upgrading the SDK
|
||||||
|
|
||||||
|
### Routine Upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install expo@latest --fix # bumps Expo + aligns peer deps
|
||||||
|
npx expo-doctor # surfaces remaining mismatches
|
||||||
|
```
|
||||||
|
|
||||||
|
Then test on both platforms and rebuild the dev client if you use one.
|
||||||
|
|
||||||
|
### Trying a Pre-release
|
||||||
|
|
||||||
|
Pre-release versions are tagged `@next` on npm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install expo@next --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notable Changes Across SDK Versions
|
||||||
|
|
||||||
|
| Version | What Changed |
|
||||||
|
|---------|-------------|
|
||||||
|
| SDK 53 | New Architecture on by default; Expo Go requires it; `autoprefixer` no longer needed |
|
||||||
|
| SDK 54 | React 19 (`use()` replaces `useContext`, `<Context>` replaces `<Context.Provider>`, `forwardRef` removed); React Compiler available; `EXPO_USE_FAST_RESOLVER` removed |
|
||||||
|
| SDK 55 | NativeTabs API updated — Icon/Label/Badge accessed via `NativeTabs.Trigger.*` |
|
||||||
|
| Ongoing | `expo-av` deprecated in favor of `expo-audio` + `expo-video` |
|
||||||
|
|
||||||
|
### React 19 Patterns (SDK 54+)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Context
|
||||||
|
import { use, createContext } from "react";
|
||||||
|
const ThemeCtx = createContext("light");
|
||||||
|
// consume: const theme = use(ThemeCtx);
|
||||||
|
// provide: <ThemeCtx value="dark">...</ThemeCtx>
|
||||||
|
|
||||||
|
// Refs — no more forwardRef
|
||||||
|
function Field({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
|
||||||
|
return <TextInput ref={ref} {...props} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opting Out of New Architecture
|
||||||
|
|
||||||
|
If a third-party library breaks under the New Architecture:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "expo": { "newArchEnabled": false } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Check compatibility at [reactnative.directory](https://reactnative.directory).
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
### Build Profiles
|
||||||
|
|
||||||
|
A typical `eas.json` has three tiers:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cli": { "version": ">= 16.0.1", "appVersionSource": "remote" },
|
||||||
|
"build": {
|
||||||
|
"development": { "developmentClient": true, "distribution": "internal", "autoIncrement": true },
|
||||||
|
"preview": { "distribution": "internal", "autoIncrement": true },
|
||||||
|
"production": { "autoIncrement": true, "ios": { "resourceClass": "m-medium" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building & Submitting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for both platforms
|
||||||
|
eas build -p ios --profile production
|
||||||
|
eas build -p android --profile production
|
||||||
|
|
||||||
|
# Build + submit in one step
|
||||||
|
eas build -p ios --profile production --submit
|
||||||
|
|
||||||
|
# Or submit a finished build separately
|
||||||
|
eas submit -p ios
|
||||||
|
eas submit -p android
|
||||||
|
```
|
||||||
|
|
||||||
|
### Store Submission Notes
|
||||||
|
|
||||||
|
**iOS** — Run `eas credentials` to set up signing. Create the app record in App Store Connect, fill metadata, then `--submit` pushes the build to TestFlight automatically.
|
||||||
|
|
||||||
|
**Android** — Create a Google Play service account, download its JSON key, and reference it in `eas.json` under `submit.production.android.serviceAccountKeyPath`. The first build must be uploaded manually through Play Console; subsequent builds use `eas submit`.
|
||||||
|
|
||||||
|
### Over-the-Air Updates
|
||||||
|
|
||||||
|
For JS-only changes (no new native code), skip the full build/review cycle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install expo-updates
|
||||||
|
eas update --branch production --message "Fix checkout rounding error"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Hosting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo export -p web
|
||||||
|
eas deploy # preview URL
|
||||||
|
eas deploy --prod # production
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD with EAS Workflows
|
||||||
|
|
||||||
|
Workflow files live in `.eas/workflows/` and follow a YAML schema:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .eas/workflows/release.yml
|
||||||
|
name: Release to stores
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
type: build
|
||||||
|
params:
|
||||||
|
platform: all
|
||||||
|
profile: production
|
||||||
|
|
||||||
|
submit:
|
||||||
|
type: submit
|
||||||
|
needs: [build]
|
||||||
|
params:
|
||||||
|
platform: all
|
||||||
|
profile: production
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .eas/workflows/pr-check.yml
|
||||||
|
name: PR check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
preview-build:
|
||||||
|
type: build
|
||||||
|
params:
|
||||||
|
platform: all
|
||||||
|
profile: preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## DOM Components
|
||||||
|
|
||||||
|
The `"use dom"` directive lets you render web-only code inside a WebView on native while running it as standard DOM on web. Useful for libraries that depend on browser APIs (chart libraries, rich text editors, syntax highlighters).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/RichPreview.tsx
|
||||||
|
"use dom";
|
||||||
|
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
export default function RichPreview({ markdown }: { markdown: string }) {
|
||||||
|
return <ReactMarkdown>{markdown}</ReactMarkdown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/note/[id].tsx — native screen
|
||||||
|
import RichPreview from "@/components/RichPreview";
|
||||||
|
|
||||||
|
export default function NoteScreen() {
|
||||||
|
const { content } = useNote();
|
||||||
|
return (
|
||||||
|
<ScrollView>
|
||||||
|
<RichPreview markdown={content} />
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- `"use dom"` must be the first statement in the file
|
||||||
|
- One default export per file; cannot be mixed with native components
|
||||||
|
- Props must be serializable (strings, numbers, booleans, plain objects/arrays)
|
||||||
|
- Async function props bridge native actions into the webview (e.g., `onSave: (data) => Promise<void>`)
|
||||||
|
- Cannot be used in `_layout.tsx` files
|
||||||
|
- Router hooks that read native navigation state (`useLocalSearchParams`, `usePathname`, etc.) must be called in the native parent and passed as props
|
||||||
300
skills/react-native-dev/references/forms.md
Normal file
300
skills/react-native-dev/references/forms.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# Forms Reference
|
||||||
|
|
||||||
|
React Hook Form + Zod validation for React Native / Expo.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install react-hook-form zod @hookform/resolvers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Basic Form
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.string().email("Invalid email"),
|
||||||
|
password: z.string().min(8, "Min 8 characters"),
|
||||||
|
});
|
||||||
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export function LoginForm({ onSubmit }: { onSubmit: (data: FormData) => void }) {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { email: "", password: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{/* Controller pattern — repeat for each field */}
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field: { onChange, onBlur, value } }) => (
|
||||||
|
<TextInput value={value} onChangeText={onChange} onBlur={onBlur}
|
||||||
|
placeholder="Email" keyboardType="email-address" autoCapitalize="none" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.email && <Text style={styles.error}>{errors.email.message}</Text>}
|
||||||
|
|
||||||
|
{/* Same Controller pattern for password, with secureTextEntry */}
|
||||||
|
|
||||||
|
<Pressable onPress={handleSubmit(onSubmit)} disabled={isSubmitting}>
|
||||||
|
<Text>{isSubmitting ? "Submitting..." : "Login"}</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zod Schema Patterns
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Registration form
|
||||||
|
const registerSchema = z.object({
|
||||||
|
name: z.string().min(2, "Name must be at least 2 characters").max(50),
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string()
|
||||||
|
.min(8, "At least 8 characters")
|
||||||
|
.regex(/[A-Z]/, "Must contain uppercase letter")
|
||||||
|
.regex(/[0-9]/, "Must contain a number"),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
age: z.number({ invalid_type_error: "Age must be a number" }).int().min(18, "Must be 18+").optional(),
|
||||||
|
role: z.enum(["admin", "user", "guest"]),
|
||||||
|
agreedToTerms: z.literal(true, { errorMap: () => ({ message: "Must agree to terms" }) }),
|
||||||
|
}).refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// All-optional schema — use .optional() or .partial()
|
||||||
|
const profileSchema = registerSchema.pick({ name: true, email: true }).partial();
|
||||||
|
|
||||||
|
// Nested objects — compose schemas with z.array() and references
|
||||||
|
const addressSchema = z.object({ street: z.string().min(1), city: z.string().min(1), country: z.string().length(2) });
|
||||||
|
const orderSchema = z.object({ items: z.array(z.object({ productId: z.string(), quantity: z.number().int().positive() })).min(1), shippingAddress: addressSchema });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
reset,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
formState: {
|
||||||
|
errors,
|
||||||
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
|
isDirty, // Any field changed from defaultValues
|
||||||
|
dirtyFields, // Which fields changed
|
||||||
|
touchedFields, // Which fields were focused
|
||||||
|
},
|
||||||
|
} = useForm<FormData>({ resolver: zodResolver(schema) });
|
||||||
|
|
||||||
|
// Watch a field value
|
||||||
|
const password = watch("password");
|
||||||
|
const allValues = watch(); // Watch all
|
||||||
|
|
||||||
|
// Set a value programmatically
|
||||||
|
setValue("email", "prefilled@example.com", { shouldValidate: true });
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
reset(); // Back to defaultValues
|
||||||
|
reset({ email: "new@email.com" }); // Reset with new values
|
||||||
|
|
||||||
|
// Set server-side errors
|
||||||
|
setError("email", { message: "Email already in use" });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Async Submit with Error Handling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { handleSubmit, setError } = useForm<FormData>();
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
await api.post("/auth/register", data);
|
||||||
|
router.replace("/home");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 409) {
|
||||||
|
setError("email", { message: "Email already registered" });
|
||||||
|
} else {
|
||||||
|
setError("root", { message: "Something went wrong. Please try again." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display root error
|
||||||
|
{errors.root && <Text style={styles.rootError}>{errors.root.message}</Text>}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Step Forms
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const schema = z.object({
|
||||||
|
step1: z.object({ name: z.string().min(1), email: z.string().email() }),
|
||||||
|
step2: z.object({ phone: z.string(), address: z.string() }),
|
||||||
|
step3: z.object({ password: z.string().min(8), confirmPassword: z.string() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export function MultiStepForm() {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const { control, handleSubmit, trigger, formState: { errors } } = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextStep = async () => {
|
||||||
|
const stepKey = `step${step}` as keyof FormData;
|
||||||
|
const valid = await trigger(stepKey); // Validate only current step's fields
|
||||||
|
if (valid) setStep(s => s + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render step component by index, with Back/Next/Submit navigation
|
||||||
|
// Key pattern: trigger(stepKey) validates only current step before advancing
|
||||||
|
return (/* StepOne | StepTwo | StepThree + Back/Next/Submit buttons */);
|
||||||
|
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reusable Field Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/ui/FormField.tsx
|
||||||
|
import { Controller, Control, FieldValues, Path } from "react-hook-form";
|
||||||
|
|
||||||
|
interface FormFieldProps<T extends FieldValues> {
|
||||||
|
control: Control<T>;
|
||||||
|
name: Path<T>;
|
||||||
|
label: string;
|
||||||
|
placeholder?: string;
|
||||||
|
secureTextEntry?: boolean;
|
||||||
|
keyboardType?: TextInputProps["keyboardType"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormField<T extends FieldValues>({
|
||||||
|
control, name, label, placeholder, secureTextEntry, keyboardType,
|
||||||
|
}: FormFieldProps<T>) {
|
||||||
|
// Wraps Controller with: label, styled TextInput, and error message display
|
||||||
|
// Uses fieldState.error for per-field error, accessibilityLabel for a11y
|
||||||
|
return (
|
||||||
|
<Controller control={control} name={name}
|
||||||
|
render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
|
||||||
|
<View>
|
||||||
|
<Text>{label}</Text>
|
||||||
|
<TextInput value={value} onChangeText={onChange} onBlur={onBlur}
|
||||||
|
placeholder={placeholder} secureTextEntry={secureTextEntry} keyboardType={keyboardType}
|
||||||
|
style={[styles.input, error && styles.inputError]} accessibilityLabel={label} />
|
||||||
|
{error && <Text style={styles.errorText}>{error.message}</Text>}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
<FormField control={control} name="email" label="Email" keyboardType="email-address" />
|
||||||
|
<FormField control={control} name="password" label="Password" secureTextEntry />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Arrays
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useFieldArray } from "react-hook-form";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
tags: z.array(z.object({ value: z.string().min(1) })).min(1, "Add at least one tag"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function TagsForm() {
|
||||||
|
const { control, handleSubmit } = useForm<z.infer<typeof schema>>();
|
||||||
|
const { fields, append, remove } = useFieldArray({ control, name: "tags" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<View key={field.id} style={{ flexDirection: "row" }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`tags.${index}.value`}
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<TextInput value={value} onChangeText={onChange} placeholder="Tag" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Pressable onPress={() => remove(index)}><Text>✕</Text></Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<Pressable onPress={() => append({ value: "" })}><Text>+ Add Tag</Text></Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keyboard Handling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { KeyboardAvoidingView, Platform, ScrollView } from "react-native";
|
||||||
|
|
||||||
|
export function FormScreen() {
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
|
keyboardShouldPersistTaps="handled" // Tapping outside keyboard doesn't dismiss
|
||||||
|
>
|
||||||
|
<LoginForm />
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Forms
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, fireEvent, waitFor, screen } from "@testing-library/react-native";
|
||||||
|
import { userEvent } from "@testing-library/react-native";
|
||||||
|
|
||||||
|
it("validates required fields", async () => {
|
||||||
|
render(<LoginForm onSubmit={jest.fn()} />);
|
||||||
|
fireEvent.press(screen.getByText("Login")); // Submit without filling
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Invalid email")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Min 8 characters")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits with valid data", async () => {
|
||||||
|
const onSubmit = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LoginForm onSubmit={onSubmit} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("Email"), "user@example.com");
|
||||||
|
await user.type(screen.getByPlaceholderText("Password"), "password123");
|
||||||
|
await user.press(screen.getByText("Login"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({ email: "user@example.com", password: "password123" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
163
skills/react-native-dev/references/native-capabilities.md
Normal file
163
skills/react-native-dev/references/native-capabilities.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Native Capabilities Reference
|
||||||
|
|
||||||
|
Camera, location, permissions, haptics, notifications, and biometrics for Expo/React Native.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
All Expo modules that need permissions expose a `use*Permissions()` hook. Follow this pattern:
|
||||||
|
|
||||||
|
1. Call the permission hook to get current status and a request function
|
||||||
|
2. Check `status` — if not `granted`, show a rationale and call `requestPermission()`
|
||||||
|
3. If the user denies twice, `canAskAgain` becomes `false` — direct them to Settings
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCameraPermissions } from "expo-camera";
|
||||||
|
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
|
||||||
|
if (!permission?.granted) {
|
||||||
|
// Show rationale, then call requestPermission()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Module | Permission Hook |
|
||||||
|
|--------|----------------|
|
||||||
|
| `expo-camera` | `useCameraPermissions()` |
|
||||||
|
| `expo-location` | `useForegroundPermissions()` / `useBackgroundPermissions()` |
|
||||||
|
| `expo-media-library` | `usePermissions()` |
|
||||||
|
| `expo-notifications` | `getPermissionsAsync()` / `requestPermissionsAsync()` |
|
||||||
|
| `expo-contacts` | `usePermissions()` |
|
||||||
|
|
||||||
|
For modules without a hook, use `requestPermissionsAsync()` / `getPermissionsAsync()` directly.
|
||||||
|
|
||||||
|
## Camera
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
|
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const cameraRef = useRef<CameraView>(null);
|
||||||
|
|
||||||
|
// Capture a photo
|
||||||
|
const photo = await cameraRef.current?.takePictureAsync();
|
||||||
|
|
||||||
|
// Toggle front/back
|
||||||
|
const [facing, setFacing] = useState<"front" | "back">("back");
|
||||||
|
```
|
||||||
|
|
||||||
|
For simple photo/video selection without a camera UI, use `expo-image-picker`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as ImagePicker from "expo-image-picker";
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ["images"],
|
||||||
|
allowsEditing: true,
|
||||||
|
quality: 0.8,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Location
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as Location from "expo-location";
|
||||||
|
|
||||||
|
// One-time location
|
||||||
|
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||||
|
if (status === "granted") {
|
||||||
|
const location = await Location.getCurrentPositionAsync({});
|
||||||
|
// location.coords.latitude, location.coords.longitude
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For background location tracking, request `requestBackgroundPermissionsAsync()` and register a background task. Background location requires the `location` background mode in `app.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"ios": { "infoPlist": { "UIBackgroundModes": ["location"] } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Haptics
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as Haptics from "expo-haptics";
|
||||||
|
|
||||||
|
// Light tap feedback (button press)
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
|
// Success / error / warning
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
|
||||||
|
// Selection change (picker scroll)
|
||||||
|
Haptics.selectionAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
| Style | When to Use |
|
||||||
|
|-------|-------------|
|
||||||
|
| `ImpactFeedbackStyle.Light` | Button taps, toggles |
|
||||||
|
| `ImpactFeedbackStyle.Medium` | Drag snaps, significant actions |
|
||||||
|
| `ImpactFeedbackStyle.Heavy` | Destructive actions, impacts |
|
||||||
|
| `NotificationFeedbackType.Success` | Task completed |
|
||||||
|
| `NotificationFeedbackType.Warning` | Attention needed |
|
||||||
|
| `NotificationFeedbackType.Error` | Action failed |
|
||||||
|
| `selectionAsync()` | Picker/slider value changes |
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
### Push Notifications (Expo)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as Notifications from "expo-notifications";
|
||||||
|
import * as Device from "expo-device";
|
||||||
|
|
||||||
|
async function registerForPushNotifications() {
|
||||||
|
if (!Device.isDevice) return; // Push doesn't work on simulators
|
||||||
|
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
if (status !== "granted") return;
|
||||||
|
|
||||||
|
const token = await Notifications.getExpoPushTokenAsync({
|
||||||
|
projectId: "your-project-id", // From app.json > extra > eas > projectId
|
||||||
|
});
|
||||||
|
// Send token.data to your server
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notification Handlers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for received/tapped notifications
|
||||||
|
const subscription = Notifications.addNotificationReceivedListener(notification => {
|
||||||
|
// Notification received while app is foregrounded
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Biometrics
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as LocalAuthentication from "expo-local-authentication";
|
||||||
|
|
||||||
|
const hasHardware = await LocalAuthentication.hasHardwareAsync();
|
||||||
|
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
|
||||||
|
|
||||||
|
if (hasHardware && isEnrolled) {
|
||||||
|
const result = await LocalAuthentication.authenticateAsync({
|
||||||
|
promptMessage: "Authenticate to continue",
|
||||||
|
fallbackLabel: "Use passcode",
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
// Authenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
271
skills/react-native-dev/references/navigation.md
Normal file
271
skills/react-native-dev/references/navigation.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# Navigation Reference
|
||||||
|
|
||||||
|
Expo Router file-based navigation: Stack, Tabs, modals, links, and context menus.
|
||||||
|
|
||||||
|
## File Conventions
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
_layout.tsx Root layout (providers, NativeTabs)
|
||||||
|
index.tsx → /
|
||||||
|
about.tsx → /about
|
||||||
|
user/
|
||||||
|
[id].tsx → /user/:id
|
||||||
|
[id]/
|
||||||
|
posts.tsx → /user/:id/posts
|
||||||
|
(tabs)/
|
||||||
|
_layout.tsx Tab navigator (group, not in URL)
|
||||||
|
home.tsx → /home
|
||||||
|
profile.tsx → /profile
|
||||||
|
(index,search)/
|
||||||
|
_layout.tsx Shared Stack for both tabs
|
||||||
|
index.tsx → /
|
||||||
|
search.tsx → /search
|
||||||
|
i/[id].tsx → /i/:id (shared detail screen)
|
||||||
|
api/
|
||||||
|
users+api.ts → /api/users (server route)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
- Routes live only in `app/` — never co-locate components, types, or utils there
|
||||||
|
- Always have a route matching `/` (may be inside a group)
|
||||||
|
- Remove old route files when restructuring navigation
|
||||||
|
- Use kebab-case filenames
|
||||||
|
|
||||||
|
## Root Layout (Stack)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx — root is always a Stack
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerTransparent: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerBackButtonDisplayMode: "minimal",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="user/[id]" options={{ headerLargeTitle: false }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Always set page title via `Stack.Screen options.title`**, never use a custom Text element as a title.
|
||||||
|
|
||||||
|
## Tabs — Which to Use
|
||||||
|
|
||||||
|
| Scenario | Use |
|
||||||
|
|----------|-----|
|
||||||
|
| Custom design system, cross-platform | **JS Tabs** (stable, fully customizable) |
|
||||||
|
| iOS-native look, Liquid Glass (iOS 26+) | **NativeTabs** (alpha, limited customization) |
|
||||||
|
|
||||||
|
## JS Tabs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(tabs)/_layout.tsx
|
||||||
|
import { Tabs } from "expo-router";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<Tabs screenOptions={{ tabBarActiveTintColor: "blue" }}>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="home"
|
||||||
|
options={{
|
||||||
|
tabBarLabel: "Home",
|
||||||
|
tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## NativeTabs (alpha, iOS 18+)
|
||||||
|
|
||||||
|
> Alpha API — all tabs render at once, limited customization, max 5 tabs on Android. Use when you want native iOS look (Liquid Glass, native blur/transitions) without rebuilding it yourself.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { NativeTabs } from "expo-router/unstable-native-tabs";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<NativeTabs>
|
||||||
|
<NativeTabs.Trigger name="(index)">
|
||||||
|
<NativeTabs.Trigger.Icon sf="house" />
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
<NativeTabs.Trigger name="(profile)">
|
||||||
|
<NativeTabs.Trigger.Icon sf="person" />
|
||||||
|
<NativeTabs.Trigger.Label>Profile</NativeTabs.Trigger.Label>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shared Stack for Multiple Tabs
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/(index,search)/_layout.tsx — shared Stack for both index and search tabs
|
||||||
|
import { Stack } from "expo-router/stack";
|
||||||
|
|
||||||
|
const tabLabels: Record<string, string> = { index: "Home", search: "Explore" };
|
||||||
|
|
||||||
|
export default function Layout({ segment }: { segment: string }) {
|
||||||
|
const activeTab = segment.replace(/[()]/g, "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerLargeTitle: true, headerBackButtonDisplayMode: "minimal" }}>
|
||||||
|
<Stack.Screen name={activeTab} options={{ title: tabLabels[activeTab] }} />
|
||||||
|
<Stack.Screen name="i/[id]" options={{ headerLargeTitle: false }} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Link Component
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Link } from "expo-router";
|
||||||
|
|
||||||
|
// Basic navigation
|
||||||
|
<Link href="/about">About</Link>
|
||||||
|
|
||||||
|
// Dynamic routes
|
||||||
|
<Link href={`/user/${userId}`}>Profile</Link>
|
||||||
|
|
||||||
|
// Wrapping custom component
|
||||||
|
<Link href="/settings" asChild>
|
||||||
|
<Pressable><Text>Settings</Text></Pressable>
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Programmatic Navigation
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useRouter, useLocalSearchParams } from "expo-router";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
router.push("/settings");
|
||||||
|
router.replace("/login"); // No back button
|
||||||
|
router.back();
|
||||||
|
|
||||||
|
// Access route params
|
||||||
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modals & Sheets
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Modal presentation
|
||||||
|
<Stack.Screen options={{ presentation: "modal" }} />
|
||||||
|
|
||||||
|
// Form sheet with detents
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
presentation: "formSheet",
|
||||||
|
sheetGrabberVisible: true,
|
||||||
|
sheetAllowedDetents: [0.5, 1.0],
|
||||||
|
contentStyle: { backgroundColor: "transparent" }, // Liquid glass on iOS 26+
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context Menus on Links
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link href="/settings" asChild>
|
||||||
|
<Link.Trigger>
|
||||||
|
<Pressable><Card /></Pressable>
|
||||||
|
</Link.Trigger>
|
||||||
|
<Link.Menu>
|
||||||
|
<Link.MenuAction
|
||||||
|
title="Share"
|
||||||
|
icon="square.and.arrow.up"
|
||||||
|
onPress={handleShare}
|
||||||
|
/>
|
||||||
|
<Link.MenuAction
|
||||||
|
title="Delete"
|
||||||
|
icon="trash"
|
||||||
|
destructive
|
||||||
|
onPress={handleDelete}
|
||||||
|
/>
|
||||||
|
<Link.Menu title="More" icon="ellipsis">
|
||||||
|
<Link.MenuAction title="Copy" icon="doc.on.doc" onPress={() => {}} />
|
||||||
|
</Link.Menu>
|
||||||
|
</Link.Menu>
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Link Previews (iOS only, requires Expo SDK 54+)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link href="/detail">
|
||||||
|
<Link.Trigger>
|
||||||
|
<Pressable><Card /></Pressable>
|
||||||
|
</Link.Trigger>
|
||||||
|
<Link.Preview /> {/* Shows peek preview on 3D touch / long press */}
|
||||||
|
</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Header Search Bar
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In Stack.Screen — preferred over building custom search UI
|
||||||
|
<Stack.Screen
|
||||||
|
options={{
|
||||||
|
headerSearchBarOptions: {
|
||||||
|
placeholder: "Search...",
|
||||||
|
onChangeText: (e) => setQuery(e.nativeEvent.text),
|
||||||
|
onCancelButtonPress: () => setQuery(""),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deep Linking
|
||||||
|
|
||||||
|
```json
|
||||||
|
// app.json
|
||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"scheme": "myapp",
|
||||||
|
"ios": {
|
||||||
|
"associatedDomains": ["applinks:myapp.example.com"]
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"intentFilters": [
|
||||||
|
{
|
||||||
|
"action": "VIEW",
|
||||||
|
"autoVerify": true,
|
||||||
|
"data": [{ "scheme": "https", "host": "myapp.example.com" }],
|
||||||
|
"category": ["BROWSABLE", "DEFAULT"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Expo Router handles deep links automatically — `/user/123` maps to `app/user/[id].tsx`.
|
||||||
|
|
||||||
|
## ScrollView in Routes
|
||||||
|
|
||||||
|
When a route belongs to a Stack, its first child should almost always be a ScrollView:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function HomeScreen() {
|
||||||
|
return (
|
||||||
|
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
||||||
|
{/* Content */}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `contentInsetAdjustmentBehavior="automatic"` on `ScrollView`, `FlatList`, and `SectionList` — this handles safe areas and header insets automatically. Prefer it over `<SafeAreaView>`.
|
||||||
346
skills/react-native-dev/references/networking.md
Normal file
346
skills/react-native-dev/references/networking.md
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
# Networking Reference
|
||||||
|
|
||||||
|
Building a robust data layer for Expo apps: API clients, server state, authentication, and server-side routes.
|
||||||
|
|
||||||
|
## API Client
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
Create a thin wrapper around `fetch` (or `expo/fetch` on SDK 53+) rather than installing axios. Build a generic `request<T>(path, init?)` function that:
|
||||||
|
|
||||||
|
- Prepends `process.env.EXPO_PUBLIC_API_URL` to the path
|
||||||
|
- Defaults `Content-Type: application/json`, merges caller headers
|
||||||
|
- On `!res.ok`, throws an error with `status` and `body` attached (use `Object.assign`) so callers can branch on HTTP status
|
||||||
|
- Returns `res.json() as Promise<T>`
|
||||||
|
|
||||||
|
Then export convenience methods: `api.get<T>(path)`, `api.post<T>(path, body)`, etc., each delegating to `request()` with the appropriate method and `JSON.stringify(body)`.
|
||||||
|
|
||||||
|
### Typed Errors
|
||||||
|
|
||||||
|
Distinguish network-level failures (no connectivity, DNS) from HTTP-level errors (4xx/5xx). The wrapper above attaches `status` and `body` to thrown errors so callers can branch:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
try {
|
||||||
|
await api.post("/tasks", newTask);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.status === 409) {
|
||||||
|
Alert.alert("Duplicate", "A task with that title already exists.");
|
||||||
|
} else if (err.status === undefined) {
|
||||||
|
Alert.alert("Offline", "Check your connection and try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server State (React Query)
|
||||||
|
|
||||||
|
### Provider
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx
|
||||||
|
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { staleTime: 60_000 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<Stack />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reading Data
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function TaskList({ projectId }: { projectId: string }) {
|
||||||
|
const { data: tasks, isPending, error } = useQuery({
|
||||||
|
queryKey: ["projects", projectId, "tasks"],
|
||||||
|
queryFn: () => api.get<Task[]>(`/projects/${projectId}/tasks`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPending) return <ActivityIndicator />;
|
||||||
|
if (error) return <ErrorBanner message={error.message} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={tasks}
|
||||||
|
renderItem={({ item }) => <TaskRow task={item} />}
|
||||||
|
estimatedItemSize={56}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writing Data
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useCompleteTask(projectId: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (taskId: string) => api.put(`/tasks/${taskId}`, { done: true }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["projects", projectId, "tasks"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optimistic Updates
|
||||||
|
|
||||||
|
For snappy UIs, update the cache before the server confirms:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const toggle = useMutation({
|
||||||
|
mutationFn: (task: Task) => api.put(`/tasks/${task.id}`, { done: !task.done }),
|
||||||
|
onMutate: async (task) => {
|
||||||
|
await qc.cancelQueries({ queryKey });
|
||||||
|
const prev = qc.getQueryData<Task[]>(queryKey);
|
||||||
|
qc.setQueryData<Task[]>(queryKey, (old) =>
|
||||||
|
old?.map((t) => (t.id === task.id ? { ...t, done: !t.done } : t)),
|
||||||
|
);
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_err, _task, ctx) => qc.setQueryData(queryKey, ctx?.prev),
|
||||||
|
onSettled: () => qc.invalidateQueries({ queryKey }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Storing Credentials
|
||||||
|
|
||||||
|
Use `expo-secure-store` for any token or secret. AsyncStorage is unencrypted and readable on rooted/jailbroken devices.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "session_token";
|
||||||
|
|
||||||
|
export async function saveToken(token: string) {
|
||||||
|
await SecureStore.setItemAsync(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
export async function getToken() {
|
||||||
|
return SecureStore.getItemAsync(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
export async function clearToken() {
|
||||||
|
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Injecting Auth Headers
|
||||||
|
|
||||||
|
Extend the API client to attach the token automatically:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export async function authRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const token = await getToken();
|
||||||
|
return request<T>(path, {
|
||||||
|
...init,
|
||||||
|
headers: { ...init?.headers, ...(token && { Authorization: `Bearer ${token}` }) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refreshing Expired Tokens
|
||||||
|
|
||||||
|
Avoid stampeding refresh calls when multiple requests discover the token is expired simultaneously. Hold a single in-flight refresh promise and let all waiters share it:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
let pending: Promise<string> | null = null;
|
||||||
|
|
||||||
|
async function getFreshToken(): Promise<string> {
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
pending = (async () => {
|
||||||
|
const refresh = await SecureStore.getItemAsync("refresh_token");
|
||||||
|
const { accessToken } = await api.post<{ accessToken: string }>("/auth/refresh", { refresh });
|
||||||
|
await saveToken(accessToken);
|
||||||
|
return accessToken;
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await pending;
|
||||||
|
} finally {
|
||||||
|
pending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.development
|
||||||
|
EXPO_PUBLIC_API_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# .env.production
|
||||||
|
EXPO_PUBLIC_API_URL=https://api.production.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `EXPO_PUBLIC_` prefix makes a variable available in client JS (inlined at build time)
|
||||||
|
- Variables **without** the prefix are only accessible in server-side API routes
|
||||||
|
- Never expose database credentials or write-capable API keys via `EXPO_PUBLIC_`
|
||||||
|
- Restart the dev server after editing `.env` files
|
||||||
|
|
||||||
|
Type the variables for autocomplete:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// env.d.ts
|
||||||
|
declare global {
|
||||||
|
namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
EXPO_PUBLIC_API_URL: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Offline & Connectivity
|
||||||
|
|
||||||
|
Track device connectivity with `@react-native-community/netinfo` and wire it into React Query so queries automatically pause offline and resume on reconnect:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx (once, at startup)
|
||||||
|
import { onlineManager } from "@tanstack/react-query";
|
||||||
|
import NetInfo from "@react-native-community/netinfo";
|
||||||
|
|
||||||
|
onlineManager.setEventListener((setOnline) =>
|
||||||
|
NetInfo.addEventListener((state) => setOnline(!!state.isConnected)),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
To show an in-app banner, subscribe separately:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function useOnline() {
|
||||||
|
const [online, setOnline] = useState(true);
|
||||||
|
useEffect(() => NetInfo.addEventListener((s) => setOnline(!!s.isConnected)), []);
|
||||||
|
return online;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Lifecycle
|
||||||
|
|
||||||
|
### Cancellation
|
||||||
|
|
||||||
|
When a component unmounts mid-request, abort the in-flight fetch to avoid setting state on an unmounted component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
const ac = new AbortController();
|
||||||
|
api.get(`/projects/${id}`, { signal: ac.signal }).then(setProject);
|
||||||
|
return () => ac.abort();
|
||||||
|
}, [id]);
|
||||||
|
```
|
||||||
|
|
||||||
|
React Query handles cancellation automatically for queries — no extra work needed.
|
||||||
|
|
||||||
|
### Retries
|
||||||
|
|
||||||
|
React Query retries failed queries by default (3 attempts with exponential backoff). For mutations or non-React-Query code, implement manually:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
if (i === attempts - 1) throw err;
|
||||||
|
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("unreachable");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Side API Routes
|
||||||
|
|
||||||
|
Expo Router supports `+api.ts` files that run on the server (deployed to EAS Hosting / Cloudflare Workers). Use them when you need to keep secrets server-side, proxy third-party APIs, or run database queries.
|
||||||
|
|
||||||
|
### Conventions
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
api/
|
||||||
|
health+api.ts → GET /api/health
|
||||||
|
projects+api.ts → GET|POST /api/projects
|
||||||
|
projects/[id]+api.ts → GET|PUT|DELETE /api/projects/:id
|
||||||
|
webhooks/payments+api.ts → POST /api/webhooks/payments
|
||||||
|
```
|
||||||
|
|
||||||
|
Export a named function per HTTP method:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/projects+api.ts
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const cursor = url.searchParams.get("cursor");
|
||||||
|
const rows = await db.query("SELECT * FROM projects WHERE id > ? LIMIT 20", [cursor ?? 0]);
|
||||||
|
return Response.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { name, description } = await req.json();
|
||||||
|
const [row] = await db.insert(projectsTable).values({ name, description }).returning();
|
||||||
|
return Response.json(row, { status: 201 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
Variables **without** the `EXPO_PUBLIC_` prefix are server-only:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/ai/summarize+api.ts
|
||||||
|
const LLM_KEY = process.env.LLM_API_KEY; // never reaches the client bundle
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { text } = await req.json();
|
||||||
|
const res = await fetch("https://api.llm.example.com/v1/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${LLM_KEY}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messages: [{ role: "user", content: `Summarize: ${text}` }] }),
|
||||||
|
});
|
||||||
|
return Response.json(await res.json());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/api/webhooks/payments+api.ts — verify signature, then handle event
|
||||||
|
const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WH_SECRET!);
|
||||||
|
if (event.type === "checkout.session.completed") {
|
||||||
|
await activateSubscription(event.data.object.customer as string);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protecting Routes
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// lib/require-auth.ts — extract and verify JWT from Authorization header, throw Response on failure
|
||||||
|
export async function requireAuth(req: Request): Promise<string> {
|
||||||
|
const header = req.headers.get("Authorization");
|
||||||
|
if (!header?.startsWith("Bearer "))
|
||||||
|
throw Response.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
const uid = await verifyJwt(header.slice(7));
|
||||||
|
if (!uid) throw Response.json({ error: "invalid token" }, { status: 401 });
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
// Usage in route: const uid = await requireAuth(req);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploying
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo export
|
||||||
|
eas deploy # preview
|
||||||
|
eas deploy --prod # production
|
||||||
|
|
||||||
|
# Set server-only secrets
|
||||||
|
eas env:create --name LLM_API_KEY --value "sk-..." --environment production
|
||||||
|
```
|
||||||
|
|
||||||
|
API routes run on Cloudflare Workers — no `fs` module, 30 s CPU limit, use Web APIs (`fetch`, `crypto.subtle`) instead of Node built-ins.
|
||||||
215
skills/react-native-dev/references/performance.md
Normal file
215
skills/react-native-dev/references/performance.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Performance Reference
|
||||||
|
|
||||||
|
Diagnosing and fixing performance issues in React Native / Expo apps.
|
||||||
|
|
||||||
|
## Profiling Workflow
|
||||||
|
|
||||||
|
Before optimizing, identify the actual bottleneck:
|
||||||
|
|
||||||
|
1. **JS thread** — Open React Native DevTools (press `j` in Metro terminal) → Profiler tab → record interaction → look for components with long render times
|
||||||
|
2. **Native thread** — iOS: Xcode Instruments (Time Profiler); Android: Android Studio CPU Profiler
|
||||||
|
3. **Measure, don't guess** — Always reproduce the issue in a release-like build (`npx expo run:ios --configuration Release`)
|
||||||
|
|
||||||
|
## Rendering
|
||||||
|
|
||||||
|
### Virtualized Lists
|
||||||
|
|
||||||
|
Never render large datasets inside a `ScrollView`. Use a virtualized list that recycles off-screen views:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
|
||||||
|
function ProductCatalog({ products }: { products: Product[] }) {
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
data={products}
|
||||||
|
renderItem={({ item }) => <ProductRow product={item} />}
|
||||||
|
estimatedItemSize={72}
|
||||||
|
keyExtractor={(p) => p.sku}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductRow = memo(function ProductRow({ product }: { product: Product }) {
|
||||||
|
return (
|
||||||
|
<View style={rowStyles.container}>
|
||||||
|
<Image source={product.thumbnail} style={rowStyles.image} />
|
||||||
|
<Text style={rowStyles.title}>{product.name}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- Wrap list items with `memo` to skip re-renders when props haven't changed
|
||||||
|
- Always provide `estimatedItemSize` — FlashList uses it for layout estimation
|
||||||
|
- Extract `renderItem` or use a named component to keep stable references
|
||||||
|
|
||||||
|
### Minimizing Re-renders
|
||||||
|
|
||||||
|
**Split state by concern.** A single large state object forces every subscriber to re-render on any change:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Zustand — select only the slice you need
|
||||||
|
const count = useStore((s) => s.cart.itemCount);
|
||||||
|
|
||||||
|
// Jotai — one atom per concern
|
||||||
|
const cartTotalAtom = atom((get) => {
|
||||||
|
const items = get(cartItemsAtom);
|
||||||
|
return items.reduce((sum, i) => sum + i.price * i.qty, 0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**React Compiler** (Expo SDK 54+) automatically memoizes components and hooks. Enable it to eliminate most manual `useMemo`/`useCallback`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// app.json
|
||||||
|
{ "expo": { "experiments": { "reactCompiler": true } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deferred Updates
|
||||||
|
|
||||||
|
When a state change triggers expensive computation (filtering a long list, rendering a complex tree), defer the update so typing or scrolling stays responsive:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const deferred = useDeferredValue(search);
|
||||||
|
|
||||||
|
const results = useMemo(
|
||||||
|
() => catalog.filter((p) => p.name.toLowerCase().includes(deferred.toLowerCase())),
|
||||||
|
[catalog, deferred],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### TextInput on Android
|
||||||
|
|
||||||
|
Controlled `TextInput` (with `value` + `onChangeText`) can lag on Android because every keystroke round-trips through the JS thread. For search bars or other high-frequency inputs, prefer uncontrolled mode:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const ref = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
ref={ref}
|
||||||
|
defaultValue=""
|
||||||
|
onEndEditing={(e) => handleSearch(e.nativeEvent.text)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Startup Time (TTI)
|
||||||
|
|
||||||
|
### Measuring
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { PerformanceObserver, performance } from "react-native-performance";
|
||||||
|
|
||||||
|
performance.mark("nativeLaunch");
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
performance.measure("TTI", "nativeLaunch");
|
||||||
|
}, []);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Always measure **cold starts** — kill the app completely before each measurement.
|
||||||
|
|
||||||
|
### Reducing TTI
|
||||||
|
|
||||||
|
- **Android bundle mmap** — Set `expo.useLegacyPackaging=false` in `android/gradle.properties` so Hermes memory-maps the bundle instead of decompressing it
|
||||||
|
- **Preload heavy routes** — Call `preloadRouteAsync("/dashboard")` (from `expo-router`) while the user is still on the splash/login screen
|
||||||
|
- **Lazy-load non-critical screens** — Screens behind deep navigation don't need to be in the initial bundle
|
||||||
|
|
||||||
|
## Bundle & App Size
|
||||||
|
|
||||||
|
### Inspecting the Bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo export --platform ios --source-maps --output-dir dist
|
||||||
|
npx source-map-explorer dist/bundles/ios/*.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Common wins:
|
||||||
|
- **Direct imports** — `import groupBy from "lodash/groupBy"` instead of `import { groupBy } from "lodash"`
|
||||||
|
- **Remove dead Intl polyfills** — Hermes ships with built-in `Intl` support since SDK 50
|
||||||
|
- **Tree shaking** — Enable via `"experiments": { "treeShaking": true }` in app config (SDK 52+)
|
||||||
|
|
||||||
|
### Shrinking the Native Binary
|
||||||
|
|
||||||
|
```properties
|
||||||
|
# android/gradle.properties
|
||||||
|
android.enableProguardInReleaseBuilds=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Inspect the final artifact:
|
||||||
|
- iOS: download the `.ipa` from EAS, unzip, check `Payload/*.app` size
|
||||||
|
- Android: open the `.aab`/`.apk` in Android Studio → Build → Analyze APK
|
||||||
|
|
||||||
|
## Memory
|
||||||
|
|
||||||
|
### Preventing Leaks
|
||||||
|
|
||||||
|
Every subscription, listener, or long-lived resource acquired in `useEffect` must be cleaned up:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
const sub = AppState.addEventListener("change", onAppStateChange);
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
For fetch calls, pass an `AbortSignal` and abort on unmount:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
const ac = new AbortController();
|
||||||
|
loadProducts(ac.signal);
|
||||||
|
return () => ac.abort();
|
||||||
|
}, [categoryId]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Native Memory
|
||||||
|
|
||||||
|
- Monitor with Xcode Memory Graph Debugger (iOS) or Android Studio Memory Profiler
|
||||||
|
- Release heavy native resources (camera sessions, audio players) in cleanup
|
||||||
|
- In Swift/Kotlin modules, watch for retain cycles — use `[weak self]` / `WeakReference`
|
||||||
|
|
||||||
|
## Animations
|
||||||
|
|
||||||
|
Only animate **`transform`** and **`opacity`**. These properties are composited on the GPU and don't trigger layout recalculation:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const style = useAnimatedStyle(() => ({
|
||||||
|
opacity: withTiming(visible.value ? 1 : 0),
|
||||||
|
transform: [{ translateY: withSpring(offset.value) }],
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Animating `width`, `height`, `margin`, `padding`, or `top`/`left` forces the layout engine to re-measure on every frame — a common source of dropped frames.
|
||||||
|
|
||||||
|
Keep gesture callbacks on the UI thread:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const drag = Gesture.Pan().onUpdate((e) => {
|
||||||
|
"worklet";
|
||||||
|
translateX.value = e.translationX;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native Module Performance
|
||||||
|
|
||||||
|
- Prefer **async** Turbo Module methods — synchronous calls block the JS thread
|
||||||
|
- Use native SDK implementations over JS polyfills (`expo-crypto` over `crypto-js`, `react-native-mmkv` over AsyncStorage for hot paths)
|
||||||
|
- **Android 16KB page alignment** is required for Google Play (2025+). Verify third-party `.so` files are compiled with 16KB alignment
|
||||||
|
|
||||||
|
## Troubleshooting Guide
|
||||||
|
|
||||||
|
| Symptom | Where to Look | Likely Fix |
|
||||||
|
|---------|--------------|------------|
|
||||||
|
| Scroll jank in long lists | JS thread — re-renders | Virtualized list + memoized items |
|
||||||
|
| Typing lag in search bar | JS thread — controlled input | Uncontrolled `TextInput` with `defaultValue` |
|
||||||
|
| Slow cold start | Bundle size, sync init | Mmap bundle, preload routes, lazy screens |
|
||||||
|
| App binary too large | Native assets, unused libs | R8 (Android), analyze bundle, direct imports |
|
||||||
|
| Growing memory over time | Effect cleanup | Return teardown from every `useEffect` |
|
||||||
|
| Choppy enter/exit animation | Animated properties | Only `transform` + `opacity`, use worklets |
|
||||||
|
| Re-renders cascade across app | Global state shape | Atomic selectors (Zustand/Jotai), React Compiler |
|
||||||
230
skills/react-native-dev/references/state-management.md
Normal file
230
skills/react-native-dev/references/state-management.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# State Management Reference
|
||||||
|
|
||||||
|
Patterns for local, shared, and server state in React Native / Expo apps.
|
||||||
|
|
||||||
|
## Decision Guide
|
||||||
|
|
||||||
|
| State Type | Solution |
|
||||||
|
|------------|----------|
|
||||||
|
| Local UI state (toggle, input) | `useState` / `useReducer` |
|
||||||
|
| Shared app-wide state | Zustand or Jotai |
|
||||||
|
| Server/async data | React Query (TanStack Query) |
|
||||||
|
| Form state | React Hook Form (see forms.md) |
|
||||||
|
| Auth / session | Zustand + `expo-secure-store` |
|
||||||
|
|
||||||
|
**Avoid**: Redux for new projects (boilerplate), Context for high-frequency updates (re-render overhead).
|
||||||
|
|
||||||
|
## useState / useReducer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Simple toggle
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Complex local state — useReducer
|
||||||
|
type State = { count: number; status: "idle" | "loading" | "error" };
|
||||||
|
type Action = { type: "increment" } | { type: "setStatus"; payload: State["status"] };
|
||||||
|
|
||||||
|
function reducer(state: State, action: Action): State {
|
||||||
|
switch (action.type) {
|
||||||
|
case "increment": return { ...state, count: state.count + 1 };
|
||||||
|
case "setStatus": return { ...state, status: action.payload };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [state, dispatch] = useReducer(reducer, { count: 0, status: "idle" });
|
||||||
|
dispatch({ type: "increment" });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zustand (Shared State)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install zustand
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// stores/settings-store.ts
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { persist, createJSONStorage } from "zustand/middleware";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
interface SettingsStore {
|
||||||
|
theme: "light" | "dark";
|
||||||
|
locale: string;
|
||||||
|
setTheme: (theme: "light" | "dark") => void;
|
||||||
|
setLocale: (locale: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = create<SettingsStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
theme: "light",
|
||||||
|
locale: "en",
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
setLocale: (locale) => set({ locale }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "settings-storage",
|
||||||
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
const { theme, setTheme } = useSettingsStore();
|
||||||
|
const locale = useSettingsStore((s) => s.locale); // Selector — minimizes re-renders
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// stores/cart-store.ts
|
||||||
|
interface CartStore {
|
||||||
|
items: CartItem[];
|
||||||
|
add: (product: Product) => void;
|
||||||
|
remove: (id: string) => void;
|
||||||
|
clear: () => void;
|
||||||
|
total: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCartStore = create<CartStore>()((set, get) => ({
|
||||||
|
items: [],
|
||||||
|
add: (product) => set((s) => ({
|
||||||
|
items: [...s.items, { product, quantity: 1 }],
|
||||||
|
})),
|
||||||
|
remove: (id) => set((s) => ({
|
||||||
|
items: s.items.filter((i) => i.product.id !== id),
|
||||||
|
})),
|
||||||
|
clear: () => set({ items: [] }),
|
||||||
|
total: () => get().items.reduce((sum, i) => sum + i.product.price * i.quantity, 0),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Jotai (Atomic State)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install jotai
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// atoms/user-atoms.ts
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
|
||||||
|
const storage = createJSONStorage(() => AsyncStorage);
|
||||||
|
|
||||||
|
export const userAtom = atom<User | null>(null);
|
||||||
|
export const themeAtom = atomWithStorage<"light" | "dark">("theme", "light", storage);
|
||||||
|
|
||||||
|
// Derived atom — computed from others
|
||||||
|
export const isAdminAtom = atom((get) => get(userAtom)?.role === "admin");
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Usage — component only re-renders when its atoms change
|
||||||
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const user = useAtomValue(userAtom); // read-only
|
||||||
|
const setTheme = useSetAtom(themeAtom); // write-only
|
||||||
|
const [theme, setThemeRW] = useAtom(themeAtom); // read + write
|
||||||
|
return <Text>{user?.name}</Text>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zustand vs Jotai**:
|
||||||
|
- **Zustand** — store-based, better for related state with actions (auth, cart)
|
||||||
|
- **Jotai** — atom-based, better for independent values, fine-grained subscriptions, avoids re-renders
|
||||||
|
|
||||||
|
## React Query (Server State)
|
||||||
|
|
||||||
|
See [networking.md](networking.md) for full reference. Key patterns:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Queries — read
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
|
||||||
|
|
||||||
|
// Mutations — write
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: createUser,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: updateUser,
|
||||||
|
onMutate: async (newUser) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ["user", newUser.id] });
|
||||||
|
const prev = queryClient.getQueryData(["user", newUser.id]);
|
||||||
|
queryClient.setQueryData(["user", newUser.id], newUser);
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (_err, variables, context) => {
|
||||||
|
queryClient.setQueryData(["user", variables.id], context?.prev);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Minimize Re-renders
|
||||||
|
|
||||||
|
### Zustand Selectors
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✗ Wrong — re-renders on any store change
|
||||||
|
const store = useAuthStore();
|
||||||
|
|
||||||
|
// ✓ Correct — re-renders only when user changes
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const logout = useAuthStore((s) => s.logout); // Actions are stable references
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dispatcher Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✗ Wrong — passes callbacks that recreate on every render
|
||||||
|
function Parent() {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
return <Child onIncrement={() => setCount(c => c + 1)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✓ Correct — dispatcher reference is stable
|
||||||
|
function Parent() {
|
||||||
|
const [count, dispatch] = useReducer(reducer, 0);
|
||||||
|
return <Child dispatch={dispatch} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### React Compiler (SDK 54+)
|
||||||
|
|
||||||
|
With React Compiler enabled, `memo`, `useCallback`, and `useMemo` are often unnecessary:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// app.json
|
||||||
|
{ "expo": { "experiments": { "reactCompiler": true } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context (Use Sparingly)
|
||||||
|
|
||||||
|
Context is suitable for infrequently-changing values (theme, locale, auth status). **Avoid** for high-frequency updates like scroll position or form input.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const ThemeContext = createContext<"light" | "dark">("light");
|
||||||
|
|
||||||
|
function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||||
|
return <ThemeContext value={theme}>{children}</ThemeContext>; // React 19+
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume
|
||||||
|
const theme = use(ThemeContext); // React 19+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fallback on First Render
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✓ Always show fallback while async state loads
|
||||||
|
function UserProfile({ userId }: { userId: string }) {
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId) });
|
||||||
|
if (isLoading) return <UserProfileSkeleton />;
|
||||||
|
if (!data) return null;
|
||||||
|
return <Profile user={data} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
117
skills/react-native-dev/references/styling.md
Normal file
117
skills/react-native-dev/references/styling.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Styling Reference
|
||||||
|
|
||||||
|
StyleSheet, NativeWind/Tailwind, platform-specific styles, and theming for Expo/React Native.
|
||||||
|
|
||||||
|
## StyleSheet
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { StyleSheet } from "react-native";
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: { flex: 1, backgroundColor: "#fff" },
|
||||||
|
text: { fontSize: 16, fontWeight: "600" },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer `StyleSheet.create` over inline style objects — it validates styles at creation time and enables potential future optimizations.
|
||||||
|
|
||||||
|
## Platform-Specific Styles
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Platform, StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
shadow: Platform.select({
|
||||||
|
ios: { shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
|
||||||
|
android: { elevation: 4 },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Since React Native 0.76+, `boxShadow` is supported as a unified cross-platform shadow API. Prefer it over platform-specific shadow properties when targeting New Architecture.
|
||||||
|
|
||||||
|
## NativeWind / Tailwind CSS
|
||||||
|
|
||||||
|
For existing projects, check which NativeWind version is in `package.json` and follow the corresponding docs. For new projects, use NativeWind v4 (stable).
|
||||||
|
|
||||||
|
### Installation (NativeWind v4)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install nativewind tailwindcss@3 \
|
||||||
|
tailwind-merge clsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```js
|
||||||
|
// babel.config.js
|
||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ["babel-preset-expo"],
|
||||||
|
plugins: ["nativewind/babel"],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// tailwind.config.js
|
||||||
|
module.exports = {
|
||||||
|
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||||
|
presets: [require("nativewind/preset")],
|
||||||
|
theme: { extend: {} },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* global.css */
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/_layout.tsx
|
||||||
|
import "../global.css";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<View className="flex-1 bg-white p-4">
|
||||||
|
<Text className="text-lg font-semibold text-gray-900">Title</Text>
|
||||||
|
<Pressable className="mt-4 rounded-lg bg-blue-500 px-4 py-2">
|
||||||
|
<Text className="text-center text-white font-medium">Button</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Classes
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
|
||||||
|
|
||||||
|
<View className={cn("p-4", isActive && "bg-blue-500", isDisabled && "opacity-50")} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Theming and Dark Mode
|
||||||
|
|
||||||
|
For apps using NativeWind, use Tailwind's `dark:` variant:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<View className="bg-white dark:bg-gray-900">
|
||||||
|
<Text className="text-gray-900 dark:text-white">Adaptive text</Text>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
For StyleSheet-based projects, read the system color scheme and map it to a theme object:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useColorScheme } from "react-native";
|
||||||
|
|
||||||
|
const colorScheme = useColorScheme(); // "light" | "dark"
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep color tokens in a central `constants/colors.ts` file with light and dark variants. Pass the active palette via React Context or a Zustand store.
|
||||||
342
skills/react-native-dev/references/testing.md
Normal file
342
skills/react-native-dev/references/testing.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# Testing Reference
|
||||||
|
|
||||||
|
Jest, React Native Testing Library, and E2E testing for Expo/React Native apps.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo install jest-expo @testing-library/react-native
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
// package.json
|
||||||
|
{
|
||||||
|
"jest": {
|
||||||
|
"preset": "jest-expo",
|
||||||
|
"setupFilesAfterSetup": ["@testing-library/react-native/extend-expect"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx jest # Run all tests
|
||||||
|
npx jest --watch # Watch mode
|
||||||
|
npx jest --coverage # Coverage report
|
||||||
|
npx jest path/to/test.tsx # Single file
|
||||||
|
```
|
||||||
|
|
||||||
|
## React Native Testing Library
|
||||||
|
|
||||||
|
### Basic Component Test
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/__tests__/Button.test.tsx
|
||||||
|
import { render, fireEvent, screen } from "@testing-library/react-native";
|
||||||
|
import { Button } from "../Button";
|
||||||
|
|
||||||
|
describe("Button", () => {
|
||||||
|
it("renders label", () => {
|
||||||
|
render(<Button label="Submit" onPress={() => {}} />);
|
||||||
|
expect(screen.getByText("Submit")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onPress when tapped", () => {
|
||||||
|
const onPress = jest.fn();
|
||||||
|
render(<Button label="Submit" onPress={onPress} />);
|
||||||
|
fireEvent.press(screen.getByText("Submit"));
|
||||||
|
expect(onPress).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is disabled when loading", () => {
|
||||||
|
render(<Button label="Submit" onPress={() => {}} loading />);
|
||||||
|
expect(screen.getByRole("button")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Queries
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Prefer accessible queries
|
||||||
|
screen.getByRole("button", { name: "Submit" });
|
||||||
|
screen.getByLabelText("Email");
|
||||||
|
screen.getByPlaceholderText("Enter email");
|
||||||
|
|
||||||
|
// Text content
|
||||||
|
screen.getByText("Welcome back");
|
||||||
|
screen.getByText(/welcome/i); // Regex — case insensitive
|
||||||
|
|
||||||
|
// Test IDs (last resort)
|
||||||
|
screen.getByTestId("user-avatar");
|
||||||
|
|
||||||
|
// Async queries
|
||||||
|
await screen.findByText("Loaded content"); // Waits for element to appear
|
||||||
|
await screen.findAllByRole("listitem");
|
||||||
|
|
||||||
|
// Non-existence
|
||||||
|
expect(screen.queryByText("Error")).toBeNull();
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Events
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { userEvent } from "@testing-library/react-native";
|
||||||
|
|
||||||
|
it("submits form on valid input", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LoginForm onSubmit={mockSubmit} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText("Email"), "user@example.com");
|
||||||
|
await user.type(screen.getByPlaceholderText("Password"), "password123");
|
||||||
|
await user.press(screen.getByRole("button", { name: "Login" }));
|
||||||
|
|
||||||
|
expect(mockSubmit).toHaveBeenCalledWith({
|
||||||
|
email: "user@example.com",
|
||||||
|
password: "password123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Async State
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { waitFor, act } from "@testing-library/react-native";
|
||||||
|
|
||||||
|
it("shows user data after loading", async () => {
|
||||||
|
render(<UserProfile userId="123" />);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
expect(screen.getByTestId("loading-indicator")).toBeTruthy();
|
||||||
|
|
||||||
|
// Wait for data
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("John Doe")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("loading-indicator")).toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing with React Query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
function createTestQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWithQuery(ui: ReactElement) {
|
||||||
|
const client = createTestQueryClient();
|
||||||
|
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("fetches and displays posts", async () => {
|
||||||
|
// Mock fetch
|
||||||
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve([{ id: "1", title: "Post 1" }]),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithQuery(<PostsList />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Post 1")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing with Zustand
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAuthStore } from "../../stores/auth-store";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset store state before each test
|
||||||
|
useAuthStore.setState({ user: null, token: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows user name when logged in", () => {
|
||||||
|
useAuthStore.setState({ user: { id: "1", name: "Alice" }, token: "tok" });
|
||||||
|
render(<Header />);
|
||||||
|
expect(screen.getByText("Alice")).toBeTruthy();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Navigation (Expo Router)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { renderRouter, screen } from "expo-router/testing-library";
|
||||||
|
|
||||||
|
it("navigates to detail screen", async () => {
|
||||||
|
renderRouter({
|
||||||
|
index: () => <HomeScreen />,
|
||||||
|
"user/[id]": () => <UserScreen />,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.press(screen.getByText("View Profile"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("user-screen")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
### Mock Expo Modules
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// __mocks__/expo-secure-store.ts
|
||||||
|
export const getItemAsync = jest.fn().mockResolvedValue(null);
|
||||||
|
export const setItemAsync = jest.fn().mockResolvedValue(undefined);
|
||||||
|
export const deleteItemAsync = jest.fn().mockResolvedValue(undefined);
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// In test
|
||||||
|
jest.mock("expo-secure-store", () => ({
|
||||||
|
getItemAsync: jest.fn().mockResolvedValue("mock-token"),
|
||||||
|
setItemAsync: jest.fn(),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock fetch / API calls
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles API error", async () => {
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: () => Promise.resolve({ message: "Server error" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<UserProfile userId="123" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Server error")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock react-native modules
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// jest.setup.ts
|
||||||
|
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");
|
||||||
|
|
||||||
|
jest.mock("@react-native-community/netinfo", () => ({
|
||||||
|
addEventListener: jest.fn(() => jest.fn()),
|
||||||
|
fetch: jest.fn(() => Promise.resolve({ isConnected: true })),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unit Tests (Non-UI Logic)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// utils/__tests__/format.test.ts
|
||||||
|
import { formatCurrency, formatDate } from "../format";
|
||||||
|
|
||||||
|
describe("formatCurrency", () => {
|
||||||
|
it("formats USD", () => expect(formatCurrency(1234.5, "USD")).toBe("$1,234.50"));
|
||||||
|
it("handles zero", () => expect(formatCurrency(0, "USD")).toBe("$0.00"));
|
||||||
|
it("handles negative", () => expect(formatCurrency(-50, "USD")).toBe("-$50.00"));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// stores/__tests__/cart-store.test.ts
|
||||||
|
import { useCartStore } from "../cart-store";
|
||||||
|
|
||||||
|
beforeEach(() => useCartStore.setState({ items: [] }));
|
||||||
|
|
||||||
|
describe("CartStore", () => {
|
||||||
|
it("adds item", () => {
|
||||||
|
useCartStore.getState().add(mockProduct);
|
||||||
|
expect(useCartStore.getState().items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates total", () => {
|
||||||
|
useCartStore.getState().add({ ...mockProduct, price: 10 });
|
||||||
|
expect(useCartStore.getState().total()).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## E2E Testing (Maestro)
|
||||||
|
|
||||||
|
Maestro is the recommended E2E tool for Expo — no build configuration needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||||
|
|
||||||
|
# Run flow
|
||||||
|
maestro test flows/login.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# flows/login.yaml
|
||||||
|
appId: com.example.myapp
|
||||||
|
---
|
||||||
|
- launchApp
|
||||||
|
- tapOn:
|
||||||
|
text: "Sign In"
|
||||||
|
- inputText:
|
||||||
|
id: "email-input"
|
||||||
|
text: "user@example.com"
|
||||||
|
- inputText:
|
||||||
|
id: "password-input"
|
||||||
|
text: "password123"
|
||||||
|
- tapOn:
|
||||||
|
text: "Login"
|
||||||
|
- assertVisible:
|
||||||
|
text: "Welcome back"
|
||||||
|
- takeScreenshot: login-success
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# flows/create-post.yaml
|
||||||
|
appId: com.example.myapp
|
||||||
|
---
|
||||||
|
- launchApp
|
||||||
|
- runFlow: ./login.yaml
|
||||||
|
- tapOn:
|
||||||
|
id: "new-post-button"
|
||||||
|
- inputText:
|
||||||
|
id: "post-title"
|
||||||
|
text: "My Test Post"
|
||||||
|
- tapOn:
|
||||||
|
text: "Publish"
|
||||||
|
- assertVisible:
|
||||||
|
text: "My Test Post"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
| Layer | What to Test |
|
||||||
|
|-------|-------------|
|
||||||
|
| Unit | Business logic, stores, utility functions, hooks |
|
||||||
|
| Component | Renders correctly, user interactions, loading/error states |
|
||||||
|
| Integration | Component + store/query working together |
|
||||||
|
| E2E | Critical user flows (login, checkout, core feature) |
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
| Wrong | Right |
|
||||||
|
|-------|-------|
|
||||||
|
| `getByTestId` everywhere | Use accessible queries (`getByRole`, `getByLabelText`) |
|
||||||
|
| Testing implementation details | Test behavior the user sees |
|
||||||
|
| No `waitFor` on async operations | `waitFor` or `findBy*` for async |
|
||||||
|
| Real network calls in tests | Mock `fetch` or use MSW |
|
||||||
|
| Testing every line | Focus on behavior, not coverage % |
|
||||||
174
skills/vision-analysis/SKILL.md
Normal file
174
skills/vision-analysis/SKILL.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
---
|
||||||
|
name: vision-analysis
|
||||||
|
description: >
|
||||||
|
Analyze, describe, and extract information from images using the MiniMax vision MCP tool.
|
||||||
|
Use when: user shares an image file path or URL (any message containing .jpg, .jpeg, .png,
|
||||||
|
.gif, .webp, .bmp, or .svg file extension) or uses any of these words/phrases near an image:
|
||||||
|
"analyze", "analyse", "describe", "explain", "understand", "look at", "review",
|
||||||
|
"extract text", "OCR", "what is in", "what's in", "read this image", "see this image",
|
||||||
|
"tell me about", "explain this", "interpret this", in connection with an image, screenshot,
|
||||||
|
diagram, chart, mockup, wireframe, or photo.
|
||||||
|
Also triggers for: UI mockup review, wireframe analysis, design critique, data extraction
|
||||||
|
from charts, object detection, person/animal/activity identification.
|
||||||
|
Triggers: any message with an image file extension (jpg, jpeg, png, gif, webp, bmp, svg),
|
||||||
|
or any request to analyze/describ/understand/review/extract text from an image, screenshot,
|
||||||
|
diagram, chart, photo, mockup, or wireframe.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
version: "1.0"
|
||||||
|
category: ai-vision
|
||||||
|
sources:
|
||||||
|
- MiniMax Token Plan MCP (understand_image tool)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vision Analysis
|
||||||
|
|
||||||
|
Analyze images using the MiniMax `MiniMax_understand_image` MCP tool available in the MiniMax Token Plan.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- MiniMax Token Plan subscription with valid `MINIMAX_API_KEY`
|
||||||
|
- MiniMax MCP configured (`MiniMax_understand_image` tool available)
|
||||||
|
|
||||||
|
### If MCP tool is not configured
|
||||||
|
|
||||||
|
**Step 1:** The agent should fetch setup instructions from:
|
||||||
|
**https://platform.minimaxi.com/docs/token-plan/mcp-guide**
|
||||||
|
|
||||||
|
**Step 2:** Detect the user's environment (OpenCode, Cursor, Claude Code, etc.) and output the exact commands needed. Common examples:
|
||||||
|
|
||||||
|
**OpenCode** — add to `~/.config/opencode/opencode.json` or `package.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"MiniMax": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["uvx", "minimax-coding-plan-mcp", "-y"],
|
||||||
|
"environment": {
|
||||||
|
"MINIMAX_API_KEY": "YOUR_TOKEN_PLAN_KEY",
|
||||||
|
"MINIMAX_API_HOST": "https://api.minimaxi.com"
|
||||||
|
},
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Claude Code**:
|
||||||
|
```bash
|
||||||
|
claude mcp add -s user MiniMax --env MINIMAX_API_KEY=your-key --env MINIMAX_API_HOST=https://api.minimaxi.com -- uvx minimax-coding-plan-mcp -y
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cursor** — add to MCP settings:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"MiniMax": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["minimax-coding-plan-mcp"],
|
||||||
|
"env": {
|
||||||
|
"MINIMAX_API_KEY": "your-key",
|
||||||
|
"MINIMAX_API_HOST": "https://api.minimaxi.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3:** After configuration, tell the user to restart their app and verify with `/mcp`.
|
||||||
|
|
||||||
|
**Important:** If the user does not have a MiniMax Token Plan subscription, inform them that the `understand_image` tool requires one — it cannot be used with free or other tier API keys.
|
||||||
|
|
||||||
|
## Analysis Modes
|
||||||
|
|
||||||
|
| Mode | When to use | Prompt strategy |
|
||||||
|
|---|---|---|
|
||||||
|
| `describe` | General image understanding | Ask for detailed description |
|
||||||
|
| `ocr` | Text extraction from screenshots, documents | Ask to extract all text verbatim |
|
||||||
|
| `ui-review` | UI mockups, wireframes, design files | Ask for design critique with suggestions |
|
||||||
|
| `chart-data` | Charts, graphs, data visualizations | Ask to extract data points and trends |
|
||||||
|
| `object-detect` | Identify objects, people, activities | Ask to list and locate all elements |
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: Auto-detect image
|
||||||
|
|
||||||
|
The skill triggers automatically when a message contains an image file path or URL with extensions:
|
||||||
|
`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp`, `.svg`
|
||||||
|
|
||||||
|
Extract the image path from the message.
|
||||||
|
|
||||||
|
### Step 2: Select analysis mode and call MCP tool
|
||||||
|
|
||||||
|
Use the `MiniMax_understand_image` tool with a mode-specific prompt:
|
||||||
|
|
||||||
|
**describe:**
|
||||||
|
```
|
||||||
|
Provide a detailed description of this image. Include: main subject, setting/background,
|
||||||
|
colors/style, any text visible, notable objects, and overall composition.
|
||||||
|
```
|
||||||
|
|
||||||
|
**ocr:**
|
||||||
|
```
|
||||||
|
Extract all text visible in this image verbatim. Preserve structure and formatting
|
||||||
|
(headers, lists, columns). If no text is found, say so.
|
||||||
|
```
|
||||||
|
|
||||||
|
**ui-review:**
|
||||||
|
```
|
||||||
|
You are a UI/UX design reviewer. Analyze this interface mockup or design. Provide:
|
||||||
|
(1) Strengths — what works well, (2) Issues — usability or design problems,
|
||||||
|
(3) Specific, actionable suggestions for improvement. Be constructive and detailed.
|
||||||
|
```
|
||||||
|
|
||||||
|
**chart-data:**
|
||||||
|
```
|
||||||
|
Extract all data from this chart or graph. List: chart title, axis labels, all
|
||||||
|
data points/series with values if readable, and a brief summary of the trend.
|
||||||
|
```
|
||||||
|
|
||||||
|
**object-detect:**
|
||||||
|
```
|
||||||
|
List all distinct objects, people, and activities you can identify. For each,
|
||||||
|
describe what it is and its approximate location in the image.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Present results
|
||||||
|
|
||||||
|
Return the analysis clearly. For `describe`, use readable prose. For `ocr`, preserve structure. For `ui-review`, use a structured critique format.
|
||||||
|
|
||||||
|
## Output Format Example
|
||||||
|
|
||||||
|
For describe mode:
|
||||||
|
```
|
||||||
|
## Image Description
|
||||||
|
|
||||||
|
[Detailed description of the image contents...]
|
||||||
|
```
|
||||||
|
|
||||||
|
For ocr mode:
|
||||||
|
```
|
||||||
|
## Extracted Text
|
||||||
|
|
||||||
|
[Preserved text structure from the image]
|
||||||
|
```
|
||||||
|
|
||||||
|
For ui-review mode:
|
||||||
|
```
|
||||||
|
## UI Design Review
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### Issues
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### Suggestions
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Images up to 20MB supported (JPEG, PNG, GIF, WebP)
|
||||||
|
- Local file paths work if MiniMax MCP is configured with file access
|
||||||
|
- The `MiniMax_understand_image` tool is provided by the `minimax-coding-plan-mcp` package
|
||||||
Reference in New Issue
Block a user