From 2995582a5effe9000023f190aa7147a5116cd05c Mon Sep 17 00:00:00 2001 From: mljxxx <704582254@qq.com> Date: Thu, 26 Mar 2026 17:35:55 +0800 Subject: [PATCH] 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 --- README.md | 1 + README_zh.md | 1 + skills/flutter-dev/SKILL.md | 128 ++++ skills/flutter-dev/references/animations.md | 497 +++++++++++++ skills/flutter-dev/references/bloc-state.md | 281 ++++++++ skills/flutter-dev/references/forms.md | 656 ++++++++++++++++++ .../references/gorouter-navigation.md | 257 +++++++ skills/flutter-dev/references/localization.md | 510 ++++++++++++++ skills/flutter-dev/references/networking.md | 566 +++++++++++++++ skills/flutter-dev/references/performance.md | 306 ++++++++ .../references/platform-specific.md | 417 +++++++++++ .../references/project-structure.md | 274 ++++++++ .../flutter-dev/references/riverpod-state.md | 232 +++++++ skills/flutter-dev/references/testing.md | 364 ++++++++++ .../flutter-dev/references/widget-patterns.md | 233 +++++++ 15 files changed, 4723 insertions(+) create mode 100644 skills/flutter-dev/SKILL.md create mode 100644 skills/flutter-dev/references/animations.md create mode 100644 skills/flutter-dev/references/bloc-state.md create mode 100644 skills/flutter-dev/references/forms.md create mode 100644 skills/flutter-dev/references/gorouter-navigation.md create mode 100644 skills/flutter-dev/references/localization.md create mode 100644 skills/flutter-dev/references/networking.md create mode 100644 skills/flutter-dev/references/performance.md create mode 100644 skills/flutter-dev/references/platform-specific.md create mode 100644 skills/flutter-dev/references/project-structure.md create mode 100644 skills/flutter-dev/references/riverpod-state.md create mode 100644 skills/flutter-dev/references/testing.md create mode 100644 skills/flutter-dev/references/widget-patterns.md diff --git a/README.md b/README.md index 5529e27..7de7c3c 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README_zh.md b/README_zh.md index 62155d1..ec224bb 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 | diff --git a/skills/flutter-dev/SKILL.md b/skills/flutter-dev/SKILL.md new file mode 100644 index 0000000..0e33e8f --- /dev/null +++ b/skills/flutter-dev/SKILL.md @@ -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. diff --git a/skills/flutter-dev/references/animations.md b/skills/flutter-dev/references/animations.md new file mode 100644 index 0000000..b989153 --- /dev/null +++ b/skills/flutter-dev/references/animations.md @@ -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 createState() => _ImplicitAnimationExampleState(); +} + +class _ImplicitAnimationExampleState extends State { + 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( + 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 createState() => _ExplicitAnimationExampleState(); +} + +class _ExplicitAnimationExampleState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _scaleAnimation; + late final Animation _rotationAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + + _scaleAnimation = Tween(begin: 1.0, end: 1.2).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + + _rotationAnimation = Tween(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(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 createState() => _StaggeredAnimationState(); +} + +class _StaggeredAnimationState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final List> _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(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( + 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 fade({ + required LocalKey key, + required Widget child, + }) { + return CustomTransitionPage( + key: key, + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + ); + } + + static CustomTransitionPage slideUp({ + required LocalKey key, + required Widget child, + }) { + return CustomTransitionPage( + key: key, + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + )), + child: child, + ); + }, + ); + } + + static CustomTransitionPage scale({ + required LocalKey key, + required Widget child, + }) { + return CustomTransitionPage( + key: key, + child: child, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return ScaleTransition( + scale: Tween(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.* diff --git a/skills/flutter-dev/references/bloc-state.md b/skills/flutter-dev/references/bloc-state.md new file mode 100644 index 0000000..9d999a5 --- /dev/null +++ b/skills/flutter-dev/references/bloc-state.md @@ -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 { + 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 { + CounterBloc() : super(const CounterState(value: 0)) { + on(_onIncremented); + on(_onDecremented); + on(_onReset); + } + + void _onIncremented(CounterIncremented event, Emitter emit) { + emit(state.copyWith(value: state.value + 1)); + } + + void _onDecremented(CounterDecremented event, Emitter emit) { + emit(state.copyWith(value: state.value - 1)); + } + + void _onReset(CounterReset event, Emitter 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( + 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( + 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( + 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().add(FormSubmitted()) + : null, + child: const Text('Submit'), + ); + }, +); +``` + +### BlocSelector (Granular Rebuilds) + +```dart +BlocSelector( + selector: (state) => state.user.name, + builder: (context, name) { + return Text('Hello, $name'); + }, +); +``` + +## Async Bloc Pattern + +```dart +on((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().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( + 'emits incremented value when CounterIncremented added', + build: () => CounterBloc(), + act: (bloc) => bloc.add(CounterIncremented()), + expect: () => [const CounterState(value: 1)], +); + +blocTest( + '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.* diff --git a/skills/flutter-dev/references/forms.md b/skills/flutter-dev/references/forms.md new file mode 100644 index 0000000..eb3426d --- /dev/null +++ b/skills/flutter-dev/references/forms.md @@ -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 createState() => _LoginFormState(); +} + +class _LoginFormState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _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 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 extends FormField { + DropdownFormField({ + super.key, + required List> items, + super.initialValue, + super.validator, + super.onSaved, + String? labelText, + String? hintText, + ValueChanged? onChanged, + }) : super( + builder: (state) { + return InputDecorator( + decoration: InputDecoration( + labelText: labelText, + errorText: state.errorText, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + 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 { + 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 { + 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.new); + final emailController = useTextEditingController(); + final passwordController = useTextEditingController(); + final emailFocus = useFocusNode(); + final passwordFocus = useFocusNode(); + final isLoading = useState(false); + + Future 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 createState() => _ServerValidationFormState(); +} + +class _ServerValidationFormState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + Map> _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 _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 createState() => _AutoSaveFormState(); +} + +class _AutoSaveFormState extends State { + final _formKey = GlobalKey(); + Timer? _debounce; + bool _hasChanges = false; + + void _onChanged() { + setState(() => _hasChanges = true); + + _debounce?.cancel(); + _debounce = Timer(const Duration(seconds: 2), _autoSave); + } + + Future _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()` 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.* diff --git a/skills/flutter-dev/references/gorouter-navigation.md b/skills/flutter-dev/references/gorouter-navigation.md new file mode 100644 index 0000000..1e26155 --- /dev/null +++ b/skills/flutter-dev/references/gorouter-navigation.md @@ -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?; + 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((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.* diff --git a/skills/flutter-dev/references/localization.md b/skills/flutter-dev/references/localization.md new file mode 100644 index 0000000..c6ce238 --- /dev/null +++ b/skills/flutter-dev/references/localization.md @@ -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( + 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.* diff --git a/skills/flutter-dev/references/networking.md b/skills/flutter-dev/references/networking.md new file mode 100644 index 0000000..bd8edc4 --- /dev/null +++ b/skills/flutter-dev/references/networking.md @@ -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 _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> 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>.from( + (data['errors'] as Map).map( + (k, v) => MapEntry(k.toString(), List.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> _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 safeCall(Future Function() call) async { + try { + return await call(); + } on DioException catch (e) { + throw ApiErrorHandler.handle(e); + } + } +} + +class UserRepository extends BaseRepository { + UserRepository(super.dio); + + Future getUser(String id) => safeCall(() async { + final response = await dio.get('/users/$id'); + return User.fromJson(response.data); + }); + + Future> 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 updateUser(String id, Map 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 _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 json) { + return CachedResponse( + data: json['data'], + expiry: DateTime.parse(json['expiry']), + ); + } + + Map 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(Ref ref, String id) async { + final repository = ref.watch(userRepositoryProvider); + return repository.getUser(id); +} + +@riverpod +class Users extends _$Users { + @override + Future> build() => _fetch(); + + Future> _fetch() async { + final repository = ref.watch(userRepositoryProvider); + return repository.getUsers(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(_fetch); + } +} +``` + +## Request Cancellation + +```dart +class SearchRepository extends BaseRepository { + CancelToken? _searchToken; + + SearchRepository(super.dio); + + Future> 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.* diff --git a/skills/flutter-dev/references/performance.md b/skills/flutter-dev/references/performance.md new file mode 100644 index 0000000..15afcf4 --- /dev/null +++ b/skills/flutter-dev/references/performance.md @@ -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( + 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 processData() async { + final result = await compute(heavyComputation, data); + updateUI(result); +} + +// For multiple operations +Future 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 createState() => _MyWidgetState(); +} + +class _MyWidgetState extends State { + 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.* + diff --git a/skills/flutter-dev/references/platform-specific.md b/skills/flutter-dev/references/platform-specific.md new file mode 100644 index 0000000..9d59026 --- /dev/null +++ b/skills/flutter-dev/references/platform-specific.md @@ -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 showAdaptiveConfirmDialog( + BuildContext context, { + required String title, + required String content, +}) async { + if (Platform.isIOS) { + return showCupertinoDialog( + 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( + 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? 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 getPlatformVersion() async { + final version = await _channel.invokeMethod('getPlatformVersion'); + return version ?? 'Unknown'; + } + + static Future triggerHaptic() async { + await _channel.invokeMethod('triggerHaptic'); + } + + static Future> getDeviceInfo() async { + final result = await _channel.invokeMethod('getDeviceInfo'); + return Map.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 + +NSCameraUsageDescription +This app needs camera access to take photos + +NSPhotoLibraryUsageDescription +This app needs photo library access to save images + +NSLocationWhenInUseUsageDescription +This app needs location access to show nearby places + +NSMicrophoneUsageDescription +This app needs microphone access for voice recording +``` + +### 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 + + + + + + + + + + + + +``` + +### 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 + + + + + + + My App + + + + + + + +``` + +## 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.* diff --git a/skills/flutter-dev/references/project-structure.md b/skills/flutter-dev/references/project-structure.md new file mode 100644 index 0000000..87daa81 --- /dev/null +++ b/skills/flutter-dev/references/project-structure.md @@ -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((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((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((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.* + diff --git a/skills/flutter-dev/references/riverpod-state.md b/skills/flutter-dev/references/riverpod-state.md new file mode 100644 index 0000000..59d3674 --- /dev/null +++ b/skills/flutter-dev/references/riverpod-state.md @@ -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((ref) { + final name = ref.watch(userNameProvider); + return 'Hello, $name'; +}); + +// Simple mutable state +final counterProvider = StateProvider((ref) => 0); + +// Async state (API calls) +final usersProvider = FutureProvider>((ref) async { + final api = ref.read(apiProvider); + return api.getUsers(); +}); + +// Stream state (real-time) +final messagesProvider = StreamProvider>((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 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 build() async { + return ref.read(apiProvider).getCurrentUser(); + } + + Future 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 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 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.* diff --git a/skills/flutter-dev/references/testing.md b/skills/flutter-dev/references/testing.md new file mode 100644 index 0000000..541b80d --- /dev/null +++ b/skills/flutter-dev/references/testing.md @@ -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 + 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.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.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( + '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( + '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() + .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 pumpApp(Widget widget, {List? 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.* diff --git a/skills/flutter-dev/references/widget-patterns.md b/skills/flutter-dev/references/widget-patterns.md new file mode 100644 index 0000000..d6a9093 --- /dev/null +++ b/skills/flutter-dev/references/widget-patterns.md @@ -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.*