feat: add flutter-dev skill
Add comprehensive Flutter cross-platform development guide covering: - Widget patterns and const optimization - Riverpod/Bloc state management - GoRouter navigation - Performance optimization - Testing strategies - Platform-specific implementations Made-with: Cursor
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 |
|
||||
| `android-native-dev` | Android native application development with Material Design 3. Kotlin / Jetpack Compose, adaptive layouts, Gradle configuration, accessibility (WCAG), build troubleshooting, performance optimization, and motion system. | Official |
|
||||
| `ios-application-dev` | iOS application development guide covering UIKit, SnapKit, and SwiftUI. Touch targets, safe areas, navigation patterns, Dynamic Type, Dark Mode, accessibility, collection views, and Apple HIG compliance. | Official |
|
||||
| `flutter-dev` | Flutter cross-platform development covering widget patterns, Riverpod/Bloc state management, GoRouter navigation, performance optimization, and testing strategies. | Official |
|
||||
| `shader-dev` | Comprehensive GLSL shader techniques for creating stunning visual effects — ray marching, SDF modeling, fluid simulation, particle systems, procedural generation, lighting, post-processing, and more. ShaderToy-compatible. | Official |
|
||||
| `gif-sticker-maker` | Convert photos (people, pets, objects, logos) into 4 animated GIF stickers with captions. Funko Pop / Pop Mart style, powered by MiniMax Image & Video Generation API. | Official |
|
||||
| `minimax-pdf` | Generate, fill, and reformat PDF documents with a token-based design system. CREATE polished PDFs from scratch (15 cover styles), FILL existing form fields, or REFORMAT documents into a new design. Print-ready output with typography and color derived from document type. | Official |
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| `fullstack-dev` | 全栈后端架构与前后端集成。REST API 设计、认证流程(JWT、Session、OAuth)、实时功能(SSE、WebSocket)、数据库集成(SQL / NoSQL)、生产环境加固与发布清单。引导式工作流:需求收集 → 架构决策 → 实现。 | Official |
|
||||
| `android-native-dev` | 基于 Material Design 3 的 Android 原生应用开发。Kotlin / Jetpack Compose、自适应布局、Gradle 配置、无障碍(WCAG)、构建问题排查、性能优化与动效系统。 | Official |
|
||||
| `ios-application-dev` | iOS 应用开发指南,涵盖 UIKit、SnapKit 和 SwiftUI。触控目标、安全区域、导航模式、Dynamic Type、深色模式、无障碍、集合视图,符合 Apple HIG 规范。 | Official |
|
||||
| `flutter-dev` | Flutter 跨平台开发指南,涵盖 Widget 模式、Riverpod/Bloc 状态管理、GoRouter 导航、性能优化与测试策略。 | Official |
|
||||
| `shader-dev` | 全面的 GLSL 着色器技术,用于创建惊艳的视觉效果 — 光线行进、SDF 建模、流体模拟、粒子系统、程序化生成、光照、后处理等。兼容 ShaderToy。 | Official |
|
||||
| `gif-sticker-maker` | 将照片(人物、宠物、物品、Logo)转换为 4 张带字幕的动画 GIF 贴纸。Funko Pop / Pop Mart 盲盒风格,基于 MiniMax 图片与视频生成 API。 | Official |
|
||||
| `minimax-pdf` | 基于 token 化设计系统生成、填写和重排 PDF 文档。支持三种模式:CREATE(从零生成,15 种封面风格)、FILL(填写现有表单字段)、REFORMAT(将已有文档重排为新设计)。排版与配色由文档类型自动推导,输出即可打印。 | Official |
|
||||
|
||||
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