Merge pull request #32 from mljxxx/feat/add_flutter_dev_skill
add flutter dev skill
This commit is contained in:
@@ -14,6 +14,7 @@ Development skills for AI coding agents. Plug into your favorite AI coding tool
|
|||||||
| `fullstack-dev` | Full-stack backend architecture and frontend-backend integration. REST API design, auth flows (JWT, session, OAuth), real-time features (SSE, WebSocket), database integration (SQL / NoSQL), production hardening, and release checklist. Guided workflow: requirements → architecture → implementation. | Official |
|
| `fullstack-dev` | Full-stack backend architecture and frontend-backend integration. REST API design, auth flows (JWT, session, OAuth), real-time features (SSE, WebSocket), database integration (SQL / NoSQL), production hardening, and release checklist. Guided workflow: requirements → architecture → implementation. | Official |
|
||||||
| `android-native-dev` | Android native application development with Material Design 3. Kotlin / Jetpack Compose, adaptive layouts, Gradle configuration, accessibility (WCAG), build troubleshooting, performance optimization, and motion system. | Official |
|
| `android-native-dev` | Android native application development with Material Design 3. Kotlin / Jetpack Compose, adaptive layouts, Gradle configuration, accessibility (WCAG), build troubleshooting, performance optimization, and motion system. | Official |
|
||||||
| `ios-application-dev` | iOS application development guide covering UIKit, SnapKit, and SwiftUI. Touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, and Apple HIG compliance. | Official |
|
| `ios-application-dev` | iOS application development guide covering UIKit, SnapKit, and SwiftUI. Touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, and Apple HIG compliance. | Official |
|
||||||
|
| `flutter-dev` | Flutter cross-platform development covering widget patterns, Riverpod/Bloc state management, GoRouter navigation, performance optimization, and testing strategies. | Official |
|
||||||
| `shader-dev` | Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. ShaderToy-compatible. | Official |
|
| `shader-dev` | Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. ShaderToy-compatible. | Official |
|
||||||
| `gif-sticker-maker` | Convert photos (people, pets, objects, logos) into 4 animated GIF stickers with captions. Funko Pop / Pop Mart style, powered by MiniMax Image & Video Generation API. | Official |
|
| `gif-sticker-maker` | Convert photos (people, pets, objects, logos) into 4 animated GIF stickers with captions. Funko Pop / Pop Mart style, powered by MiniMax Image & Video Generation API. | Official |
|
||||||
| `minimax-pdf` | Generate, fill, and reformat PDF documents with a token-based design system. CREATE polished PDFs from scratch (15 cover styles), FILL existing form fields, or REFORMAT documents into a new design. Print-ready output with typography and color derived from document type. | Official |
|
| `minimax-pdf` | Generate, fill, and reformat PDF documents with a token-based design system. CREATE polished PDFs from scratch (15 cover styles), FILL existing form fields, or REFORMAT documents into a new design. Print-ready output with typography and color derived from document type. | Official |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
| `fullstack-dev` | 全栈后端架构与前后端集成。REST API 设计、认证流程(JWT、Session、OAuth)、实时功能(SSE、WebSocket)、数据库集成(SQL / NoSQL)、生产环境加固与发布清单。引导式工作流:需求收集 → 架构决策 → 实现。 | Official |
|
| `fullstack-dev` | 全栈后端架构与前后端集成。REST API 设计、认证流程(JWT、Session、OAuth)、实时功能(SSE、WebSocket)、数据库集成(SQL / NoSQL)、生产环境加固与发布清单。引导式工作流:需求收集 → 架构决策 → 实现。 | Official |
|
||||||
| `android-native-dev` | 基于 Material Design 3 的 Android 原生应用开发。Kotlin / Jetpack Compose、自适应布局、Gradle 配置、无障碍(WCAG)、构建问题排查、性能优化与动效系统。 | Official |
|
| `android-native-dev` | 基于 Material Design 3 的 Android 原生应用开发。Kotlin / Jetpack Compose、自适应布局、Gradle 配置、无障碍(WCAG)、构建问题排查、性能优化与动效系统。 | Official |
|
||||||
| `ios-application-dev` | iOS 应用开发指南,涵盖 UIKit、SnapKit 和 SwiftUI。触控目标、安全区域、导航模式、Dynamic Type、深色模式、无障碍、集合视图,符合 Apple HIG 规范。 | Official |
|
| `ios-application-dev` | iOS 应用开发指南,涵盖 UIKit、SnapKit 和 SwiftUI。触控目标、安全区域、导航模式、Dynamic Type、深色模式、无障碍、集合视图,符合 Apple HIG 规范。 | Official |
|
||||||
|
| `flutter-dev` | Flutter 跨平台开发指南,涵盖 Widget 模式、Riverpod/Bloc 状态管理、GoRouter 导航、性能优化与测试策略。 | Official |
|
||||||
| `shader-dev` | 全面的 GLSL 着色器技术,用于创建惊艳的视觉效果 — 光线行进、SDF 建模、流体模拟、粒子系统、程序化生成、光照、后处理等。兼容 ShaderToy。 | Official |
|
| `shader-dev` | 全面的 GLSL 着色器技术,用于创建惊艳的视觉效果 — 光线行进、SDF 建模、流体模拟、粒子系统、程序化生成、光照、后处理等。兼容 ShaderToy。 | Official |
|
||||||
| `gif-sticker-maker` | 将照片(人物、宠物、物品、Logo)转换为 4 张带字幕的动画 GIF 贴纸。Funko Pop / Pop Mart 盲盒风格,基于 MiniMax 图片与视频生成 API。 | Official |
|
| `gif-sticker-maker` | 将照片(人物、宠物、物品、Logo)转换为 4 张带字幕的动画 GIF 贴纸。Funko Pop / Pop Mart 盲盒风格,基于 MiniMax 图片与视频生成 API。 | Official |
|
||||||
| `minimax-pdf` | 基于 token 化设计系统生成、填写和重排 PDF 文档。支持三种模式:CREATE(从零生成,15 种封面风格)、FILL(填写现有表单字段)、REFORMAT(将已有文档重排为新设计)。排版与配色由文档类型自动推导,输出即可打印。 | Official |
|
| `minimax-pdf` | 基于 token 化设计系统生成、填写和重排 PDF 文档。支持三种模式:CREATE(从零生成,15 种封面风格)、FILL(填写现有表单字段)、REFORMAT(将已有文档重排为新设计)。排版与配色由文档类型自动推导,输出即可打印。 | Official |
|
||||||
|
|||||||
128
skills/flutter-dev/SKILL.md
Normal file
128
skills/flutter-dev/SKILL.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
name: flutter-dev
|
||||||
|
description: |
|
||||||
|
Flutter cross-platform development guide covering widget patterns, Riverpod/Bloc state management, GoRouter navigation, performance optimization, and platform-specific implementations. Includes const optimization, responsive layouts, testing strategies, and DevTools profiling.
|
||||||
|
Use when: building Flutter apps, implementing state management (Riverpod/Bloc), setting up GoRouter navigation, creating custom widgets, optimizing performance, writing widget tests, cross-platform development.
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
category: mobile
|
||||||
|
sources:
|
||||||
|
- Flutter Documentation
|
||||||
|
- Riverpod Documentation
|
||||||
|
- Bloc Library Documentation
|
||||||
|
---
|
||||||
|
|
||||||
|
# Flutter Development Guide
|
||||||
|
|
||||||
|
A practical guide for building cross-platform applications with Flutter 3 and Dart. Focuses on proven patterns, state management, and performance optimization.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Widget Patterns
|
||||||
|
|
||||||
|
| Purpose | Component |
|
||||||
|
|---------|-----------|
|
||||||
|
| State management (simple) | `StateProvider` + `ConsumerWidget` |
|
||||||
|
| State management (complex) | `NotifierProvider` / `Bloc` |
|
||||||
|
| Async data | `FutureProvider` / `AsyncNotifierProvider` |
|
||||||
|
| Real-time streams | `StreamProvider` |
|
||||||
|
| Navigation | `GoRouter` + `context.go/push` |
|
||||||
|
| Responsive layout | `LayoutBuilder` + breakpoints |
|
||||||
|
| List display | `ListView.builder` |
|
||||||
|
| Complex scrolling | `CustomScrollView` + Slivers |
|
||||||
|
| Hooks | `HookWidget` + `useState/useEffect` |
|
||||||
|
| Forms | `Form` + `TextFormField` + validation |
|
||||||
|
|
||||||
|
### Performance Patterns
|
||||||
|
|
||||||
|
| Purpose | Solution |
|
||||||
|
|---------|----------|
|
||||||
|
| Prevent rebuilds | `const` constructors |
|
||||||
|
| Selective updates | `ref.watch(provider.select(...))` |
|
||||||
|
| Isolate repaints | `RepaintBoundary` |
|
||||||
|
| Lazy lists | `ListView.builder` |
|
||||||
|
| Heavy computation | `compute()` isolate |
|
||||||
|
| Image caching | `cached_network_image` |
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### Widget Optimization
|
||||||
|
- Use `const` constructors wherever possible
|
||||||
|
- Extract static widgets to separate const classes
|
||||||
|
- Use `Key` for list items (ValueKey, ObjectKey)
|
||||||
|
- Prefer `ConsumerWidget` over `StatefulWidget` for state
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Riverpod for dependency injection and simple state
|
||||||
|
- Bloc/Cubit for event-driven workflows and complex logic
|
||||||
|
- Never mutate state directly (create new instances)
|
||||||
|
- Use `select()` to minimize rebuilds
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- 8pt spacing increments (8, 16, 24, 32, 48)
|
||||||
|
- Responsive breakpoints: mobile (<650), tablet (650-1100), desktop (>1100)
|
||||||
|
- Support all screen sizes with flexible layouts
|
||||||
|
- Follow Material 3 / Cupertino design guidelines
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Profile with DevTools before optimizing
|
||||||
|
- Target <16ms frame time for 60fps
|
||||||
|
- Use `RepaintBoundary` for complex animations
|
||||||
|
- Offload heavy work with `compute()`
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
### Widget Best Practices
|
||||||
|
- [ ] `const` constructors on all static widgets
|
||||||
|
- [ ] Proper `Key` on list items
|
||||||
|
- [ ] `ConsumerWidget` for state-dependent widgets
|
||||||
|
- [ ] No widget building inside `build()` method
|
||||||
|
- [ ] Extract reusable widgets to separate files
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- [ ] Immutable state objects
|
||||||
|
- [ ] `select()` for granular rebuilds
|
||||||
|
- [ ] Proper provider scoping
|
||||||
|
- [ ] Dispose controllers and subscriptions
|
||||||
|
- [ ] Handle loading/error states
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- [ ] GoRouter with typed routes
|
||||||
|
- [ ] Auth guards via redirect
|
||||||
|
- [ ] Deep linking support
|
||||||
|
- [ ] State preservation across routes
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] Profile mode testing (`flutter run --profile`)
|
||||||
|
- [ ] <16ms frame rendering time
|
||||||
|
- [ ] No unnecessary rebuilds (DevTools check)
|
||||||
|
- [ ] Images cached and resized
|
||||||
|
- [ ] Heavy computation in isolates
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Widget tests for UI components
|
||||||
|
- [ ] Unit tests for business logic
|
||||||
|
- [ ] Integration tests for user flows
|
||||||
|
- [ ] Bloc tests with `blocTest()`
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
| Topic | Reference |
|
||||||
|
|-------|-----------|
|
||||||
|
| Widget patterns, const optimization, responsive layout | [Widget Patterns](references/widget-patterns.md) |
|
||||||
|
| Riverpod providers, notifiers, async state | [Riverpod State Management](references/riverpod-state.md) |
|
||||||
|
| Bloc, Cubit, event-driven state | [Bloc State Management](references/bloc-state.md) |
|
||||||
|
| GoRouter setup, routes, deep linking | [GoRouter Navigation](references/gorouter-navigation.md) |
|
||||||
|
| Feature-based structure, dependencies | [Project Structure](references/project-structure.md) |
|
||||||
|
| Profiling, const optimization, DevTools | [Performance Optimization](references/performance.md) |
|
||||||
|
| Widget tests, integration tests, mocking | [Testing Strategies](references/testing.md) |
|
||||||
|
| iOS/Android/Web specific implementations | [Platform Integration](references/platform-specific.md) |
|
||||||
|
| Implicit/explicit animations, Hero, transitions | [Animations](references/animations.md) |
|
||||||
|
| Dio, interceptors, error handling, caching | [Networking](references/networking.md) |
|
||||||
|
| Form validation, FormField, input formatters | [Forms](references/forms.md) |
|
||||||
|
| i18n, flutter_localizations, intl | [Localization](references/localization.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Flutter, Dart, Material Design, and Cupertino are trademarks of Google LLC and Apple Inc. respectively. Riverpod, Bloc, and GoRouter are open-source packages by their respective maintainers.
|
||||||
497
skills/flutter-dev/references/animations.md
Normal file
497
skills/flutter-dev/references/animations.md
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
# Animations
|
||||||
|
|
||||||
|
Flutter animation patterns covering implicit animations, explicit animations, Hero transitions, and page transitions.
|
||||||
|
|
||||||
|
## Implicit Animations
|
||||||
|
|
||||||
|
Use implicit animations for simple property changes:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ImplicitAnimationExample extends StatefulWidget {
|
||||||
|
const ImplicitAnimationExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImplicitAnimationExample> createState() => _ImplicitAnimationExampleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImplicitAnimationExampleState extends State<ImplicitAnimationExample> {
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
width: _expanded ? 200 : 100,
|
||||||
|
height: _expanded ? 200 : 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _expanded ? Colors.blue : Colors.red,
|
||||||
|
borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
|
||||||
|
),
|
||||||
|
child: const Center(child: Text('Tap me')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Implicit Widgets
|
||||||
|
|
||||||
|
| Widget | Animates |
|
||||||
|
|--------|----------|
|
||||||
|
| `AnimatedContainer` | Size, color, padding, decoration |
|
||||||
|
| `AnimatedOpacity` | Opacity |
|
||||||
|
| `AnimatedPadding` | Padding |
|
||||||
|
| `AnimatedPositioned` | Position in Stack |
|
||||||
|
| `AnimatedAlign` | Alignment |
|
||||||
|
| `AnimatedCrossFade` | Cross-fade between two widgets |
|
||||||
|
| `AnimatedSwitcher` | Transition between child widgets |
|
||||||
|
| `AnimatedDefaultTextStyle` | Text style |
|
||||||
|
| `AnimatedScale` | Scale transform |
|
||||||
|
| `AnimatedRotation` | Rotation transform |
|
||||||
|
| `AnimatedSlide` | Slide offset |
|
||||||
|
|
||||||
|
### AnimatedSwitcher
|
||||||
|
|
||||||
|
```dart
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(animation),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _showFirst
|
||||||
|
? const Icon(Icons.check, key: ValueKey('check'))
|
||||||
|
: const Icon(Icons.close, key: ValueKey('close')),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Explicit Animations
|
||||||
|
|
||||||
|
Use explicit animations for complex, custom, or controlled animations:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ExplicitAnimationExample extends StatefulWidget {
|
||||||
|
const ExplicitAnimationExample({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExplicitAnimationExample> createState() => _ExplicitAnimationExampleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExplicitAnimationExampleState extends State<ExplicitAnimationExample>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final Animation<double> _scaleAnimation;
|
||||||
|
late final Animation<double> _rotationAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||||
|
);
|
||||||
|
|
||||||
|
_rotationAnimation = Tween<double>(begin: 0, end: 0.1).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.elasticOut),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: (_) => _controller.forward(),
|
||||||
|
onTapUp: (_) => _controller.reverse(),
|
||||||
|
onTapCancel: () => _controller.reverse(),
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: _rotationAnimation.value,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Card(child: Padding(padding: EdgeInsets.all(24), child: Text('Press me'))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation with Hooks
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
class AnimatedButtonHook extends HookWidget {
|
||||||
|
const AnimatedButtonHook({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final controller = useAnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
);
|
||||||
|
final scale = useAnimation(
|
||||||
|
Tween<double>(begin: 1.0, end: 0.95).animate(
|
||||||
|
CurvedAnimation(parent: controller, curve: Curves.easeInOut),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: (_) => controller.forward(),
|
||||||
|
onTapUp: (_) => controller.reverse(),
|
||||||
|
onTapCancel: () => controller.reverse(),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: scale,
|
||||||
|
child: const Card(child: Text('Animated Button')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Staggered Animations
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class StaggeredAnimation extends StatefulWidget {
|
||||||
|
const StaggeredAnimation({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StaggeredAnimation> createState() => _StaggeredAnimationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StaggeredAnimationState extends State<StaggeredAnimation>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller;
|
||||||
|
late final List<Animation<double>> _itemAnimations;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 1500),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_itemAnimations = List.generate(5, (index) {
|
||||||
|
final start = index * 0.1;
|
||||||
|
final end = start + 0.4;
|
||||||
|
return Tween<double>(begin: 0, end: 1).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Interval(start, end.clamp(0, 1), curve: Curves.easeOut),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: List.generate(5, (index) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _itemAnimations[index],
|
||||||
|
builder: (context, child) {
|
||||||
|
return Opacity(
|
||||||
|
opacity: _itemAnimations[index].value,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, 20 * (1 - _itemAnimations[index].value)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: ListTile(title: Text('Item $index')),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hero Animations
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class HeroSourcePage extends StatelessWidget {
|
||||||
|
const HeroSourcePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GridView.builder(
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
),
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => context.push('/detail/${item.id}'),
|
||||||
|
child: Hero(
|
||||||
|
tag: 'hero-${item.id}',
|
||||||
|
child: Image.network(item.imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HeroDetailPage extends StatelessWidget {
|
||||||
|
final String itemId;
|
||||||
|
|
||||||
|
const HeroDetailPage({super.key, required this.itemId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final item = getItem(itemId);
|
||||||
|
return Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Hero(
|
||||||
|
tag: 'hero-${item.id}',
|
||||||
|
child: Image.network(item.imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(item.title, style: Theme.of(context).textTheme.headlineMedium),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hero with Custom Flight
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Hero(
|
||||||
|
tag: 'avatar-$userId',
|
||||||
|
flightShuttleBuilder: (
|
||||||
|
flightContext,
|
||||||
|
animation,
|
||||||
|
flightDirection,
|
||||||
|
fromHeroContext,
|
||||||
|
toHeroContext,
|
||||||
|
) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: lerpDouble(24, 48, animation.value),
|
||||||
|
backgroundImage: NetworkImage(avatarUrl),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: CircleAvatar(radius: 24, backgroundImage: NetworkImage(avatarUrl)),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Page Transitions
|
||||||
|
|
||||||
|
### GoRouter Custom Transitions
|
||||||
|
|
||||||
|
```dart
|
||||||
|
GoRoute(
|
||||||
|
path: '/detail/:id',
|
||||||
|
pageBuilder: (context, state) {
|
||||||
|
return CustomTransitionPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: DetailPage(id: state.pathParameters['id']!),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.05),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Transition Patterns
|
||||||
|
|
||||||
|
```dart
|
||||||
|
extension PageTransitions on CustomTransitionPage {
|
||||||
|
static CustomTransitionPage<T> fade<T>({
|
||||||
|
required LocalKey key,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return CustomTransitionPage<T>(
|
||||||
|
key: key,
|
||||||
|
child: child,
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return FadeTransition(opacity: animation, child: child);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static CustomTransitionPage<T> slideUp<T>({
|
||||||
|
required LocalKey key,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return CustomTransitionPage<T>(
|
||||||
|
key: key,
|
||||||
|
child: child,
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
)),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static CustomTransitionPage<T> scale<T>({
|
||||||
|
required LocalKey key,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return CustomTransitionPage<T>(
|
||||||
|
key: key,
|
||||||
|
child: child,
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return ScaleTransition(
|
||||||
|
scale: Tween<double>(begin: 0.9, end: 1).animate(
|
||||||
|
CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||||
|
),
|
||||||
|
child: FadeTransition(opacity: animation, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Axis Transition
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:animations/animations.dart';
|
||||||
|
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings',
|
||||||
|
pageBuilder: (context, state) {
|
||||||
|
return CustomTransitionPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const SettingsPage(),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return SharedAxisTransition(
|
||||||
|
animation: animation,
|
||||||
|
secondaryAnimation: secondaryAnimation,
|
||||||
|
transitionType: SharedAxisTransitionType.horizontal,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Curves
|
||||||
|
|
||||||
|
| Curve | Usage |
|
||||||
|
|-------|-------|
|
||||||
|
| `Curves.easeInOut` | General purpose (default) |
|
||||||
|
| `Curves.easeOut` | Deceleration (entering) |
|
||||||
|
| `Curves.easeIn` | Acceleration (exiting) |
|
||||||
|
| `Curves.elasticOut` | Bouncy effect |
|
||||||
|
| `Curves.bounceOut` | Bounce at end |
|
||||||
|
| `Curves.fastOutSlowIn` | Material standard |
|
||||||
|
| `Curves.easeOutCubic` | Smooth deceleration |
|
||||||
|
|
||||||
|
## Animation Performance
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class PerformantAnimation extends StatelessWidget {
|
||||||
|
const PerformantAnimation({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(animation.value * 100, 0),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const ExpensiveWidget(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Tips
|
||||||
|
|
||||||
|
| Tip | Implementation |
|
||||||
|
|-----|----------------|
|
||||||
|
| Use `child` parameter | Pass static content to `child` in `AnimatedBuilder` |
|
||||||
|
| `RepaintBoundary` | Isolate animated widgets |
|
||||||
|
| Avoid `Opacity` widget | Use `FadeTransition` instead |
|
||||||
|
| Prefer transforms | `Transform` is cheaper than layout changes |
|
||||||
|
| Pre-compute values | Calculate in `initState`, not `build` |
|
||||||
|
|
||||||
|
## Animation Checklist
|
||||||
|
|
||||||
|
| Item | Implementation |
|
||||||
|
|------|----------------|
|
||||||
|
| Simple animations | Use implicit widgets |
|
||||||
|
| Complex sequences | Use `AnimationController` |
|
||||||
|
| Widget transitions | `AnimatedSwitcher` with key |
|
||||||
|
| Cross-page elements | `Hero` with unique tags |
|
||||||
|
| Page transitions | `CustomTransitionPage` |
|
||||||
|
| Performance | `RepaintBoundary` + `child` parameter |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter and Material Design are trademarks of Google LLC.*
|
||||||
281
skills/flutter-dev/references/bloc-state.md
Normal file
281
skills/flutter-dev/references/bloc-state.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Bloc State Management
|
||||||
|
|
||||||
|
Bloc state management guide covering events, states, Cubit, and widget integration for complex business logic.
|
||||||
|
|
||||||
|
## When to Use Bloc
|
||||||
|
|
||||||
|
Use **Bloc/Cubit** when you need:
|
||||||
|
- Explicit event → state transitions
|
||||||
|
- Complex business logic with multiple events
|
||||||
|
- Predictable, testable state flows
|
||||||
|
- Clear separation between UI and logic
|
||||||
|
|
||||||
|
| Use Case | Recommended |
|
||||||
|
|----------|-------------|
|
||||||
|
| Simple mutable state | Riverpod |
|
||||||
|
| Computed values | Riverpod |
|
||||||
|
| Event-driven workflows | Bloc |
|
||||||
|
| Forms, auth, wizards | Bloc |
|
||||||
|
| Feature modules with complex logic | Bloc |
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
| Concept | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| Event | User or system input that triggers state change |
|
||||||
|
| State | Immutable representation of UI state |
|
||||||
|
| Bloc | Maps events to new states |
|
||||||
|
| Cubit | Simplified Bloc without events |
|
||||||
|
|
||||||
|
## Cubit (Recommended for Simpler Logic)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
class CounterCubit extends Cubit<int> {
|
||||||
|
CounterCubit() : super(0);
|
||||||
|
|
||||||
|
void increment() => emit(state + 1);
|
||||||
|
void decrement() => emit(state - 1);
|
||||||
|
void reset() => emit(0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Bloc Setup
|
||||||
|
|
||||||
|
### Event Definition
|
||||||
|
|
||||||
|
```dart
|
||||||
|
sealed class CounterEvent {}
|
||||||
|
|
||||||
|
final class CounterIncremented extends CounterEvent {}
|
||||||
|
final class CounterDecremented extends CounterEvent {}
|
||||||
|
final class CounterReset extends CounterEvent {}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Definition
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterState {
|
||||||
|
final int value;
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
const CounterState({
|
||||||
|
required this.value,
|
||||||
|
this.isLoading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
CounterState copyWith({int? value, bool? isLoading}) {
|
||||||
|
return CounterState(
|
||||||
|
value: value ?? this.value,
|
||||||
|
isLoading: isLoading ?? this.isLoading,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bloc Implementation
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterBloc extends Bloc<CounterEvent, CounterState> {
|
||||||
|
CounterBloc() : super(const CounterState(value: 0)) {
|
||||||
|
on<CounterIncremented>(_onIncremented);
|
||||||
|
on<CounterDecremented>(_onDecremented);
|
||||||
|
on<CounterReset>(_onReset);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onIncremented(CounterIncremented event, Emitter<CounterState> emit) {
|
||||||
|
emit(state.copyWith(value: state.value + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDecremented(CounterDecremented event, Emitter<CounterState> emit) {
|
||||||
|
emit(state.copyWith(value: state.value - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onReset(CounterReset event, Emitter<CounterState> emit) {
|
||||||
|
emit(const CounterState(value: 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Providing Bloc to Widget Tree
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Single bloc
|
||||||
|
BlocProvider(
|
||||||
|
create: (_) => CounterBloc(),
|
||||||
|
child: const CounterScreen(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Multiple blocs
|
||||||
|
MultiBlocProvider(
|
||||||
|
providers: [
|
||||||
|
BlocProvider(create: (_) => AuthBloc()),
|
||||||
|
BlocProvider(create: (_) => ProfileBloc()),
|
||||||
|
BlocProvider(create: (_) => SettingsBloc()),
|
||||||
|
],
|
||||||
|
child: const AppRoot(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Bloc in Widgets
|
||||||
|
|
||||||
|
### BlocBuilder (UI Rebuilds)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CounterScreen extends StatelessWidget {
|
||||||
|
const CounterScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<CounterBloc, CounterState>(
|
||||||
|
buildWhen: (prev, curr) => prev.value != curr.value,
|
||||||
|
builder: (context, state) {
|
||||||
|
return Text(
|
||||||
|
state.value.toString(),
|
||||||
|
style: Theme.of(context).textTheme.displayLarge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### BlocListener (Side Effects)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocListener<AuthBloc, AuthState>(
|
||||||
|
listenWhen: (prev, curr) => prev.status != curr.status,
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == AuthStatus.failure) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(state.errorMessage ?? 'Error')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.status == AuthStatus.authenticated) {
|
||||||
|
context.go('/home');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const LoginForm(),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### BlocConsumer (Builder + Listener)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocConsumer<FormBloc, FormState>(
|
||||||
|
listenWhen: (prev, curr) => prev.status != curr.status,
|
||||||
|
listener: (context, state) {
|
||||||
|
if (state.status == FormStatus.success) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buildWhen: (prev, curr) => prev.isValid != curr.isValid,
|
||||||
|
builder: (context, state) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: state.isValid
|
||||||
|
? () => context.read<FormBloc>().add(FormSubmitted())
|
||||||
|
: null,
|
||||||
|
child: const Text('Submit'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### BlocSelector (Granular Rebuilds)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
BlocSelector<UserBloc, UserState, String>(
|
||||||
|
selector: (state) => state.user.name,
|
||||||
|
builder: (context, name) {
|
||||||
|
return Text('Hello, $name');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Async Bloc Pattern
|
||||||
|
|
||||||
|
```dart
|
||||||
|
on<UserRequested>((event, emit) async {
|
||||||
|
emit(state.copyWith(status: UserStatus.loading));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final user = await repository.fetchUser(event.userId);
|
||||||
|
emit(state.copyWith(status: UserStatus.success, user: user));
|
||||||
|
} catch (e) {
|
||||||
|
emit(state.copyWith(status: UserStatus.failure, error: e.toString()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bloc + GoRouter Auth Guard
|
||||||
|
|
||||||
|
```dart
|
||||||
|
redirect: (context, state) {
|
||||||
|
final authState = context.read<AuthBloc>().state;
|
||||||
|
final isAuthRoute = state.matchedLocation.startsWith('/auth');
|
||||||
|
|
||||||
|
if (authState.status != AuthStatus.authenticated && !isAuthRoute) {
|
||||||
|
return '/auth/login';
|
||||||
|
}
|
||||||
|
if (authState.status == AuthStatus.authenticated && isAuthRoute) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Bloc
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:bloc_test/bloc_test.dart';
|
||||||
|
|
||||||
|
blocTest<CounterBloc, CounterState>(
|
||||||
|
'emits incremented value when CounterIncremented added',
|
||||||
|
build: () => CounterBloc(),
|
||||||
|
act: (bloc) => bloc.add(CounterIncremented()),
|
||||||
|
expect: () => [const CounterState(value: 1)],
|
||||||
|
);
|
||||||
|
|
||||||
|
blocTest<CounterBloc, CounterState>(
|
||||||
|
'emits multiple states',
|
||||||
|
build: () => CounterBloc(),
|
||||||
|
act: (bloc) {
|
||||||
|
bloc.add(CounterIncremented());
|
||||||
|
bloc.add(CounterIncremented());
|
||||||
|
bloc.add(CounterDecremented());
|
||||||
|
},
|
||||||
|
expect: () => [
|
||||||
|
const CounterState(value: 1),
|
||||||
|
const CounterState(value: 2),
|
||||||
|
const CounterState(value: 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Keep states immutable | Mutate state directly |
|
||||||
|
| Use small, focused blocs | Create "god blocs" with everything |
|
||||||
|
| One feature = one bloc | Share blocs across unrelated features |
|
||||||
|
| Use Cubit for simple cases | Overcomplicate with Bloc unnecessarily |
|
||||||
|
| Test all state transitions | Skip bloc testing |
|
||||||
|
| Use `buildWhen`/`listenWhen` | Rebuild on every state change |
|
||||||
|
|
||||||
|
## Widget Reference
|
||||||
|
|
||||||
|
| Widget | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `BlocBuilder` | UI rebuilds based on state |
|
||||||
|
| `BlocListener` | Side effects (navigation, snackbar) |
|
||||||
|
| `BlocConsumer` | Both builder and listener |
|
||||||
|
| `BlocSelector` | Granular state selection |
|
||||||
|
| `BlocProvider` | Dependency injection |
|
||||||
|
| `MultiBlocProvider` | Multiple bloc injection |
|
||||||
|
| `RepositoryProvider` | Repository injection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Bloc is an open-source state management library by Felix Angelov.*
|
||||||
656
skills/flutter-dev/references/forms.md
Normal file
656
skills/flutter-dev/references/forms.md
Normal file
@@ -0,0 +1,656 @@
|
|||||||
|
# Forms
|
||||||
|
|
||||||
|
Form validation, FormField patterns, input formatting, and reusable form components for Flutter.
|
||||||
|
|
||||||
|
## Basic Form Setup
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class LoginForm extends StatefulWidget {
|
||||||
|
const LoginForm({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginForm> createState() => _LoginFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginFormState extends State<LoginForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authService.login(
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
prefixIcon: Icon(Icons.email_outlined),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autocorrect: false,
|
||||||
|
validator: Validators.email,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Password',
|
||||||
|
prefixIcon: Icon(Icons.lock_outlined),
|
||||||
|
),
|
||||||
|
obscureText: true,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) => _submit(),
|
||||||
|
validator: Validators.password,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _submit,
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Login'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validators
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class Validators {
|
||||||
|
static String? required(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'This field is required';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? email(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Email is required';
|
||||||
|
}
|
||||||
|
final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||||
|
if (!regex.hasMatch(value.trim())) {
|
||||||
|
return 'Enter a valid email address';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? password(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Password is required';
|
||||||
|
}
|
||||||
|
if (value.length < 8) {
|
||||||
|
return 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? strongPassword(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Password is required';
|
||||||
|
}
|
||||||
|
if (value.length < 8) {
|
||||||
|
return 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
if (!RegExp(r'[A-Z]').hasMatch(value)) {
|
||||||
|
return 'Password must contain an uppercase letter';
|
||||||
|
}
|
||||||
|
if (!RegExp(r'[a-z]').hasMatch(value)) {
|
||||||
|
return 'Password must contain a lowercase letter';
|
||||||
|
}
|
||||||
|
if (!RegExp(r'[0-9]').hasMatch(value)) {
|
||||||
|
return 'Password must contain a number';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? phone(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return 'Phone number is required';
|
||||||
|
}
|
||||||
|
final digits = value.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.length < 10 || digits.length > 15) {
|
||||||
|
return 'Enter a valid phone number';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? minLength(int min) {
|
||||||
|
return (String? value) {
|
||||||
|
if (value == null || value.length < min) {
|
||||||
|
return 'Must be at least $min characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? maxLength(int max) {
|
||||||
|
return (String? value) {
|
||||||
|
if (value != null && value.length > max) {
|
||||||
|
return 'Must be at most $max characters';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? Function(String?) combine(List<String? Function(String?)> validators) {
|
||||||
|
return (String? value) {
|
||||||
|
for (final validator in validators) {
|
||||||
|
final error = validator(value);
|
||||||
|
if (error != null) return error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? match(String pattern, String message) {
|
||||||
|
return (String? value) {
|
||||||
|
if (value != null && !RegExp(pattern).hasMatch(value)) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? confirmPassword(TextEditingController passwordController) {
|
||||||
|
return (String? value) {
|
||||||
|
if (value != passwordController.text) {
|
||||||
|
return 'Passwords do not match';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Input Formatters
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class PhoneInputFormatter extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
for (int i = 0; i < digits.length && i < 10; i++) {
|
||||||
|
if (i == 3 || i == 6) buffer.write('-');
|
||||||
|
buffer.write(digits[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: buffer.toString(),
|
||||||
|
selection: TextSelection.collapsed(offset: buffer.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreditCardFormatter extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
for (int i = 0; i < digits.length && i < 16; i++) {
|
||||||
|
if (i > 0 && i % 4 == 0) buffer.write(' ');
|
||||||
|
buffer.write(digits[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: buffer.toString(),
|
||||||
|
selection: TextSelection.collapsed(offset: buffer.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CurrencyInputFormatter extends TextInputFormatter {
|
||||||
|
final int decimalPlaces;
|
||||||
|
|
||||||
|
CurrencyInputFormatter({this.decimalPlaces = 2});
|
||||||
|
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
if (newValue.text.isEmpty) return newValue;
|
||||||
|
|
||||||
|
final digits = newValue.text.replaceAll(RegExp(r'[^\d]'), '');
|
||||||
|
if (digits.isEmpty) return const TextEditingValue(text: '');
|
||||||
|
|
||||||
|
final value = int.parse(digits) / 100;
|
||||||
|
final formatted = value.toStringAsFixed(decimalPlaces);
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: formatted.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpperCaseFormatter extends TextInputFormatter {
|
||||||
|
@override
|
||||||
|
TextEditingValue formatEditUpdate(
|
||||||
|
TextEditingValue oldValue,
|
||||||
|
TextEditingValue newValue,
|
||||||
|
) {
|
||||||
|
return newValue.copyWith(text: newValue.text.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Formatters
|
||||||
|
|
||||||
|
```dart
|
||||||
|
TextFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Phone'),
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
PhoneInputFormatter(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
TextFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Amount'),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'[\d.]')),
|
||||||
|
CurrencyInputFormatter(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom FormFields
|
||||||
|
|
||||||
|
### Dropdown FormField
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DropdownFormField<T> extends FormField<T> {
|
||||||
|
DropdownFormField({
|
||||||
|
super.key,
|
||||||
|
required List<DropdownMenuItem<T>> items,
|
||||||
|
super.initialValue,
|
||||||
|
super.validator,
|
||||||
|
super.onSaved,
|
||||||
|
String? labelText,
|
||||||
|
String? hintText,
|
||||||
|
ValueChanged<T?>? onChanged,
|
||||||
|
}) : super(
|
||||||
|
builder: (state) {
|
||||||
|
return InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: labelText,
|
||||||
|
errorText: state.errorText,
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<T>(
|
||||||
|
value: state.value,
|
||||||
|
hint: hintText != null ? Text(hintText) : null,
|
||||||
|
isExpanded: true,
|
||||||
|
items: items,
|
||||||
|
onChanged: (value) {
|
||||||
|
state.didChange(value);
|
||||||
|
onChanged?.call(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checkbox FormField
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CheckboxFormField extends FormField<bool> {
|
||||||
|
CheckboxFormField({
|
||||||
|
super.key,
|
||||||
|
required Widget label,
|
||||||
|
super.initialValue = false,
|
||||||
|
super.validator,
|
||||||
|
super.onSaved,
|
||||||
|
}) : super(
|
||||||
|
builder: (state) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: state.value ?? false,
|
||||||
|
onChanged: state.didChange,
|
||||||
|
),
|
||||||
|
Expanded(child: GestureDetector(
|
||||||
|
onTap: () => state.didChange(!(state.value ?? false)),
|
||||||
|
child: label,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (state.hasError)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12, top: 4),
|
||||||
|
child: Text(
|
||||||
|
state.errorText!,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(state.context).colorScheme.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Date Picker FormField
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class DatePickerFormField extends FormField<DateTime> {
|
||||||
|
DatePickerFormField({
|
||||||
|
super.key,
|
||||||
|
super.initialValue,
|
||||||
|
super.validator,
|
||||||
|
super.onSaved,
|
||||||
|
String? labelText,
|
||||||
|
DateTime? firstDate,
|
||||||
|
DateTime? lastDate,
|
||||||
|
}) : super(
|
||||||
|
builder: (state) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: state.context,
|
||||||
|
initialDate: state.value ?? DateTime.now(),
|
||||||
|
firstDate: firstDate ?? DateTime(1900),
|
||||||
|
lastDate: lastDate ?? DateTime(2100),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
state.didChange(picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: labelText,
|
||||||
|
errorText: state.errorText,
|
||||||
|
suffixIcon: const Icon(Icons.calendar_today),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
state.value != null
|
||||||
|
? DateFormat.yMMMd().format(state.value!)
|
||||||
|
: 'Select date',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form with Hooks
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
class HookLoginForm extends HookWidget {
|
||||||
|
const HookLoginForm({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final formKey = useMemoized(GlobalKey<FormState>.new);
|
||||||
|
final emailController = useTextEditingController();
|
||||||
|
final passwordController = useTextEditingController();
|
||||||
|
final emailFocus = useFocusNode();
|
||||||
|
final passwordFocus = useFocusNode();
|
||||||
|
final isLoading = useState(false);
|
||||||
|
|
||||||
|
Future<void> submit() async {
|
||||||
|
if (!formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
await authService.login(
|
||||||
|
email: emailController.text.trim(),
|
||||||
|
password: passwordController.text,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: emailController,
|
||||||
|
focusNode: emailFocus,
|
||||||
|
decoration: const InputDecoration(labelText: 'Email'),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
onFieldSubmitted: (_) => passwordFocus.requestFocus(),
|
||||||
|
validator: Validators.email,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: passwordController,
|
||||||
|
focusNode: passwordFocus,
|
||||||
|
decoration: const InputDecoration(labelText: 'Password'),
|
||||||
|
obscureText: true,
|
||||||
|
onFieldSubmitted: (_) => submit(),
|
||||||
|
validator: Validators.password,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoading.value ? null : submit,
|
||||||
|
child: isLoading.value
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: const Text('Login'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Side Validation
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ServerValidationForm extends StatefulWidget {
|
||||||
|
const ServerValidationForm({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ServerValidationForm> createState() => _ServerValidationFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerValidationFormState extends State<ServerValidationForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
|
Map<String, List<String>> _serverErrors = {};
|
||||||
|
|
||||||
|
String? _emailValidator(String? value) {
|
||||||
|
final clientError = Validators.email(value);
|
||||||
|
if (clientError != null) return clientError;
|
||||||
|
|
||||||
|
final serverError = _serverErrors['email'];
|
||||||
|
if (serverError != null && serverError.isNotEmpty) {
|
||||||
|
return serverError.first;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
setState(() => _serverErrors = {});
|
||||||
|
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.register(email: _emailController.text);
|
||||||
|
} on ValidationException catch (e) {
|
||||||
|
setState(() => _serverErrors = e.errors);
|
||||||
|
_formKey.currentState!.validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
decoration: const InputDecoration(labelText: 'Email'),
|
||||||
|
validator: _emailValidator,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_serverErrors.containsKey('email')) {
|
||||||
|
setState(() => _serverErrors.remove('email'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _submit,
|
||||||
|
child: const Text('Register'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-Save Form
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AutoSaveForm extends StatefulWidget {
|
||||||
|
const AutoSaveForm({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AutoSaveForm> createState() => _AutoSaveFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AutoSaveFormState extends State<AutoSaveForm> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
Timer? _debounce;
|
||||||
|
bool _hasChanges = false;
|
||||||
|
|
||||||
|
void _onChanged() {
|
||||||
|
setState(() => _hasChanges = true);
|
||||||
|
|
||||||
|
_debounce?.cancel();
|
||||||
|
_debounce = Timer(const Duration(seconds: 2), _autoSave);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _autoSave() async {
|
||||||
|
if (!_hasChanges) return;
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
_formKey.currentState!.save();
|
||||||
|
await saveToServer();
|
||||||
|
setState(() => _hasChanges = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounce?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Form(
|
||||||
|
key: _formKey,
|
||||||
|
onChanged: _onChanged,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (_hasChanges)
|
||||||
|
const Text('Saving...', style: TextStyle(color: Colors.grey)),
|
||||||
|
TextFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Title'),
|
||||||
|
onSaved: (value) => saveField('title', value),
|
||||||
|
),
|
||||||
|
TextFormField(
|
||||||
|
decoration: const InputDecoration(labelText: 'Description'),
|
||||||
|
maxLines: 3,
|
||||||
|
onSaved: (value) => saveField('description', value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Keyboard Types
|
||||||
|
|
||||||
|
| Type | Usage |
|
||||||
|
|------|-------|
|
||||||
|
| `TextInputType.text` | General text |
|
||||||
|
| `TextInputType.emailAddress` | Email with @ keyboard |
|
||||||
|
| `TextInputType.phone` | Phone number pad |
|
||||||
|
| `TextInputType.number` | Numeric keyboard |
|
||||||
|
| `TextInputType.numberWithOptions(decimal: true)` | Numbers with decimal |
|
||||||
|
| `TextInputType.multiline` | Multi-line text |
|
||||||
|
| `TextInputType.url` | URL with shortcuts |
|
||||||
|
|
||||||
|
## Form Checklist
|
||||||
|
|
||||||
|
| Item | Implementation |
|
||||||
|
|------|----------------|
|
||||||
|
| GlobalKey | `GlobalKey<FormState>()` for form |
|
||||||
|
| Dispose controllers | Clean up in `dispose()` |
|
||||||
|
| Validation | Client + server-side |
|
||||||
|
| Input formatters | Phone, currency, etc. |
|
||||||
|
| Keyboard types | Match input type |
|
||||||
|
| Text actions | `textInputAction` for flow |
|
||||||
|
| Loading state | Disable during submission |
|
||||||
|
| Error display | Show below fields |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter and Material Design are trademarks of Google LLC.*
|
||||||
257
skills/flutter-dev/references/gorouter-navigation.md
Normal file
257
skills/flutter-dev/references/gorouter-navigation.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# GoRouter Navigation
|
||||||
|
|
||||||
|
GoRouter navigation guide covering route setup, guards, deep linking, and shell routes.
|
||||||
|
|
||||||
|
## Basic Setup
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
final goRouter = GoRouter(
|
||||||
|
initialLocation: '/',
|
||||||
|
debugLogDiagnostics: true,
|
||||||
|
redirect: (context, state) {
|
||||||
|
final isLoggedIn = /* check auth state */;
|
||||||
|
final isAuthRoute = state.matchedLocation.startsWith('/auth');
|
||||||
|
|
||||||
|
if (!isLoggedIn && !isAuthRoute) {
|
||||||
|
return '/auth/login';
|
||||||
|
}
|
||||||
|
if (isLoggedIn && isAuthRoute) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
builder: (context, state) => const HomeScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'details/:id',
|
||||||
|
name: 'details',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
final extra = state.extra as Map<String, dynamic>?;
|
||||||
|
return DetailsScreen(id: id, title: extra?['title']);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/auth/login',
|
||||||
|
name: 'login',
|
||||||
|
builder: (context, state) => const LoginScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### App Integration
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp.router(
|
||||||
|
routerConfig: goRouter,
|
||||||
|
theme: AppTheme.light,
|
||||||
|
darkTheme: AppTheme.dark,
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Navigation Methods
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Navigate and replace entire stack
|
||||||
|
context.go('/details/123');
|
||||||
|
|
||||||
|
// Navigate and add to stack (can go back)
|
||||||
|
context.push('/details/123');
|
||||||
|
|
||||||
|
// Go back
|
||||||
|
context.pop();
|
||||||
|
|
||||||
|
// Go back with result
|
||||||
|
context.pop(result);
|
||||||
|
|
||||||
|
// Replace current route
|
||||||
|
context.pushReplacement('/home');
|
||||||
|
|
||||||
|
// Navigate with extra data
|
||||||
|
context.push('/details/123', extra: {'title': 'Item Title'});
|
||||||
|
|
||||||
|
// Navigate by name
|
||||||
|
context.goNamed('details', pathParameters: {'id': '123'});
|
||||||
|
context.pushNamed('details', pathParameters: {'id': '123'}, extra: data);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation Reference
|
||||||
|
|
||||||
|
| Method | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| `context.go()` | Navigate, replace entire stack |
|
||||||
|
| `context.push()` | Navigate, add to stack |
|
||||||
|
| `context.pop()` | Go back one level |
|
||||||
|
| `context.pushReplacement()` | Replace current route |
|
||||||
|
| `context.goNamed()` | Navigate by route name |
|
||||||
|
| `context.canPop()` | Check if can go back |
|
||||||
|
|
||||||
|
## Shell Routes (Persistent UI)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final goRouter = GoRouter(
|
||||||
|
routes: [
|
||||||
|
ShellRoute(
|
||||||
|
builder: (context, state, child) {
|
||||||
|
return ScaffoldWithNavBar(child: child);
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/home',
|
||||||
|
builder: (_, __) => const HomeScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/search',
|
||||||
|
builder: (_, __) => const SearchScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/profile',
|
||||||
|
builder: (_, __) => const ProfileScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
class ScaffoldWithNavBar extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ScaffoldWithNavBar({super.key, required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: child,
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: _calculateSelectedIndex(context),
|
||||||
|
onDestinationSelected: (index) => _onItemTapped(index, context),
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
|
||||||
|
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
|
||||||
|
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _calculateSelectedIndex(BuildContext context) {
|
||||||
|
final location = GoRouterState.of(context).matchedLocation;
|
||||||
|
if (location.startsWith('/home')) return 0;
|
||||||
|
if (location.startsWith('/search')) return 1;
|
||||||
|
if (location.startsWith('/profile')) return 2;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemTapped(int index, BuildContext context) {
|
||||||
|
switch (index) {
|
||||||
|
case 0: context.go('/home');
|
||||||
|
case 1: context.go('/search');
|
||||||
|
case 2: context.go('/profile');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
```dart
|
||||||
|
GoRoute(
|
||||||
|
path: '/search',
|
||||||
|
builder: (context, state) {
|
||||||
|
final query = state.uri.queryParameters['q'] ?? '';
|
||||||
|
final page = int.tryParse(state.uri.queryParameters['page'] ?? '1') ?? 1;
|
||||||
|
return SearchScreen(query: query, page: page);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Navigate with query params
|
||||||
|
context.go('/search?q=flutter&page=2');
|
||||||
|
context.goNamed('search', queryParameters: {'q': 'flutter', 'page': '2'});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Riverpod Integration
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
|
return GoRouter(
|
||||||
|
refreshListenable: authState,
|
||||||
|
redirect: (context, state) {
|
||||||
|
final isLoggedIn = authState.isAuthenticated;
|
||||||
|
final isAuthRoute = state.matchedLocation.startsWith('/auth');
|
||||||
|
|
||||||
|
if (!isLoggedIn && !isAuthRoute) return '/auth/login';
|
||||||
|
if (isLoggedIn && isAuthRoute) return '/';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [...],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// In app.dart
|
||||||
|
class MyApp extends ConsumerWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
return MaterialApp.router(routerConfig: router);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```dart
|
||||||
|
final goRouter = GoRouter(
|
||||||
|
errorBuilder: (context, state) {
|
||||||
|
return ErrorScreen(error: state.error);
|
||||||
|
},
|
||||||
|
routes: [...],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deep Linking
|
||||||
|
|
||||||
|
Deep links work automatically when routes are configured with path parameters:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// URL: myapp://details/123
|
||||||
|
// or: https://myapp.com/details/123
|
||||||
|
GoRoute(
|
||||||
|
path: '/details/:id',
|
||||||
|
builder: (context, state) => DetailsScreen(id: state.pathParameters['id']!),
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Use named routes for maintainability | Hardcode paths everywhere |
|
||||||
|
| Use `push()` for detail screens | Use `go()` for all navigation |
|
||||||
|
| Pass simple data via `extra` | Pass complex objects via URL |
|
||||||
|
| Use redirect for auth guards | Check auth in every screen |
|
||||||
|
| Use ShellRoute for persistent UI | Rebuild nav bar in every screen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*GoRouter is an open-source navigation package for Flutter.*
|
||||||
510
skills/flutter-dev/references/localization.md
Normal file
510
skills/flutter-dev/references/localization.md
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
# Localization
|
||||||
|
|
||||||
|
Internationalization (i18n) patterns using flutter_localizations and intl package for Flutter applications.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# pubspec.yaml
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_localizations:
|
||||||
|
sdk: flutter
|
||||||
|
intl: ^0.19.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
generate: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### l10n Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# l10n.yaml
|
||||||
|
arb-dir: lib/l10n
|
||||||
|
template-arb-file: app_en.arb
|
||||||
|
output-localization-file: app_localizations.dart
|
||||||
|
output-class: AppLocalizations
|
||||||
|
nullable-getter: false
|
||||||
|
```
|
||||||
|
|
||||||
|
## ARB Files
|
||||||
|
|
||||||
|
### English (Template)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// lib/l10n/app_en.arb
|
||||||
|
{
|
||||||
|
"@@locale": "en",
|
||||||
|
"appTitle": "My App",
|
||||||
|
"@appTitle": {
|
||||||
|
"description": "The application title"
|
||||||
|
},
|
||||||
|
"hello": "Hello",
|
||||||
|
"welcome": "Welcome, {name}!",
|
||||||
|
"@welcome": {
|
||||||
|
"description": "Welcome message with user name",
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String",
|
||||||
|
"example": "John"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
|
||||||
|
"@itemCount": {
|
||||||
|
"description": "Number of items",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lastUpdated": "Last updated: {date}",
|
||||||
|
"@lastUpdated": {
|
||||||
|
"description": "Last update timestamp",
|
||||||
|
"placeholders": {
|
||||||
|
"date": {
|
||||||
|
"type": "DateTime",
|
||||||
|
"format": "yMMMd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"price": "Price: {amount}",
|
||||||
|
"@price": {
|
||||||
|
"description": "Product price",
|
||||||
|
"placeholders": {
|
||||||
|
"amount": {
|
||||||
|
"type": "double",
|
||||||
|
"format": "currency",
|
||||||
|
"optionalParameters": {
|
||||||
|
"symbol": "$",
|
||||||
|
"decimalDigits": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gender": "{gender, select, male{He} female{She} other{They}} liked this",
|
||||||
|
"@gender": {
|
||||||
|
"description": "Gender-specific message",
|
||||||
|
"placeholders": {
|
||||||
|
"gender": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chinese
|
||||||
|
|
||||||
|
```json
|
||||||
|
// lib/l10n/app_zh.arb
|
||||||
|
{
|
||||||
|
"@@locale": "zh",
|
||||||
|
"appTitle": "我的应用",
|
||||||
|
"hello": "你好",
|
||||||
|
"welcome": "欢迎,{name}!",
|
||||||
|
"itemCount": "{count, plural, =0{没有项目} other{{count} 个项目}}",
|
||||||
|
"lastUpdated": "最后更新:{date}",
|
||||||
|
"price": "价格:{amount}",
|
||||||
|
"gender": "{gender, select, male{他} female{她} other{Ta}}喜欢了这个"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Japanese
|
||||||
|
|
||||||
|
```json
|
||||||
|
// lib/l10n/app_ja.arb
|
||||||
|
{
|
||||||
|
"@@locale": "ja",
|
||||||
|
"appTitle": "マイアプリ",
|
||||||
|
"hello": "こんにちは",
|
||||||
|
"welcome": "ようこそ、{name}さん!",
|
||||||
|
"itemCount": "{count, plural, =0{アイテムなし} other{{count}件}}",
|
||||||
|
"lastUpdated": "最終更新:{date}",
|
||||||
|
"price": "価格:{amount}",
|
||||||
|
"gender": "{gender, select, male{彼} female{彼女} other{その人}}がいいねしました"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## App Configuration
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'My App',
|
||||||
|
localizationsDelegates: const [
|
||||||
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: const [
|
||||||
|
Locale('en'),
|
||||||
|
Locale('zh'),
|
||||||
|
Locale('ja'),
|
||||||
|
],
|
||||||
|
locale: const Locale('en'),
|
||||||
|
home: const HomePage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Translations
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class HomePage extends StatelessWidget {
|
||||||
|
const HomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(l10n.appTitle)),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Text(l10n.hello),
|
||||||
|
Text(l10n.welcome('John')),
|
||||||
|
Text(l10n.itemCount(5)),
|
||||||
|
Text(l10n.lastUpdated(DateTime.now())),
|
||||||
|
Text(l10n.price(29.99)),
|
||||||
|
Text(l10n.gender('female')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extension for Convenience
|
||||||
|
|
||||||
|
```dart
|
||||||
|
extension LocalizationExtension on BuildContext {
|
||||||
|
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
Text(context.l10n.hello)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Locale Switching
|
||||||
|
|
||||||
|
### With Riverpod
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class LocaleNotifier extends _$LocaleNotifier {
|
||||||
|
@override
|
||||||
|
Locale build() {
|
||||||
|
final saved = ref.watch(sharedPreferencesProvider).getString('locale');
|
||||||
|
if (saved != null) {
|
||||||
|
return Locale(saved);
|
||||||
|
}
|
||||||
|
return const Locale('en');
|
||||||
|
}
|
||||||
|
|
||||||
|
void setLocale(Locale locale) {
|
||||||
|
ref.read(sharedPreferencesProvider).setString('locale', locale.languageCode);
|
||||||
|
state = locale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyApp extends ConsumerWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final locale = ref.watch(localeNotifierProvider);
|
||||||
|
|
||||||
|
return MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: locale,
|
||||||
|
home: const HomePage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Language Selector
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class LanguageSelector extends ConsumerWidget {
|
||||||
|
const LanguageSelector({super.key});
|
||||||
|
|
||||||
|
static const languages = [
|
||||||
|
(Locale('en'), 'English'),
|
||||||
|
(Locale('zh'), '中文'),
|
||||||
|
(Locale('ja'), '日本語'),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final currentLocale = ref.watch(localeNotifierProvider);
|
||||||
|
|
||||||
|
return PopupMenuButton<Locale>(
|
||||||
|
initialValue: currentLocale,
|
||||||
|
onSelected: (locale) {
|
||||||
|
ref.read(localeNotifierProvider.notifier).setLocale(locale);
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => languages.map((lang) {
|
||||||
|
return PopupMenuItem(
|
||||||
|
value: lang.$1,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (currentLocale == lang.$1)
|
||||||
|
const Icon(Icons.check, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(lang.$2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
child: const Icon(Icons.language),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Date and Number Formatting
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class FormattingUtils {
|
||||||
|
static String formatDate(DateTime date, String locale) {
|
||||||
|
return DateFormat.yMMMd(locale).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatDateTime(DateTime dateTime, String locale) {
|
||||||
|
return DateFormat.yMMMd(locale).add_jm().format(dateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatRelativeTime(DateTime dateTime, String locale) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(dateTime);
|
||||||
|
|
||||||
|
if (diff.inDays > 7) {
|
||||||
|
return DateFormat.yMMMd(locale).format(dateTime);
|
||||||
|
} else if (diff.inDays > 0) {
|
||||||
|
return '${diff.inDays}d ago';
|
||||||
|
} else if (diff.inHours > 0) {
|
||||||
|
return '${diff.inHours}h ago';
|
||||||
|
} else if (diff.inMinutes > 0) {
|
||||||
|
return '${diff.inMinutes}m ago';
|
||||||
|
} else {
|
||||||
|
return 'Just now';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatCurrency(double amount, String locale, {String? symbol}) {
|
||||||
|
return NumberFormat.currency(
|
||||||
|
locale: locale,
|
||||||
|
symbol: symbol,
|
||||||
|
decimalDigits: 2,
|
||||||
|
).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatNumber(num number, String locale) {
|
||||||
|
return NumberFormat.decimalPattern(locale).format(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatPercent(double value, String locale) {
|
||||||
|
return NumberFormat.percentPattern(locale).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String formatCompact(num number, String locale) {
|
||||||
|
return NumberFormat.compact(locale: locale).format(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage with Locale
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class FormattedContent extends StatelessWidget {
|
||||||
|
const FormattedContent({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final locale = Localizations.localeOf(context).toString();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(FormattingUtils.formatDate(DateTime.now(), locale)),
|
||||||
|
Text(FormattingUtils.formatCurrency(1234.56, locale, symbol: '\$')),
|
||||||
|
Text(FormattingUtils.formatNumber(1234567, locale)),
|
||||||
|
Text(FormattingUtils.formatPercent(0.75, locale)),
|
||||||
|
Text(FormattingUtils.formatCompact(1500000, locale)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## RTL Support
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class RtlAwareWidget extends StatelessWidget {
|
||||||
|
const RtlAwareWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isRtl = Directionality.of(context) == TextDirection.rtl;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(isRtl ? Icons.arrow_back : Icons.arrow_forward),
|
||||||
|
const Expanded(child: Text('Content')),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsetsDirectional.only(start: 16),
|
||||||
|
child: const Icon(Icons.settings),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Directional Widgets
|
||||||
|
|
||||||
|
| Standard | Directional |
|
||||||
|
|----------|-------------|
|
||||||
|
| `EdgeInsets` | `EdgeInsetsDirectional` |
|
||||||
|
| `Padding` | `Padding` with `EdgeInsetsDirectional` |
|
||||||
|
| `Align` | `AlignmentDirectional` |
|
||||||
|
| `Positioned` | `PositionedDirectional` |
|
||||||
|
| `BorderRadius` | `BorderRadiusDirectional` |
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Use directional
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
|
||||||
|
child: child,
|
||||||
|
)
|
||||||
|
|
||||||
|
Container(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: child,
|
||||||
|
)
|
||||||
|
|
||||||
|
Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
borderRadius: BorderRadiusDirectional.only(
|
||||||
|
topStart: Radius.circular(8),
|
||||||
|
bottomStart: Radius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Organized Translations
|
||||||
|
|
||||||
|
### Split by Feature
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
l10n/
|
||||||
|
app_en.arb # Common translations
|
||||||
|
app_zh.arb
|
||||||
|
features/
|
||||||
|
auth_en.arb # Auth feature translations
|
||||||
|
auth_zh.arb
|
||||||
|
settings_en.arb # Settings feature translations
|
||||||
|
settings_zh.arb
|
||||||
|
```
|
||||||
|
|
||||||
|
### Namespaced Keys
|
||||||
|
|
||||||
|
```json
|
||||||
|
// app_en.arb
|
||||||
|
{
|
||||||
|
"auth_login": "Login",
|
||||||
|
"auth_logout": "Logout",
|
||||||
|
"auth_forgotPassword": "Forgot Password?",
|
||||||
|
|
||||||
|
"settings_title": "Settings",
|
||||||
|
"settings_language": "Language",
|
||||||
|
"settings_theme": "Theme",
|
||||||
|
|
||||||
|
"error_network": "Network error. Please try again.",
|
||||||
|
"error_unknown": "An unknown error occurred."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```dart
|
||||||
|
void main() {
|
||||||
|
testWidgets('shows localized text', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('en'),
|
||||||
|
home: const HomePage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Hello'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('switches locale', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
child: MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('zh'),
|
||||||
|
home: const HomePage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('你好'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ARB Placeholders Reference
|
||||||
|
|
||||||
|
| Type | Format Options |
|
||||||
|
|------|----------------|
|
||||||
|
| `String` | None |
|
||||||
|
| `int` | `compact`, `compactCurrency`, `compactLong`, `compactSimpleCurrency` |
|
||||||
|
| `double` | `compact`, `compactCurrency`, `currency`, `decimalPattern`, `decimalPercentPattern`, `percentPattern`, `scientificPattern`, `simpleCurrency` |
|
||||||
|
| `DateTime` | Any `DateFormat` pattern (yMd, yMMMd, jm, etc.) |
|
||||||
|
| `num` | Same as `int` and `double` |
|
||||||
|
|
||||||
|
## Localization Checklist
|
||||||
|
|
||||||
|
| Item | Implementation |
|
||||||
|
|------|----------------|
|
||||||
|
| Dependencies | `flutter_localizations`, `intl` |
|
||||||
|
| l10n.yaml | Configure ARB paths and output |
|
||||||
|
| ARB files | Create for each supported locale |
|
||||||
|
| App config | Add delegates and supported locales |
|
||||||
|
| Generate | Run `flutter gen-l10n` |
|
||||||
|
| Use translations | `AppLocalizations.of(context)` |
|
||||||
|
| Date/number formatting | Use `intl` formatters with locale |
|
||||||
|
| RTL support | Use directional widgets |
|
||||||
|
| Persist preference | Save user's locale choice |
|
||||||
|
| Testing | Test with different locales |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter is a trademark of Google LLC. intl is an open-source package by the Dart team.*
|
||||||
566
skills/flutter-dev/references/networking.md
Normal file
566
skills/flutter-dev/references/networking.md
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
# Networking
|
||||||
|
|
||||||
|
Dio configuration, interceptors, error handling, and caching strategies for Flutter network requests.
|
||||||
|
|
||||||
|
## Dio Setup
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
static final ApiClient _instance = ApiClient._internal();
|
||||||
|
factory ApiClient() => _instance;
|
||||||
|
|
||||||
|
late final Dio dio;
|
||||||
|
|
||||||
|
ApiClient._internal() {
|
||||||
|
dio = Dio(BaseOptions(
|
||||||
|
baseUrl: 'https://api.example.com/v1',
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 30),
|
||||||
|
sendTimeout: const Duration(seconds: 30),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
dio.interceptors.addAll([
|
||||||
|
AuthInterceptor(),
|
||||||
|
LoggingInterceptor(),
|
||||||
|
RetryInterceptor(dio: dio),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interceptors
|
||||||
|
|
||||||
|
### Auth Interceptor
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AuthInterceptor extends Interceptor {
|
||||||
|
final TokenStorage _tokenStorage;
|
||||||
|
|
||||||
|
AuthInterceptor({TokenStorage? tokenStorage})
|
||||||
|
: _tokenStorage = tokenStorage ?? TokenStorage();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||||
|
final token = await _tokenStorage.getAccessToken();
|
||||||
|
if (token != null) {
|
||||||
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
}
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
|
if (err.response?.statusCode == 401) {
|
||||||
|
try {
|
||||||
|
final newToken = await _refreshToken();
|
||||||
|
if (newToken != null) {
|
||||||
|
err.requestOptions.headers['Authorization'] = 'Bearer $newToken';
|
||||||
|
final response = await Dio().fetch(err.requestOptions);
|
||||||
|
return handler.resolve(response);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
await _tokenStorage.clearTokens();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _refreshToken() async {
|
||||||
|
final refreshToken = await _tokenStorage.getRefreshToken();
|
||||||
|
if (refreshToken == null) return null;
|
||||||
|
|
||||||
|
final response = await Dio().post(
|
||||||
|
'https://api.example.com/v1/auth/refresh',
|
||||||
|
data: {'refresh_token': refreshToken},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final newToken = response.data['access_token'];
|
||||||
|
await _tokenStorage.saveAccessToken(newToken);
|
||||||
|
return newToken;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging Interceptor
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class LoggingInterceptor extends Interceptor {
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
debugPrint('→ ${options.method} ${options.uri}');
|
||||||
|
if (options.data != null) {
|
||||||
|
debugPrint(' Body: ${options.data}');
|
||||||
|
}
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||||
|
debugPrint('← ${response.statusCode} ${response.requestOptions.uri}');
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||||
|
debugPrint('✗ ${err.response?.statusCode} ${err.requestOptions.uri}');
|
||||||
|
debugPrint(' Error: ${err.message}');
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retry Interceptor
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class RetryInterceptor extends Interceptor {
|
||||||
|
final Dio dio;
|
||||||
|
final int maxRetries;
|
||||||
|
final Duration retryDelay;
|
||||||
|
|
||||||
|
RetryInterceptor({
|
||||||
|
required this.dio,
|
||||||
|
this.maxRetries = 3,
|
||||||
|
this.retryDelay = const Duration(seconds: 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
|
final retryCount = err.requestOptions.extra['retryCount'] ?? 0;
|
||||||
|
|
||||||
|
if (_shouldRetry(err) && retryCount < maxRetries) {
|
||||||
|
await Future.delayed(retryDelay * (retryCount + 1));
|
||||||
|
|
||||||
|
err.requestOptions.extra['retryCount'] = retryCount + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await dio.fetch(err.requestOptions);
|
||||||
|
return handler.resolve(response);
|
||||||
|
} catch (e) {
|
||||||
|
return handler.next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldRetry(DioException err) {
|
||||||
|
return err.type == DioExceptionType.connectionTimeout ||
|
||||||
|
err.type == DioExceptionType.sendTimeout ||
|
||||||
|
err.type == DioExceptionType.receiveTimeout ||
|
||||||
|
(err.response?.statusCode ?? 0) >= 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Custom Exception
|
||||||
|
|
||||||
|
```dart
|
||||||
|
sealed class ApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int? statusCode;
|
||||||
|
final dynamic data;
|
||||||
|
|
||||||
|
const ApiException({
|
||||||
|
required this.message,
|
||||||
|
this.statusCode,
|
||||||
|
this.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class NetworkException extends ApiException {
|
||||||
|
const NetworkException({super.message = 'Network connection failed'});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServerException extends ApiException {
|
||||||
|
const ServerException({
|
||||||
|
required super.message,
|
||||||
|
super.statusCode,
|
||||||
|
super.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnauthorizedException extends ApiException {
|
||||||
|
const UnauthorizedException({super.message = 'Authentication required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ValidationException extends ApiException {
|
||||||
|
final Map<String, List<String>> errors;
|
||||||
|
|
||||||
|
const ValidationException({
|
||||||
|
required this.errors,
|
||||||
|
super.message = 'Validation failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handler
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ApiErrorHandler {
|
||||||
|
static ApiException handle(DioException error) {
|
||||||
|
switch (error.type) {
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
return const NetworkException(message: 'Connection timeout');
|
||||||
|
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
return const NetworkException(message: 'No internet connection');
|
||||||
|
|
||||||
|
case DioExceptionType.badResponse:
|
||||||
|
return _handleResponse(error.response);
|
||||||
|
|
||||||
|
case DioExceptionType.cancel:
|
||||||
|
return const ApiException(message: 'Request cancelled');
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ApiException(message: error.message ?? 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ApiException _handleResponse(Response? response) {
|
||||||
|
final statusCode = response?.statusCode ?? 0;
|
||||||
|
final data = response?.data;
|
||||||
|
|
||||||
|
switch (statusCode) {
|
||||||
|
case 400:
|
||||||
|
if (data is Map && data.containsKey('errors')) {
|
||||||
|
return ValidationException(
|
||||||
|
errors: Map<String, List<String>>.from(
|
||||||
|
(data['errors'] as Map).map(
|
||||||
|
(k, v) => MapEntry(k.toString(), List<String>.from(v)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ServerException(
|
||||||
|
message: data?['message'] ?? 'Bad request',
|
||||||
|
statusCode: statusCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
case 401:
|
||||||
|
return const UnauthorizedException();
|
||||||
|
|
||||||
|
case 403:
|
||||||
|
return const ServerException(
|
||||||
|
message: 'Access denied',
|
||||||
|
statusCode: 403,
|
||||||
|
);
|
||||||
|
|
||||||
|
case 404:
|
||||||
|
return const ServerException(
|
||||||
|
message: 'Resource not found',
|
||||||
|
statusCode: 404,
|
||||||
|
);
|
||||||
|
|
||||||
|
case 422:
|
||||||
|
return ValidationException(
|
||||||
|
errors: _parseValidationErrors(data),
|
||||||
|
);
|
||||||
|
|
||||||
|
case >= 500:
|
||||||
|
return ServerException(
|
||||||
|
message: 'Server error',
|
||||||
|
statusCode: statusCode,
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ServerException(
|
||||||
|
message: data?['message'] ?? 'Unknown error',
|
||||||
|
statusCode: statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, List<String>> _parseValidationErrors(dynamic data) {
|
||||||
|
if (data is! Map) return {};
|
||||||
|
final errors = data['errors'];
|
||||||
|
if (errors is! Map) return {};
|
||||||
|
return errors.map((k, v) => MapEntry(
|
||||||
|
k.toString(),
|
||||||
|
v is List ? v.map((e) => e.toString()).toList() : [v.toString()],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Pattern
|
||||||
|
|
||||||
|
```dart
|
||||||
|
abstract class BaseRepository {
|
||||||
|
final Dio dio;
|
||||||
|
|
||||||
|
BaseRepository(this.dio);
|
||||||
|
|
||||||
|
Future<T> safeCall<T>(Future<T> Function() call) async {
|
||||||
|
try {
|
||||||
|
return await call();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw ApiErrorHandler.handle(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserRepository extends BaseRepository {
|
||||||
|
UserRepository(super.dio);
|
||||||
|
|
||||||
|
Future<User> getUser(String id) => safeCall(() async {
|
||||||
|
final response = await dio.get('/users/$id');
|
||||||
|
return User.fromJson(response.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<User>> getUsers({int page = 1, int limit = 20}) => safeCall(() async {
|
||||||
|
final response = await dio.get('/users', queryParameters: {
|
||||||
|
'page': page,
|
||||||
|
'limit': limit,
|
||||||
|
});
|
||||||
|
return (response.data['data'] as List)
|
||||||
|
.map((e) => User.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<User> updateUser(String id, Map<String, dynamic> data) => safeCall(() async {
|
||||||
|
final response = await dio.patch('/users/$id', data: data);
|
||||||
|
return User.fromJson(response.data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
### Memory Cache
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class CacheInterceptor extends Interceptor {
|
||||||
|
final Map<String, CacheEntry> _cache = {};
|
||||||
|
final Duration maxAge;
|
||||||
|
|
||||||
|
CacheInterceptor({this.maxAge = const Duration(minutes: 5)});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
if (options.method != 'GET') {
|
||||||
|
handler.next(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final key = _cacheKey(options);
|
||||||
|
final cached = _cache[key];
|
||||||
|
|
||||||
|
if (cached != null && !cached.isExpired) {
|
||||||
|
return handler.resolve(Response(
|
||||||
|
requestOptions: options,
|
||||||
|
data: cached.data,
|
||||||
|
statusCode: 200,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||||
|
if (response.requestOptions.method == 'GET') {
|
||||||
|
final key = _cacheKey(response.requestOptions);
|
||||||
|
_cache[key] = CacheEntry(
|
||||||
|
data: response.data,
|
||||||
|
expiry: DateTime.now().add(maxAge),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cacheKey(RequestOptions options) {
|
||||||
|
return '${options.uri}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void invalidate(String pattern) {
|
||||||
|
_cache.removeWhere((key, _) => key.contains(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() => _cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
class CacheEntry {
|
||||||
|
final dynamic data;
|
||||||
|
final DateTime expiry;
|
||||||
|
|
||||||
|
CacheEntry({required this.data, required this.expiry});
|
||||||
|
|
||||||
|
bool get isExpired => DateTime.now().isAfter(expiry);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disk Cache with Hive
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
|
||||||
|
class DiskCacheInterceptor extends Interceptor {
|
||||||
|
static const String _boxName = 'api_cache';
|
||||||
|
final Duration maxAge;
|
||||||
|
|
||||||
|
DiskCacheInterceptor({this.maxAge = const Duration(hours: 1)});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||||
|
if (options.method != 'GET') {
|
||||||
|
handler.next(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final box = await Hive.openBox(_boxName);
|
||||||
|
final key = _cacheKey(options);
|
||||||
|
final cached = box.get(key);
|
||||||
|
|
||||||
|
if (cached != null) {
|
||||||
|
final entry = CachedResponse.fromJson(cached);
|
||||||
|
if (!entry.isExpired) {
|
||||||
|
return handler.resolve(Response(
|
||||||
|
requestOptions: options,
|
||||||
|
data: entry.data,
|
||||||
|
statusCode: 200,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.next(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) async {
|
||||||
|
if (response.requestOptions.method == 'GET') {
|
||||||
|
final box = await Hive.openBox(_boxName);
|
||||||
|
final key = _cacheKey(response.requestOptions);
|
||||||
|
await box.put(key, CachedResponse(
|
||||||
|
data: response.data,
|
||||||
|
expiry: DateTime.now().add(maxAge),
|
||||||
|
).toJson());
|
||||||
|
}
|
||||||
|
handler.next(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _cacheKey(RequestOptions options) => options.uri.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
class CachedResponse {
|
||||||
|
final dynamic data;
|
||||||
|
final DateTime expiry;
|
||||||
|
|
||||||
|
CachedResponse({required this.data, required this.expiry});
|
||||||
|
|
||||||
|
bool get isExpired => DateTime.now().isAfter(expiry);
|
||||||
|
|
||||||
|
factory CachedResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CachedResponse(
|
||||||
|
data: json['data'],
|
||||||
|
expiry: DateTime.parse(json['expiry']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'data': data,
|
||||||
|
'expiry': expiry.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Riverpod Integration
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
Dio dio(Ref ref) {
|
||||||
|
return ApiClient().dio;
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
UserRepository userRepository(Ref ref) {
|
||||||
|
return UserRepository(ref.watch(dioProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<User> user(Ref ref, String id) async {
|
||||||
|
final repository = ref.watch(userRepositoryProvider);
|
||||||
|
return repository.getUser(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class Users extends _$Users {
|
||||||
|
@override
|
||||||
|
Future<List<User>> build() => _fetch();
|
||||||
|
|
||||||
|
Future<List<User>> _fetch() async {
|
||||||
|
final repository = ref.watch(userRepositoryProvider);
|
||||||
|
return repository.getUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(_fetch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Cancellation
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class SearchRepository extends BaseRepository {
|
||||||
|
CancelToken? _searchToken;
|
||||||
|
|
||||||
|
SearchRepository(super.dio);
|
||||||
|
|
||||||
|
Future<List<SearchResult>> search(String query) async {
|
||||||
|
_searchToken?.cancel();
|
||||||
|
_searchToken = CancelToken();
|
||||||
|
|
||||||
|
return safeCall(() async {
|
||||||
|
final response = await dio.get(
|
||||||
|
'/search',
|
||||||
|
queryParameters: {'q': query},
|
||||||
|
cancelToken: _searchToken,
|
||||||
|
);
|
||||||
|
return (response.data as List)
|
||||||
|
.map((e) => SearchResult.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
| Pattern | Usage |
|
||||||
|
|---------|-------|
|
||||||
|
| Singleton client | Single Dio instance across app |
|
||||||
|
| Interceptor chain | Auth → Retry → Cache → Logging |
|
||||||
|
| Repository layer | Abstract API from business logic |
|
||||||
|
| Error mapping | Convert DioException to app exceptions |
|
||||||
|
| Cancel tokens | Debounce/cancel previous requests |
|
||||||
|
| Cache invalidation | Clear cache on mutations |
|
||||||
|
|
||||||
|
## Networking Checklist
|
||||||
|
|
||||||
|
| Item | Implementation |
|
||||||
|
|------|----------------|
|
||||||
|
| Base configuration | Timeouts, headers, base URL |
|
||||||
|
| Auth handling | Token injection, refresh on 401 |
|
||||||
|
| Error handling | Typed exceptions, user messages |
|
||||||
|
| Retry logic | Exponential backoff for transient errors |
|
||||||
|
| Request logging | Debug interceptor |
|
||||||
|
| Caching | Memory/disk cache for GET requests |
|
||||||
|
| Cancellation | Cancel tokens for search/debounce |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dio is an open-source package by the Flutter China community.*
|
||||||
306
skills/flutter-dev/references/performance.md
Normal file
306
skills/flutter-dev/references/performance.md
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
# Performance Optimization
|
||||||
|
|
||||||
|
Flutter performance guide covering profiling, const optimization, and DevTools analysis.
|
||||||
|
|
||||||
|
## Profiling Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run in profile mode (required for accurate measurements)
|
||||||
|
flutter run --profile
|
||||||
|
|
||||||
|
# Analyze code issues
|
||||||
|
flutter analyze
|
||||||
|
|
||||||
|
# Launch DevTools
|
||||||
|
flutter pub global activate devtools
|
||||||
|
flutter pub global run devtools
|
||||||
|
|
||||||
|
# Build release for testing
|
||||||
|
flutter build apk --release
|
||||||
|
flutter build ios --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Const Widget Optimization
|
||||||
|
|
||||||
|
The most important optimization for preventing unnecessary rebuilds:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD - Creates new objects every build
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(16), // New object each time
|
||||||
|
child: Text('Hello'), // New widget each time
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD - Const prevents rebuilds
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: const Text('Hello'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extracting Const Widgets
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD - Inline static content
|
||||||
|
class MyScreen extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.star, size: 48),
|
||||||
|
Text('Welcome'),
|
||||||
|
Text('Description text here'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD - Extract to const classes
|
||||||
|
class MyScreen extends StatelessWidget {
|
||||||
|
const MyScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Column(
|
||||||
|
children: [
|
||||||
|
_Header(),
|
||||||
|
_Description(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Header extends StatelessWidget {
|
||||||
|
const _Header();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Column(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.star, size: 48),
|
||||||
|
Text('Welcome'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Selective Provider Watching
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD - Rebuilds on any user change
|
||||||
|
class UserAvatar extends ConsumerWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final user = ref.watch(userProvider);
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundImage: NetworkImage(user.avatarUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD - Only rebuilds when avatarUrl changes
|
||||||
|
class UserAvatar extends ConsumerWidget {
|
||||||
|
const UserAvatar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final avatarUrl = ref.watch(userProvider.select((u) => u.avatarUrl));
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundImage: NetworkImage(avatarUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## RepaintBoundary
|
||||||
|
|
||||||
|
Isolate expensive widgets to prevent unnecessary repaints:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Isolate complex animated widgets
|
||||||
|
RepaintBoundary(
|
||||||
|
child: ComplexAnimatedWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Isolate frequently updating widgets
|
||||||
|
RepaintBoundary(
|
||||||
|
child: StreamBuilder<int>(
|
||||||
|
stream: counterStream,
|
||||||
|
builder: (context, snapshot) => Text('${snapshot.data}'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Optimization
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD - Builds all items upfront
|
||||||
|
ListView(
|
||||||
|
children: items.map((item) => ItemWidget(item: item)).toList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// GOOD - Lazy loading with builder
|
||||||
|
ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return ItemWidget(
|
||||||
|
key: ValueKey(items[index].id),
|
||||||
|
item: items[index],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// For heterogeneous content
|
||||||
|
ListView.separated(
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(),
|
||||||
|
itemBuilder: (context, index) => ItemWidget(item: items[index]),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Image Optimization
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Use cached_network_image for network images
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: url,
|
||||||
|
placeholder: (_, __) => const ShimmerPlaceholder(),
|
||||||
|
errorWidget: (_, __, ___) => const Icon(Icons.error),
|
||||||
|
memCacheWidth: 200,
|
||||||
|
memCacheHeight: 200,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resize images in memory
|
||||||
|
Image.network(
|
||||||
|
url,
|
||||||
|
cacheWidth: 200, // Decode at smaller size
|
||||||
|
cacheHeight: 200, // Saves memory
|
||||||
|
)
|
||||||
|
|
||||||
|
// Precache images
|
||||||
|
precacheImage(NetworkImage(url), context);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Heavy Computation
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// BAD - Blocks UI thread
|
||||||
|
void processData() {
|
||||||
|
final result = heavyComputation(data); // UI freezes
|
||||||
|
updateUI(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GOOD - Run in isolate
|
||||||
|
Future<void> processData() async {
|
||||||
|
final result = await compute(heavyComputation, data);
|
||||||
|
updateUI(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For multiple operations
|
||||||
|
Future<void> processMultiple() async {
|
||||||
|
final results = await Future.wait([
|
||||||
|
compute(process1, data1),
|
||||||
|
compute(process2, data2),
|
||||||
|
compute(process3, data3),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Animation Performance
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Use AnimatedBuilder for custom animations
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.rotate(
|
||||||
|
angle: controller.value * 2 * pi,
|
||||||
|
child: child, // Child not rebuilt
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const ExpensiveWidget(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefer implicit animations for simple cases
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
width: expanded ? 200 : 100,
|
||||||
|
child: const Content(),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## DevTools Analysis
|
||||||
|
|
||||||
|
### Key Metrics
|
||||||
|
|
||||||
|
| Metric | Target | Action if Exceeded |
|
||||||
|
|--------|--------|-------------------|
|
||||||
|
| Frame time | < 16ms (60fps) | Profile build/paint |
|
||||||
|
| Build time | < 8ms | Add const, extract widgets |
|
||||||
|
| Paint time | < 8ms | Add RepaintBoundary |
|
||||||
|
| Memory | Stable | Check for leaks |
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Issue | Symptom | Solution |
|
||||||
|
|-------|---------|----------|
|
||||||
|
| Expensive builds | High build time | Extract const widgets |
|
||||||
|
| Excessive repaints | High paint time | Add RepaintBoundary |
|
||||||
|
| Memory leaks | Growing memory | Dispose controllers |
|
||||||
|
| Jank | Dropped frames | Use compute() |
|
||||||
|
|
||||||
|
## Performance Checklist
|
||||||
|
|
||||||
|
| Check | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| Unnecessary rebuilds | Add `const`, use `select()` |
|
||||||
|
| Large lists | Use `ListView.builder` |
|
||||||
|
| Image loading | Use `cached_network_image` |
|
||||||
|
| Heavy computation | Use `compute()` |
|
||||||
|
| Jank in animations | Use `RepaintBoundary` |
|
||||||
|
| Memory leaks | Dispose controllers, cancel subscriptions |
|
||||||
|
| Network calls | Cache responses, debounce requests |
|
||||||
|
| Startup time | Defer initialization, lazy loading |
|
||||||
|
|
||||||
|
## Dispose Pattern
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MyWidget extends StatefulWidget {
|
||||||
|
const MyWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyWidget> createState() => _MyWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyWidgetState extends State<MyWidget> {
|
||||||
|
late final TextEditingController _controller;
|
||||||
|
late final StreamSubscription _subscription;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController();
|
||||||
|
_subscription = stream.listen(handleData);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_subscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter and DevTools are trademarks of Google LLC.*
|
||||||
|
|
||||||
417
skills/flutter-dev/references/platform-specific.md
Normal file
417
skills/flutter-dev/references/platform-specific.md
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
# Platform Integration
|
||||||
|
|
||||||
|
Flutter platform-specific implementations for iOS, Android, Web, and Desktop.
|
||||||
|
|
||||||
|
## Platform Detection
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
|
||||||
|
bool get isIOS => !kIsWeb && Platform.isIOS;
|
||||||
|
bool get isAndroid => !kIsWeb && Platform.isAndroid;
|
||||||
|
bool get isWeb => kIsWeb;
|
||||||
|
bool get isDesktop => !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isLinux);
|
||||||
|
bool get isMobile => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adaptive Widgets
|
||||||
|
|
||||||
|
### Platform-Aware Components
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AdaptiveButton extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
const AdaptiveButton({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return CupertinoButton.filled(
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Text(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
child: Text(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adaptive Dialog
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<bool?> showAdaptiveConfirmDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String content,
|
||||||
|
}) async {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return showCupertinoDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => CupertinoAlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(content),
|
||||||
|
actions: [
|
||||||
|
CupertinoDialogAction(
|
||||||
|
isDestructiveAction: true,
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
CupertinoDialogAction(
|
||||||
|
isDefaultAction: true,
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(content),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adaptive Scaffold
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class AdaptiveScaffold extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget body;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
|
||||||
|
const AdaptiveScaffold({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
this.actions,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return CupertinoPageScaffold(
|
||||||
|
navigationBar: CupertinoNavigationBar(
|
||||||
|
middle: Text(title),
|
||||||
|
trailing: actions != null
|
||||||
|
? Row(mainAxisSize: MainAxisSize.min, children: actions!)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: SafeArea(child: body),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(title), actions: actions),
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Channels
|
||||||
|
|
||||||
|
### Method Channel (Dart Side)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class NativeBridge {
|
||||||
|
static const _channel = MethodChannel('com.example.app/native');
|
||||||
|
|
||||||
|
static Future<String> getPlatformVersion() async {
|
||||||
|
final version = await _channel.invokeMethod<String>('getPlatformVersion');
|
||||||
|
return version ?? 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> triggerHaptic() async {
|
||||||
|
await _channel.invokeMethod('triggerHaptic');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> getDeviceInfo() async {
|
||||||
|
final result = await _channel.invokeMethod<Map>('getDeviceInfo');
|
||||||
|
return Map<String, dynamic>.from(result ?? {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### iOS Implementation (Swift)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ios/Runner/AppDelegate.swift
|
||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
|
) -> Bool {
|
||||||
|
let controller = window?.rootViewController as! FlutterViewController
|
||||||
|
let channel = FlutterMethodChannel(
|
||||||
|
name: "com.example.app/native",
|
||||||
|
binaryMessenger: controller.binaryMessenger
|
||||||
|
)
|
||||||
|
|
||||||
|
channel.setMethodCallHandler { (call, result) in
|
||||||
|
switch call.method {
|
||||||
|
case "getPlatformVersion":
|
||||||
|
result("iOS " + UIDevice.current.systemVersion)
|
||||||
|
case "triggerHaptic":
|
||||||
|
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||||
|
generator.impactOccurred()
|
||||||
|
result(nil)
|
||||||
|
case "getDeviceInfo":
|
||||||
|
result([
|
||||||
|
"model": UIDevice.current.model,
|
||||||
|
"name": UIDevice.current.name,
|
||||||
|
"systemVersion": UIDevice.current.systemVersion
|
||||||
|
])
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Android Implementation (Kotlin)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// android/app/src/main/kotlin/.../MainActivity.kt
|
||||||
|
package com.example.app
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.content.Context
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
class MainActivity: FlutterActivity() {
|
||||||
|
private val CHANNEL = "com.example.app/native"
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"getPlatformVersion" -> {
|
||||||
|
result.success("Android ${Build.VERSION.RELEASE}")
|
||||||
|
}
|
||||||
|
"triggerHaptic" -> {
|
||||||
|
val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
vibrator.vibrate(
|
||||||
|
VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
vibrator.vibrate(50)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"getDeviceInfo" -> {
|
||||||
|
result.success(mapOf(
|
||||||
|
"model" to Build.MODEL,
|
||||||
|
"manufacturer" to Build.MANUFACTURER,
|
||||||
|
"version" to Build.VERSION.RELEASE
|
||||||
|
))
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## iOS-Specific Configuration
|
||||||
|
|
||||||
|
### Info.plist Permissions
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- ios/Runner/Info.plist -->
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>This app needs camera access to take photos</string>
|
||||||
|
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>This app needs photo library access to save images</string>
|
||||||
|
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>This app needs location access to show nearby places</string>
|
||||||
|
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>This app needs microphone access for voice recording</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
### iOS App Icons and Launch Screen
|
||||||
|
|
||||||
|
```
|
||||||
|
ios/Runner/Assets.xcassets/
|
||||||
|
├── AppIcon.appiconset/
|
||||||
|
│ ├── Contents.json
|
||||||
|
│ └── Icon-App-*.png
|
||||||
|
└── LaunchImage.imageset/
|
||||||
|
├── Contents.json
|
||||||
|
└── LaunchImage*.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## Android-Specific Configuration
|
||||||
|
|
||||||
|
### AndroidManifest.xml Permissions
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- android/app/src/main/AndroidManifest.xml -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="My App"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<!-- ... -->
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Gradle Configuration
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
// android/app/build.gradle
|
||||||
|
android {
|
||||||
|
compileSdkVersion 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion 21
|
||||||
|
targetSdkVersion 34
|
||||||
|
multiDexEnabled true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
minifyEnabled true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web-Specific
|
||||||
|
|
||||||
|
### Conditional Imports
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/services/storage_service.dart
|
||||||
|
export 'storage_service_stub.dart'
|
||||||
|
if (dart.library.io) 'storage_service_native.dart'
|
||||||
|
if (dart.library.html) 'storage_service_web.dart';
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/services/storage_service_web.dart
|
||||||
|
import 'dart:html' as html;
|
||||||
|
|
||||||
|
class StorageService {
|
||||||
|
void save(String key, String value) {
|
||||||
|
html.window.localStorage[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? load(String key) {
|
||||||
|
return html.window.localStorage[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Index Configuration
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- web/index.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>My App</title>
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="flutter_bootstrap.js" async></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform-Specific Styling
|
||||||
|
|
||||||
|
```dart
|
||||||
|
ThemeData get theme {
|
||||||
|
final baseTheme = ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||||
|
useMaterial3: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return baseTheme.copyWith(
|
||||||
|
// iOS-style page transitions
|
||||||
|
pageTransitionsTheme: const PageTransitionsTheme(
|
||||||
|
builders: {
|
||||||
|
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseTheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform Reference
|
||||||
|
|
||||||
|
| Feature | iOS | Android | Web |
|
||||||
|
|---------|-----|---------|-----|
|
||||||
|
| Navigation | Cupertino style | Material style | URL-based |
|
||||||
|
| Haptics | UIFeedbackGenerator | Vibrator | Not available |
|
||||||
|
| Storage | NSUserDefaults | SharedPreferences | localStorage |
|
||||||
|
| Deep links | Universal Links | App Links | URL routing |
|
||||||
|
| Notifications | APNs | FCM | Web Push |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter, iOS, Android, and their respective logos are trademarks of Google LLC and Apple Inc.*
|
||||||
274
skills/flutter-dev/references/project-structure.md
Normal file
274
skills/flutter-dev/references/project-structure.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# Project Structure
|
||||||
|
|
||||||
|
Flutter project architecture guide covering feature-based structure, dependencies, and entry point setup.
|
||||||
|
|
||||||
|
## Feature-Based Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── main.dart # Entry point
|
||||||
|
├── app.dart # App widget, MaterialApp.router
|
||||||
|
├── core/
|
||||||
|
│ ├── constants/
|
||||||
|
│ │ ├── app_colors.dart
|
||||||
|
│ │ ├── app_strings.dart
|
||||||
|
│ │ └── app_sizes.dart
|
||||||
|
│ ├── theme/
|
||||||
|
│ │ ├── app_theme.dart
|
||||||
|
│ │ └── text_styles.dart
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── extensions.dart
|
||||||
|
│ │ └── validators.dart
|
||||||
|
│ └── errors/
|
||||||
|
│ └── failures.dart
|
||||||
|
├── features/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ ├── data/
|
||||||
|
│ │ │ ├── repositories/
|
||||||
|
│ │ │ │ └── auth_repository_impl.dart
|
||||||
|
│ │ │ └── datasources/
|
||||||
|
│ │ │ ├── auth_remote_datasource.dart
|
||||||
|
│ │ │ └── auth_local_datasource.dart
|
||||||
|
│ │ ├── domain/
|
||||||
|
│ │ │ ├── entities/
|
||||||
|
│ │ │ │ └── user.dart
|
||||||
|
│ │ │ ├── repositories/
|
||||||
|
│ │ │ │ └── auth_repository.dart
|
||||||
|
│ │ │ └── usecases/
|
||||||
|
│ │ │ ├── login.dart
|
||||||
|
│ │ │ └── logout.dart
|
||||||
|
│ │ ├── presentation/
|
||||||
|
│ │ │ ├── screens/
|
||||||
|
│ │ │ │ ├── login_screen.dart
|
||||||
|
│ │ │ │ └── register_screen.dart
|
||||||
|
│ │ │ └── widgets/
|
||||||
|
│ │ │ └── auth_form.dart
|
||||||
|
│ │ └── providers/
|
||||||
|
│ │ └── auth_provider.dart
|
||||||
|
│ └── home/
|
||||||
|
│ ├── data/
|
||||||
|
│ ├── domain/
|
||||||
|
│ ├── presentation/
|
||||||
|
│ └── providers/
|
||||||
|
├── shared/
|
||||||
|
│ ├── widgets/
|
||||||
|
│ │ ├── buttons/
|
||||||
|
│ │ │ └── primary_button.dart
|
||||||
|
│ │ ├── inputs/
|
||||||
|
│ │ │ └── text_input.dart
|
||||||
|
│ │ └── cards/
|
||||||
|
│ │ └── info_card.dart
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── api_service.dart
|
||||||
|
│ │ └── storage_service.dart
|
||||||
|
│ └── models/
|
||||||
|
│ └── api_response.dart
|
||||||
|
└── routes/
|
||||||
|
└── app_router.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Layer Responsibilities
|
||||||
|
|
||||||
|
| Layer | Responsibility |
|
||||||
|
|-------|----------------|
|
||||||
|
| **data/** | API calls, local storage, DTOs, repository implementations |
|
||||||
|
| **domain/** | Business logic, entities, abstract repositories, use cases |
|
||||||
|
| **presentation/** | UI screens, widgets, view logic |
|
||||||
|
| **providers/** | Riverpod providers or Bloc definitions |
|
||||||
|
|
||||||
|
## pubspec.yaml Essentials
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: my_app
|
||||||
|
description: A Flutter application.
|
||||||
|
version: 1.0.0+1
|
||||||
|
publish_to: 'none'
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# State Management (choose one)
|
||||||
|
flutter_riverpod: ^2.5.0
|
||||||
|
riverpod_annotation: ^2.3.0
|
||||||
|
# OR
|
||||||
|
flutter_bloc: ^8.1.0
|
||||||
|
|
||||||
|
# Navigation
|
||||||
|
go_router: ^14.0.0
|
||||||
|
|
||||||
|
# Networking
|
||||||
|
dio: ^5.4.0
|
||||||
|
|
||||||
|
# Code Generation
|
||||||
|
freezed_annotation: ^2.4.0
|
||||||
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
shared_preferences: ^2.2.0
|
||||||
|
hive_flutter: ^1.1.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
flutter_hooks: ^0.20.0
|
||||||
|
cached_network_image: ^3.3.0
|
||||||
|
intl: ^0.19.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# Code Generation
|
||||||
|
build_runner: ^2.4.0
|
||||||
|
riverpod_generator: ^2.4.0
|
||||||
|
freezed: ^2.5.0
|
||||||
|
json_serializable: ^6.8.0
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
bloc_test: ^9.1.0
|
||||||
|
mocktail: ^1.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Main Entry Point
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// main.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
await Hive.initFlutter();
|
||||||
|
|
||||||
|
runApp(
|
||||||
|
const ProviderScope(
|
||||||
|
child: MyApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// app.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
class MyApp extends ConsumerWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final router = ref.watch(routerProvider);
|
||||||
|
|
||||||
|
return MaterialApp.router(
|
||||||
|
title: 'My App',
|
||||||
|
routerConfig: router,
|
||||||
|
theme: AppTheme.light,
|
||||||
|
darkTheme: AppTheme.dark,
|
||||||
|
themeMode: ThemeMode.system,
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Router Provider
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// routes/app_router.dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
|
return GoRouter(
|
||||||
|
initialLocation: '/',
|
||||||
|
debugLogDiagnostics: true,
|
||||||
|
redirect: (context, state) {
|
||||||
|
// Auth guard logic
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
builder: (context, state) => const HomeScreen(),
|
||||||
|
),
|
||||||
|
// Add more routes
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// core/constants/environment.dart
|
||||||
|
enum Environment { dev, staging, prod }
|
||||||
|
|
||||||
|
class EnvConfig {
|
||||||
|
static Environment current = Environment.dev;
|
||||||
|
|
||||||
|
static String get baseUrl {
|
||||||
|
switch (current) {
|
||||||
|
case Environment.dev:
|
||||||
|
return 'https://dev-api.example.com';
|
||||||
|
case Environment.staging:
|
||||||
|
return 'https://staging-api.example.com';
|
||||||
|
case Environment.prod:
|
||||||
|
return 'https://api.example.com';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependency Injection with Riverpod
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// shared/services/api_service.dart
|
||||||
|
final apiServiceProvider = Provider<ApiService>((ref) {
|
||||||
|
final dio = Dio(BaseOptions(
|
||||||
|
baseUrl: EnvConfig.baseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Add interceptors
|
||||||
|
dio.interceptors.add(AuthInterceptor(ref));
|
||||||
|
dio.interceptors.add(LogInterceptor(responseBody: true));
|
||||||
|
|
||||||
|
return ApiService(dio);
|
||||||
|
});
|
||||||
|
|
||||||
|
// features/auth/providers/auth_provider.dart
|
||||||
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
||||||
|
final api = ref.watch(apiServiceProvider);
|
||||||
|
final storage = ref.watch(storageServiceProvider);
|
||||||
|
return AuthRepositoryImpl(api: api, storage: storage);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
| Practice | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Feature isolation | Each feature is self-contained |
|
||||||
|
| Dependency inversion | Domain depends on abstractions |
|
||||||
|
| Single responsibility | One class, one purpose |
|
||||||
|
| Naming conventions | Clear, descriptive names |
|
||||||
|
| Barrel exports | One index.dart per folder |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter is a trademark of Google LLC.*
|
||||||
|
|
||||||
232
skills/flutter-dev/references/riverpod-state.md
Normal file
232
skills/flutter-dev/references/riverpod-state.md
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# Riverpod State Management
|
||||||
|
|
||||||
|
Riverpod 2.0 state management guide covering provider types, notifier patterns, and widget integration.
|
||||||
|
|
||||||
|
## Provider Types
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
// Simple computed value
|
||||||
|
final greetingProvider = Provider<String>((ref) {
|
||||||
|
final name = ref.watch(userNameProvider);
|
||||||
|
return 'Hello, $name';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple mutable state
|
||||||
|
final counterProvider = StateProvider<int>((ref) => 0);
|
||||||
|
|
||||||
|
// Async state (API calls)
|
||||||
|
final usersProvider = FutureProvider<List<User>>((ref) async {
|
||||||
|
final api = ref.read(apiProvider);
|
||||||
|
return api.getUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream state (real-time)
|
||||||
|
final messagesProvider = StreamProvider<List<Message>>((ref) {
|
||||||
|
return ref.read(chatServiceProvider).messagesStream;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider Type Reference
|
||||||
|
|
||||||
|
| Provider | Use Case |
|
||||||
|
|----------|----------|
|
||||||
|
| `Provider` | Computed/derived values, dependency injection |
|
||||||
|
| `StateProvider` | Simple mutable state (counter, toggle) |
|
||||||
|
| `FutureProvider` | Async operations (one-time fetch) |
|
||||||
|
| `StreamProvider` | Real-time data streams |
|
||||||
|
| `NotifierProvider` | Complex state with methods |
|
||||||
|
| `AsyncNotifierProvider` | Async state with methods |
|
||||||
|
|
||||||
|
## Notifier Pattern (Riverpod 2.0)
|
||||||
|
|
||||||
|
### Synchronous Notifier
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class TodoList extends _$TodoList {
|
||||||
|
@override
|
||||||
|
List<Todo> build() => [];
|
||||||
|
|
||||||
|
void add(Todo todo) {
|
||||||
|
state = [...state, todo];
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggle(String id) {
|
||||||
|
state = [
|
||||||
|
for (final todo in state)
|
||||||
|
if (todo.id == id)
|
||||||
|
todo.copyWith(completed: !todo.completed)
|
||||||
|
else
|
||||||
|
todo,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove(String id) {
|
||||||
|
state = state.where((t) => t.id != id).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async Notifier
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@riverpod
|
||||||
|
class UserProfile extends _$UserProfile {
|
||||||
|
@override
|
||||||
|
Future<User> build() async {
|
||||||
|
return ref.read(apiProvider).getCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateName(String name) async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
state = await AsyncValue.guard(() async {
|
||||||
|
final updated = await ref.read(apiProvider).updateUser(name: name);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
ref.invalidateSelf();
|
||||||
|
await future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in Widgets
|
||||||
|
|
||||||
|
### ConsumerWidget (Recommended)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class TodoScreen extends ConsumerWidget {
|
||||||
|
const TodoScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final todos = ref.watch(todoListProvider);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: todos.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final todo = todos[index];
|
||||||
|
return ListTile(
|
||||||
|
key: ValueKey(todo.id),
|
||||||
|
title: Text(todo.title),
|
||||||
|
leading: Checkbox(
|
||||||
|
value: todo.completed,
|
||||||
|
onChanged: (_) => ref.read(todoListProvider.notifier).toggle(todo.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selective Rebuilds with select
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class UserAvatar extends ConsumerWidget {
|
||||||
|
const UserAvatar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Only rebuilds when avatarUrl changes
|
||||||
|
final avatarUrl = ref.watch(userProvider.select((u) => u?.avatarUrl));
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async State Handling
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class UserProfileScreen extends ConsumerWidget {
|
||||||
|
const UserProfileScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final userAsync = ref.watch(userProfileProvider);
|
||||||
|
|
||||||
|
return userAsync.when(
|
||||||
|
data: (user) => UserProfileContent(user: user),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (err, stack) => ErrorView(
|
||||||
|
message: err.toString(),
|
||||||
|
onRetry: () => ref.invalidate(userProfileProvider),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consumer for Scoped Rebuilds
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MyScreen extends StatelessWidget {
|
||||||
|
const MyScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const Text('Static content'),
|
||||||
|
Consumer(
|
||||||
|
builder: (context, ref, child) {
|
||||||
|
final count = ref.watch(counterProvider);
|
||||||
|
return Text('Count: $count');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Provider Modifiers
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// Auto-dispose when no longer used
|
||||||
|
@riverpod
|
||||||
|
class AutoDisposeExample extends _$AutoDisposeExample {
|
||||||
|
@override
|
||||||
|
String build() => 'value';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Family - parameterized providers
|
||||||
|
@riverpod
|
||||||
|
Future<User> userById(UserByIdRef ref, String id) async {
|
||||||
|
return ref.read(apiProvider).getUser(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
final user = ref.watch(userByIdProvider('123'));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
| Do | Don't |
|
||||||
|
|----|-------|
|
||||||
|
| Use `ref.watch()` in build | Use `ref.watch()` in callbacks |
|
||||||
|
| Use `ref.read()` in callbacks | Use `ref.read()` in build |
|
||||||
|
| Use `select()` for granular rebuilds | Watch entire state unnecessarily |
|
||||||
|
| Create new state instances | Mutate state directly |
|
||||||
|
| Use `AsyncValue.guard()` for errors | Catch errors manually |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
| Method | When to Use |
|
||||||
|
|--------|-------------|
|
||||||
|
| `ref.watch()` | In build method, rebuilds on change |
|
||||||
|
| `ref.read()` | In callbacks, one-time read |
|
||||||
|
| `ref.listen()` | Side effects on change |
|
||||||
|
| `ref.invalidate()` | Force provider refresh |
|
||||||
|
| `ref.refresh()` | Invalidate and get new value |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Riverpod is an open-source state management library by Remi Rousselet.*
|
||||||
364
skills/flutter-dev/references/testing.md
Normal file
364
skills/flutter-dev/references/testing.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# Testing Strategies
|
||||||
|
|
||||||
|
Flutter testing guide covering widget tests, unit tests, integration tests, and mocking patterns.
|
||||||
|
|
||||||
|
## Test Types
|
||||||
|
|
||||||
|
| Type | Purpose | Speed | Scope |
|
||||||
|
|------|---------|-------|-------|
|
||||||
|
| Unit tests | Business logic, utilities | Fast | Single function/class |
|
||||||
|
| Widget tests | UI components | Medium | Single widget |
|
||||||
|
| Integration tests | Full user flows | Slow | Multiple screens |
|
||||||
|
|
||||||
|
## Widget Tests
|
||||||
|
|
||||||
|
### Basic Widget Test
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('Counter increments when button tapped', (tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: CounterScreen()));
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
expect(find.text('0'), findsOneWidget);
|
||||||
|
expect(find.text('1'), findsNothing);
|
||||||
|
|
||||||
|
// Tap the increment button
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify state changed
|
||||||
|
expect(find.text('0'), findsNothing);
|
||||||
|
expect(find.text('1'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing with Riverpod
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('displays user name from provider', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
userProvider.overrideWithValue(
|
||||||
|
AsyncValue.data(User(name: 'Test User')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: UserScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Test User'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows loading indicator', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
userProvider.overrideWithValue(const AsyncValue.loading()),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: UserScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows error message', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
userProvider.overrideWithValue(
|
||||||
|
AsyncValue.error('Network error', StackTrace.current),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: UserScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Network error'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing with Bloc
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:bloc_test/bloc_test.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
class MockCounterBloc extends MockBloc<CounterEvent, CounterState>
|
||||||
|
implements CounterBloc {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockCounterBloc mockBloc;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockBloc = MockCounterBloc();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays current count', (tester) async {
|
||||||
|
when(() => mockBloc.state).thenReturn(const CounterState(value: 42));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: BlocProvider<CounterBloc>.value(
|
||||||
|
value: mockBloc,
|
||||||
|
child: const CounterScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('42'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('calls increment on button tap', (tester) async {
|
||||||
|
when(() => mockBloc.state).thenReturn(const CounterState(value: 0));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: BlocProvider<CounterBloc>.value(
|
||||||
|
value: mockBloc,
|
||||||
|
child: const CounterScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
|
||||||
|
verify(() => mockBloc.add(CounterIncremented())).called(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bloc Tests
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:bloc_test/bloc_test.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
class MockUserRepository extends Mock implements UserRepository {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockUserRepository mockRepository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockRepository = MockUserRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('UserBloc', () {
|
||||||
|
blocTest<UserBloc, UserState>(
|
||||||
|
'emits loading then success when user loaded',
|
||||||
|
setUp: () {
|
||||||
|
when(() => mockRepository.getUser())
|
||||||
|
.thenAnswer((_) async => User(name: 'Test'));
|
||||||
|
},
|
||||||
|
build: () => UserBloc(repository: mockRepository),
|
||||||
|
act: (bloc) => bloc.add(UserRequested()),
|
||||||
|
expect: () => [
|
||||||
|
const UserState(status: UserStatus.loading),
|
||||||
|
UserState(status: UserStatus.success, user: User(name: 'Test')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
blocTest<UserBloc, UserState>(
|
||||||
|
'emits loading then failure when error occurs',
|
||||||
|
setUp: () {
|
||||||
|
when(() => mockRepository.getUser())
|
||||||
|
.thenThrow(Exception('Network error'));
|
||||||
|
},
|
||||||
|
build: () => UserBloc(repository: mockRepository),
|
||||||
|
act: (bloc) => bloc.add(UserRequested()),
|
||||||
|
expect: () => [
|
||||||
|
const UserState(status: UserStatus.loading),
|
||||||
|
isA<UserState>()
|
||||||
|
.having((s) => s.status, 'status', UserStatus.failure),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unit Tests
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('Validator', () {
|
||||||
|
test('returns error for empty email', () {
|
||||||
|
expect(Validator.email(''), 'Email is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns error for invalid email', () {
|
||||||
|
expect(Validator.email('invalid'), 'Invalid email format');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for valid email', () {
|
||||||
|
expect(Validator.email('test@example.com'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Calculator', () {
|
||||||
|
late Calculator calculator;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
calculator = Calculator();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds two numbers', () {
|
||||||
|
expect(calculator.add(2, 3), 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on division by zero', () {
|
||||||
|
expect(() => calculator.divide(10, 0), throwsArgumentError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mocking with Mocktail
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
// Create mock classes
|
||||||
|
class MockApiService extends Mock implements ApiService {}
|
||||||
|
class MockStorageService extends Mock implements StorageService {}
|
||||||
|
|
||||||
|
// Register fallback values for complex types
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(User(name: 'fallback'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockApiService mockApi;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockApi = MockApiService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetches user from API', () async {
|
||||||
|
// Arrange
|
||||||
|
when(() => mockApi.getUser(any()))
|
||||||
|
.thenAnswer((_) async => User(name: 'Test'));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
final repository = UserRepository(api: mockApi);
|
||||||
|
final user = await repository.getUser('123');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(user.name, 'Test');
|
||||||
|
verify(() => mockApi.getUser('123')).called(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Tests
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// integration_test/app_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:my_app/main.dart' as app;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
testWidgets('complete login flow', (tester) async {
|
||||||
|
app.main();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Navigate to login
|
||||||
|
await tester.tap(find.text('Login'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Enter credentials
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('email_field')),
|
||||||
|
'test@example.com',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('password_field')),
|
||||||
|
'password123',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await tester.tap(find.text('Sign In'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify navigation to home
|
||||||
|
expect(find.text('Welcome'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run integration tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter test integration_test/app_test.dart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Helpers
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// test/helpers/pump_app.dart
|
||||||
|
extension PumpApp on WidgetTester {
|
||||||
|
Future<void> pumpApp(Widget widget, {List<Override>? overrides}) {
|
||||||
|
return pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: overrides ?? [],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: widget,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
await tester.pumpApp(const MyWidget());
|
||||||
|
```
|
||||||
|
|
||||||
|
## Golden Tests
|
||||||
|
|
||||||
|
```dart
|
||||||
|
testWidgets('matches golden', (tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: MyWidget()));
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
find.byType(MyWidget),
|
||||||
|
matchesGoldenFile('goldens/my_widget.png'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Update goldens:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter test --update-goldens
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
| Test Type | What to Test |
|
||||||
|
|-----------|--------------|
|
||||||
|
| Widget tests | UI rendering, user interactions, state changes |
|
||||||
|
| Bloc tests | Event → state transitions, async operations |
|
||||||
|
| Unit tests | Validators, formatters, utilities, models |
|
||||||
|
| Integration tests | Critical user flows, navigation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter and flutter_test are trademarks of Google LLC.*
|
||||||
233
skills/flutter-dev/references/widget-patterns.md
Normal file
233
skills/flutter-dev/references/widget-patterns.md
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
# Widget Patterns
|
||||||
|
|
||||||
|
Flutter widget best practices covering const optimization, responsive layouts, hooks, and sliver patterns.
|
||||||
|
|
||||||
|
## Optimized Widget Pattern
|
||||||
|
|
||||||
|
Always use `const` constructors for static widgets to prevent unnecessary rebuilds:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class OptimizedCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const OptimizedCard({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extracting Const Widgets
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class MyScreen extends StatelessWidget {
|
||||||
|
const MyScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: const [
|
||||||
|
_Header(),
|
||||||
|
_Body(),
|
||||||
|
_Footer(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Header extends StatelessWidget {
|
||||||
|
const _Header();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Text('Header');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Responsive Layout
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class ResponsiveLayout extends StatelessWidget {
|
||||||
|
final Widget mobile;
|
||||||
|
final Widget? tablet;
|
||||||
|
final Widget desktop;
|
||||||
|
|
||||||
|
const ResponsiveLayout({
|
||||||
|
super.key,
|
||||||
|
required this.mobile,
|
||||||
|
this.tablet,
|
||||||
|
required this.desktop,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const double mobileBreakpoint = 650;
|
||||||
|
static const double desktopBreakpoint = 1100;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (constraints.maxWidth >= desktopBreakpoint) return desktop;
|
||||||
|
if (constraints.maxWidth >= mobileBreakpoint) return tablet ?? mobile;
|
||||||
|
return mobile;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Breakpoint Reference
|
||||||
|
|
||||||
|
| Type | Width | Usage |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Mobile | < 650pt | Single column, bottom nav |
|
||||||
|
| Tablet | 650-1100pt | Two columns, side nav optional |
|
||||||
|
| Desktop | > 1100pt | Multi-column, persistent nav |
|
||||||
|
|
||||||
|
## Custom Hooks (flutter_hooks)
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
class CounterWidget extends HookWidget {
|
||||||
|
const CounterWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final counter = useState(0);
|
||||||
|
final controller = useTextEditingController();
|
||||||
|
final isMounted = useIsMounted();
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
debugPrint('Widget mounted');
|
||||||
|
return () {
|
||||||
|
debugPrint('Widget disposed');
|
||||||
|
};
|
||||||
|
}, const []);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text('Count: ${counter.value}'),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => counter.value++,
|
||||||
|
child: const Text('Increment'),
|
||||||
|
),
|
||||||
|
TextField(controller: controller),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Hooks
|
||||||
|
|
||||||
|
| Hook | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `useState` | Local state management |
|
||||||
|
| `useEffect` | Side effects with cleanup |
|
||||||
|
| `useMemoized` | Expensive computation caching |
|
||||||
|
| `useTextEditingController` | Text field controller |
|
||||||
|
| `useAnimationController` | Animation controller |
|
||||||
|
| `useFocusNode` | Focus management |
|
||||||
|
| `useIsMounted` | Check if widget is mounted |
|
||||||
|
|
||||||
|
## Sliver Patterns
|
||||||
|
|
||||||
|
```dart
|
||||||
|
CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar(
|
||||||
|
expandedHeight: 200,
|
||||||
|
pinned: true,
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
title: const Text('Title'),
|
||||||
|
background: Image.network(imageUrl, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => ListTile(
|
||||||
|
key: ValueKey(items[index].id),
|
||||||
|
title: Text(items[index].title),
|
||||||
|
),
|
||||||
|
childCount: items.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text('Footer'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sliver Types
|
||||||
|
|
||||||
|
| Sliver | Usage |
|
||||||
|
|--------|-------|
|
||||||
|
| `SliverAppBar` | Collapsing app bar |
|
||||||
|
| `SliverList` | Lazy list |
|
||||||
|
| `SliverGrid` | Lazy grid |
|
||||||
|
| `SliverToBoxAdapter` | Single non-sliver widget |
|
||||||
|
| `SliverPadding` | Add padding to sliver |
|
||||||
|
| `SliverFillRemaining` | Fill remaining space |
|
||||||
|
|
||||||
|
## Key Usage Patterns
|
||||||
|
|
||||||
|
```dart
|
||||||
|
ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
child: ListTile(
|
||||||
|
key: ValueKey('tile_${item.id}'),
|
||||||
|
title: Text(item.title),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key Type | When to Use |
|
||||||
|
|----------|-------------|
|
||||||
|
| `ValueKey` | Unique ID available |
|
||||||
|
| `ObjectKey` | Object identity matters |
|
||||||
|
| `UniqueKey` | Force rebuild |
|
||||||
|
| `GlobalKey` | Access state across tree |
|
||||||
|
|
||||||
|
## Optimization Checklist
|
||||||
|
|
||||||
|
| Pattern | Implementation |
|
||||||
|
|---------|----------------|
|
||||||
|
| const widgets | Add `const` to static widgets |
|
||||||
|
| Keys | Use `ValueKey` for list items |
|
||||||
|
| Select | `ref.watch(provider.select(...))` |
|
||||||
|
| RepaintBoundary | Isolate expensive repaints |
|
||||||
|
| ListView.builder | Lazy loading for lists |
|
||||||
|
| const constructors | Always use when possible |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Flutter and Material Design are trademarks of Google LLC.*
|
||||||
Reference in New Issue
Block a user