2 Commits

Author SHA1 Message Date
yuanhe
2061908623 docs: move Star History before License section
Made-with: Cursor
2026-03-25 12:14:36 +08:00
yuanhe
a68d0ed06a docs: change minimax-multimodal-toolkit source to Official and add Star History
Made-with: Cursor
2026-03-25 12:13:55 +08:00
44 changed files with 82 additions and 8990 deletions

View File

@@ -1,13 +1,13 @@
{
"name": "minimax-skills",
"description": "MiniMax AI skills library for frontend, fullstack, mobile, document, presentation, shader, and multimodal media workflows",
"description": "MiniMax AI skills library for frontend, fullstack, and Android native development",
"owner": {
"name": "MiniMax AI"
},
"plugins": [
{
"name": "minimax-skills",
"description": "MiniMax AI skills library for frontend, fullstack, Android, iOS, shader, GIF sticker, document, presentation, spreadsheet, and multimodal media workflows",
"description": "MiniMax AI skills library: frontend development, fullstack development, and Android native development",
"version": "1.0.0",
"source": "./",
"author": {

View File

@@ -1,6 +1,6 @@
{
"name": "minimax-skills",
"description": "MiniMax AI skills library for frontend, fullstack, Android, iOS, shader, GIF sticker, document, presentation, spreadsheet, and multimodal media workflows",
"description": "MiniMax AI skills library: frontend development, fullstack development, and Android native development",
"version": "1.0.0",
"author": {
"name": "MiniMax AI"
@@ -8,5 +8,5 @@
"homepage": "https://github.com/MiniMax-AI/skills",
"repository": "https://github.com/MiniMax-AI/skills",
"license": "MIT",
"keywords": ["skills", "frontend", "fullstack", "android", "ios", "shader", "gif", "sticker", "pdf", "pptx", "presentation", "xlsx", "excel", "spreadsheet", "docx", "word", "document", "multimodal", "video", "image", "audio", "music", "minimax"]
"keywords": ["skills", "frontend", "fullstack", "android", "minimax"]
}

View File

@@ -1,93 +0,0 @@
# 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

View File

@@ -1,7 +1,7 @@
{
"name": "minimax-skills",
"displayName": "MiniMax Skills",
"description": "MiniMax AI skills library for frontend, fullstack, Android, iOS, shader, GIF sticker, document, presentation, spreadsheet, and multimodal media workflows",
"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",
"version": "1.0.0",
"author": {
"name": "MiniMax AI"
@@ -9,7 +9,7 @@
"homepage": "https://github.com/MiniMax-AI/skills",
"repository": "https://github.com/MiniMax-AI/skills",
"license": "MIT",
"keywords": ["skills", "frontend", "fullstack", "android", "ios", "shader", "gif", "sticker", "pdf", "pptx", "xlsx", "excel", "spreadsheet", "docx", "word", "document", "multimodal", "video", "image", "audio", "music", "minimax"],
"keywords": ["skills", "frontend", "fullstack", "android", "ios", "shader", "gif", "sticker", "pdf", "pptx", "xlsx", "excel", "spreadsheet", "docx", "word", "document", "minimax"],
"logo": "assets/logo.png",
"skills": "./skills/"
}

View File

@@ -12,10 +12,7 @@
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
ln -s ~/.minimax-skills/skills/* ~/.config/opencode/skills/
```
### Windows (PowerShell)
@@ -25,7 +22,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"
Get-ChildItem "$env:USERPROFILE\.minimax-skills\skills" -Directory | ForEach-Object {
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.config\opencode\skills\minimax-$($_.Name)" -Target $_.FullName
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.config\opencode\skills\$($_.Name)" -Target $_.FullName
}
```
@@ -61,14 +58,14 @@ Symlinks will automatically point to the updated content — no need to re-link.
### macOS / Linux
```bash
rm -f ~/.config/opencode/skills/minimax-*
rm -rf ~/.config/opencode/skills
rm -rf ~/.minimax-skills
```
### Windows (PowerShell)
```powershell
Get-ChildItem "$env:USERPROFILE\.config\opencode\skills\minimax-*" | Remove-Item -Force
Remove-Item -Recurse -Force "$env:USERPROFILE\.config\opencode\skills"
Remove-Item -Recurse -Force "$env:USERPROFILE\.minimax-skills"
```

View File

@@ -1,85 +0,0 @@
# 安装 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

View File

@@ -14,15 +14,12 @@ 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 |
| `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 |
| `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 |
| `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 |
| `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-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 |
| `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
@@ -41,7 +38,6 @@ 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/`.
For Windows setup and verification, see [`.cursor-plugin/INSTALL.md`](.cursor-plugin/INSTALL.md).
### Codex
@@ -65,17 +61,6 @@ ln -s ~/.minimax-skills/skills/* ~/.config/opencode/skills/
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
We welcome contributions! Before submitting a PR, please read:

View File

@@ -14,15 +14,12 @@
| `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 |
| `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 |
| `gif-sticker-maker` | 将照片人物、宠物、物品、Logo转换为 4 张带字幕的动画 GIF 贴纸。Funko Pop / Pop Mart 盲盒风格,基于 MiniMax 图片与视频生成 API。 | Official |
| `minimax-pdf` | 基于 token 化设计系统生成、填写和重排 PDF 文档。支持三种模式CREATE从零生成15 种封面风格、FILL填写现有表单字段、REFORMAT将已有文档重排为新设计。排版与配色由文档类型自动推导输出即可打印。 | Official |
| `pptx-generator` | 生成、编辑和读取 PowerPoint 演示文稿。支持用 PptxGenJS 从零创建(封面、目录、内容、分节页、总结页),通过 XML 工作流编辑现有 PPTX或用 markitdown 提取文本。 | Official |
| `minimax-xlsx` | 打开、创建、读取、分析、编辑或验证 Excel/电子表格文件(.xlsx、.xlsm、.csv、.tsv。支持通过 XML 模板从零创建 xlsx、使用 pandas 读取分析、零格式损失编辑现有文件、公式重算与验证、专业财务格式化。 | Official |
| `minimax-docx` | 基于 OpenXML SDK.NET的专业 DOCX 文档创建、编辑与排版。三条流水线:从零创建新文档、填写/编辑现有文档内容、应用模板格式并通过 XSD 验证门控检查。 | Official |
| `vision-analysis` | 使用视觉 AI 模型分析、描述和提取图像信息。支持描述、OCR 文字识别、UI 界面审查、图表数据提取和物体检测。基于 MiniMax VL APIOpenAI GPT-4V 作为备选。 | Community |
| `minimax-multimodal-toolkit` | 通过 MiniMax API 生成语音、音乐、视频和图片内容 — MiniMax 多模态使用场景的统一入口。涵盖 TTS文字转语音、声音克隆、声音设计、多段合成、音乐带词歌曲、纯音乐、视频文生视频、图生视频、首尾帧、主体参考、模板、长视频多场景、图片文生图、图生图含角色参考以及基于 FFmpeg 的媒体处理(格式转换、拼接、裁剪、提取)。 | Official |
## 安装
@@ -41,7 +38,6 @@ git clone https://github.com/MiniMax-AI/skills.git ~/.cursor/minimax-skills
```
在 Cursor 设置中将 skills 路径指向 `~/.cursor/minimax-skills/skills/`
Windows 安装与校验方式见 [`.cursor-plugin/INSTALL.md`](.cursor-plugin/INSTALL.md)。
### Codex
@@ -63,18 +59,7 @@ mkdir -p ~/.config/opencode/skills
ln -s ~/.minimax-skills/skills/* ~/.config/opencode/skills/
```
重启 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)。
重启 OpenCode 以发现技能。详见 [`.opencode/INSTALL.md`](.opencode/INSTALL.md)。
## 贡献

View File

@@ -780,104 +780,3 @@ See [Design Style Guide](references/design-style-guide.md) for detailed style pr
| Privacy & Security | [Privacy & Security](references/privacy-security.md) |
| Audio, Video, Notifications | [Functional Requirements](references/functional-requirements.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
```

View File

@@ -1,554 +0,0 @@
# 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/`.

View File

@@ -1,128 +0,0 @@
---
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.

View File

@@ -1,497 +0,0 @@
# 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.*

View File

@@ -1,281 +0,0 @@
# 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.*

View File

@@ -1,656 +0,0 @@
# 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.*

View File

@@ -1,257 +0,0 @@
# 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.*

View File

@@ -1,510 +0,0 @@
# 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.*

View File

@@ -1,566 +0,0 @@
# 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.*

View File

@@ -1,306 +0,0 @@
# 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.*

View File

@@ -1,417 +0,0 @@
# 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.*

View File

@@ -1,274 +0,0 @@
# 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.*

View File

@@ -1,232 +0,0 @@
# 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.*

View File

@@ -1,364 +0,0 @@
# 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.*

View File

@@ -1,233 +0,0 @@
# 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.*

View File

@@ -18,11 +18,7 @@ import argparse
import requests
API_KEY = os.getenv("MINIMAX_API_KEY")
# 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.")
API_BASE = "https://api.minimax.io/v1"
ASPECT_RATIOS = ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]

View File

@@ -19,11 +19,7 @@ import argparse
import requests
API_KEY = os.getenv("MINIMAX_API_KEY")
# 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.")
API_BASE = os.getenv("MINIMAX_API_BASE", "https://api.minimax.io/v1")
def generate_music(

View File

@@ -19,11 +19,7 @@ import argparse
import requests
API_KEY = os.getenv("MINIMAX_API_KEY")
# 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.")
API_BASE = os.getenv("MINIMAX_API_BASE", "https://api.minimax.io/v1")
def tts(

View File

@@ -19,11 +19,7 @@ import argparse
import requests
API_KEY = os.getenv("MINIMAX_API_KEY")
# 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.")
API_BASE = "https://api.minimax.io/v1"
def _headers():

View File

@@ -19,11 +19,7 @@ import argparse
import requests
API_KEY = os.getenv("MINIMAX_API_KEY")
# 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.")
API_BASE = "https://api.minimax.io/v1"
ASPECT_RATIOS = ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"]

View File

@@ -23,11 +23,7 @@ import argparse
import requests
API_KEY = os.getenv("MINIMAX_API_KEY")
# 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.")
API_BASE = "https://api.minimax.io/v1"
I2V_MODELS = [
"MiniMax-Hailuo-2.3",

View File

@@ -1,17 +1,6 @@
---
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.
license: MIT
metadata:
version: "1.0"
category: media-generation
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.
---
# MiniMax Multi-Modal Toolkit
@@ -76,37 +65,6 @@ Before running any script, check if `MINIMAX_API_KEY` is set in the environment.
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
## 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** (25/day depending on plan) — always confirm with the user before generating video
## Key Capabilities
| Capability | Description | Entry point |
@@ -415,30 +373,40 @@ bash scripts/image/generate_image.sh \
| User intent | Script to use |
|-------------|---------------|
| Default / no special request | `scripts/video/generate_video.sh` (single segment, **6s, 768P**) |
| Default / no special request | `scripts/video/generate_video.sh` (single segment, **10s, 768P**) |
| 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 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.
**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.
Entry point (single video): `scripts/video/generate_video.sh`
Entry point (long/multi-scene): `scripts/video/generate_long_video.sh`
### Video Model Constraints (MUST follow)
**Supported resolutions and durations by model:**
**Duration limits by model and resolution:**
| Model | Resolution | Duration |
|-------|-----------|----------|
| MiniMax-Hailuo-2.3 | 768P only | 6s or 10s |
| MiniMax-Hailuo-2.3-Fast | 768P only | 6s or 10s |
| MiniMax-Hailuo-02 | 512P, 768P (default) | 6s or 10s |
| T2V-01 / T2V-01-Director | 720P | 6s only |
| I2V-01 / I2V-01-Director / I2V-01-live | 720P | 6s only |
| S2V-01 (ref) | 720P | 6s only |
| Model | 720P | 768P | 1080P |
|-------|------|------|-------|
| MiniMax-Hailuo-2.3 | - | 6s or **10s** | 6s only |
| MiniMax-Hailuo-2.3-Fast | - | 6s or **10s** | 6s only |
| MiniMax-Hailuo-02 | - | 6s or **10s** | 6s only |
| T2V-01 / T2V-01-Director | 6s only | - | - |
| I2V-01 / I2V-01-Director / I2V-01-live | 6s only | - | - |
| S2V-01 (ref) | 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:**
- **Default: 6s + 768P** — plan quotas are counted in 6-second units; use 6s unless user explicitly requests 10s
- **1080P is NOT supported** on any plan — always use 768P for Hailuo-2.3/2.3-Fast
- **Default: 10s + 768P** (best balance of length and quality for MiniMax-Hailuo-2.3)
- 1080P only supports 6s duration — if user requests 1080P, set `--duration 6`
- 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
### IMPORTANT: Prompt Optimization (MUST follow before generating any video)
@@ -464,12 +432,19 @@ 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.
```bash
# Text-to-video (default: 6s, 768P)
# Text-to-video (default: 10s, 768P)
bash scripts/video/generate_video.sh \
--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" \
--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)
bash scripts/video/generate_video.sh \
--mode i2v \
@@ -494,7 +469,7 @@ bash scripts/video/generate_video.sh \
### 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 6 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 10 seconds per segment.
**Workflow:**
1. Segment 1: t2v — generated purely from the optimized text prompt
@@ -507,10 +482,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 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
- Each segment covers only 6 seconds of action — keep it focused
- Each segment covers only 10 seconds of action — keep it focused
```bash
# Example: 3-segment story with optimized per-segment prompts (default: 6s/segment, 768P)
# Example: 3-segment story with optimized per-segment prompts (default: 10s/segment, 768P)
bash scripts/video/generate_long_video.sh \
--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" \
@@ -522,7 +497,7 @@ bash scripts/video/generate_long_video.sh \
# With custom settings
bash scripts/video/generate_long_video.sh \
--scenes "Scene 1 prompt" "Scene 2 prompt" \
--segment-duration 6 \
--segment-duration 10 \
--resolution 768P \
--crossfade 0.5 \
--music-prompt "calm ambient background music" \
@@ -553,8 +528,8 @@ bash scripts/video/generate_template_video.sh \
| Mode | Default Model | Default Duration | Default Resolution | Notes |
|------|--------------|-----------------|-------------------|-------|
| t2v | MiniMax-Hailuo-2.3 | 6s | 768P | Latest text-to-video |
| i2v | MiniMax-Hailuo-2.3 | 6s | 768P | Latest image-to-video |
| t2v | MiniMax-Hailuo-2.3 | 10s | 768P | Latest text-to-video |
| i2v | MiniMax-Hailuo-2.3 | 10s | 768P | Latest image-to-video |
| sef | MiniMax-Hailuo-02 | 6s | 768P | Start-end frame |
| ref | S2V-01 | 6s | 720P | Subject reference, 6s only |

View File

@@ -44,7 +44,7 @@ image_to_data_url() {
local mime
mime="$(file -b --mime-type "$path" 2>/dev/null)" || mime="image/jpeg"
local b64
b64="$(base64 -w 0 < "$path")"
b64="$(base64 < "$path")"
echo "data:${mime};base64,${b64}"
}
@@ -57,78 +57,6 @@ resolve_image() {
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
# ============================================================================
@@ -179,7 +107,7 @@ Options:
-n, --count N Number of images to generate (1-9, default: 1)
--seed N Random seed for reproducibility
--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)
--response-format FMT Response format: url (default), base64
--no-download Don't download, just print URL(s)
@@ -216,13 +144,31 @@ USAGE
echo "Error: -n must be between 1 and 9" >&2; exit 1
fi
# Build payload using temp-file method (avoids Windows cmd.exe arg-length limit)
# Build payload
local payload
payload=$(build_payload \
"$model" "$prompt" "$response_format" "$n" \
"$prompt_optimizer" "$aigc_watermark" \
"$aspect_ratio" "$width" "$height" "$seed" \
"$ref_image")
payload=$(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}')
[[ -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_url="${api_host}/v1/image_generation"
@@ -231,18 +177,13 @@ USAGE
echo "Model: $model"
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
raw_output="$(curl -s -w "\n%{http_code}" \
-X POST "$api_url" \
-H "Authorization: Bearer ${MINIMAX_API_KEY}" \
-H "Content-Type: application/json" \
--max-time 120 \
-d "@$payload_tmp" 2>/dev/null)" || {
-d "$payload" 2>/dev/null)" || {
echo "Error: curl request failed" >&2
exit 1
}
@@ -262,7 +203,6 @@ USAGE
local status_msg
status_msg="$(echo "$response" | jq -r '.base_resp.status_msg // "Unknown error"')"
echo "Error: API error (code $status_code): $status_msg" >&2
echo "Full response: $response" >&2
exit 1
fi

View File

@@ -1,149 +0,0 @@
---
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.*

View File

@@ -1,254 +0,0 @@
# 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); // 01
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 |

View File

@@ -1,124 +0,0 @@
# 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";
```

View File

@@ -1,527 +0,0 @@
# 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

View File

@@ -1,300 +0,0 @@
# 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" });
});
});
```

View File

@@ -1,163 +0,0 @@
# 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
}
}
```

View File

@@ -1,271 +0,0 @@
# 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>`.

View File

@@ -1,346 +0,0 @@
# 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.

View File

@@ -1,215 +0,0 @@
# 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 |

View File

@@ -1,230 +0,0 @@
# 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} />;
}
```

View File

@@ -1,117 +0,0 @@
# 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.

View File

@@ -1,342 +0,0 @@
# 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 % |

View File

@@ -1,174 +0,0 @@
---
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