Flutter Skill
Project Structure
project/├── lib/│ ├── core/ # Core utilities│ │ ├── constants/ # App constants│ │ ├── extensions/ # Dart extensions│ │ ├── router/ # go_router configuration│ │ │ └── app_router.dart│ │ └── theme/ # App theme│ │ └── app_theme.dart│ ├── data/ # Data layer│ │ ├── models/ # Freezed data models│ │ ├── repositories/ # Repository implementations│ │ └── services/ # API services│ ├── domain/ # Domain layer│ │ ├── entities/ # Business entities│ │ └── repositories/ # Repository interfaces│ ├── presentation/ # UI layer│ │ ├── common/ # Shared widgets│ │ ├── features/ # Feature modules│ │ │ └── feature_name/│ │ │ ├── providers/ # Riverpod providers│ │ │ ├── widgets/ # Feature-specific widgets│ │ │ └── feature_screen.dart│ │ └── providers/ # Global providers│ ├── main.dart│ └── app.dart├── test/│ ├── unit/ # Unit tests│ ├── widget/ # Widget tests│ └── integration/ # Integration tests├── pubspec.yaml├── analysis_options.yaml└── CLAUDE.mdRiverpod State Management
Provider Types
dart
// Simple value providerfinal appNameProvider = Provider<String>((ref) => 'My App');
// StateProvider for simple mutable statefinal counterProvider = StateProvider<int>((ref) => 0);
// NotifierProvider for complex state logicfinal userProvider = NotifierProvider<UserNotifier, User?>(() => UserNotifier());
// AsyncNotifierProvider for async operationsfinal usersProvider = AsyncNotifierProvider<UsersNotifier, List<User>>( () => UsersNotifier(),);
// FutureProvider for simple async datafinal configProvider = FutureProvider<Config>((ref) async { return await ref.watch(configServiceProvider).loadConfig();});
// StreamProvider for real-time datafinal messagesProvider = StreamProvider<List<Message>>((ref) { return ref.watch(messageServiceProvider).watchMessages();});
// Family providers for parameterized datafinal userByIdProvider = FutureProvider.family<User, String>((ref, userId) async { return await ref.watch(userRepositoryProvider).getUser(userId);});Notifier Pattern
dart
@riverpodclass Users extends _$Users { @override Future<List<User>> build() async { return await _fetchUsers(); }
Future<List<User>> _fetchUsers() async { final repository = ref.read(userRepositoryProvider); return await repository.getUsers(); }
Future<void> refresh() async { state = const AsyncLoading(); state = await AsyncValue.guard(() => _fetchUsers()); }
Future<void> addUser(User user) async { final repository = ref.read(userRepositoryProvider); await repository.addUser(user); ref.invalidateSelf(); }}AsyncValue Handling
dart
class UsersScreen extends ConsumerWidget { const UsersScreen({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { final usersAsync = ref.watch(usersProvider);
return usersAsync.when( data: (users) => UsersList(users: users), loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => ErrorDisplay( error: error, onRetry: () => ref.invalidate(usersProvider), ), ); }}
// Pattern matching alternativeWidget build(BuildContext context, WidgetRef ref) { final usersAsync = ref.watch(usersProvider);
return switch (usersAsync) { AsyncData(:final value) => UsersList(users: value), AsyncLoading() => const LoadingIndicator(), AsyncError(:final error) => ErrorDisplay(error: error), };}ref Methods
dart
// watch - rebuilds when provider changesfinal users = ref.watch(usersProvider);
// read - one-time read, no rebuildvoid onButtonPressed() { ref.read(counterProvider.notifier).state++;}
// listen - react to changes without rebuildref.listen(authProvider, (previous, next) { if (next == null) { context.go('/login'); }});
// invalidate - force refreshref.invalidate(usersProvider);
// keepAlive - prevent auto-disposefinal link = ref.keepAlive();// Later: link.close() to allow disposalFreezed Data Models
Model Definition
dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';part 'user.g.dart';
@freezedclass User with _$User { const factory User({ required String id, required String name, required String email, @Default(false) bool isActive, DateTime? createdAt, }) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);}
// Union types for states@freezedsealed class AuthState with _$AuthState { const factory AuthState.initial() = _Initial; const factory AuthState.loading() = _Loading; const factory AuthState.authenticated(User user) = _Authenticated; const factory AuthState.unauthenticated() = _Unauthenticated; const factory AuthState.error(String message) = _Error;}Using Freezed Unions
dart
Widget build(BuildContext context, WidgetRef ref) { final authState = ref.watch(authProvider);
return authState.when( initial: () => const SplashScreen(), loading: () => const LoadingScreen(), authenticated: (user) => HomeScreen(user: user), unauthenticated: () => const LoginScreen(), error: (message) => ErrorScreen(message: message), );}go_router Navigation
Router Configuration
dart
final routerProvider = Provider<GoRouter>((ref) { final authState = ref.watch(authProvider);
return GoRouter( initialLocation: '/', refreshListenable: authState, redirect: (context, state) { final isLoggedIn = authState.valueOrNull != null; final isLoggingIn = state.matchedLocation == '/login';
if (!isLoggedIn && !isLoggingIn) return '/login'; if (isLoggedIn && isLoggingIn) return '/'; return null; }, routes: [ GoRoute( path: '/', builder: (context, state) => const HomeScreen(), routes: [ GoRoute( path: 'user/:id', builder: (context, state) => UserScreen( userId: state.pathParameters['id']!, ), ), ], ), GoRoute( path: '/login', builder: (context, state) => const LoginScreen(), ), ], errorBuilder: (context, state) => ErrorScreen(error: state.error), );});Navigation
dart
// Navigate to routecontext.go('/user/123');
// Push onto stackcontext.push('/user/123');
// Pop current routecontext.pop();
// Replace current routecontext.pushReplacement('/home');
// Named routescontext.goNamed('user', pathParameters: {'id': '123'});Widget Patterns
ConsumerWidget vs ConsumerStatefulWidget
dart
// Stateless with Riverpodclass UserCard extends ConsumerWidget { const UserCard({super.key, required this.userId});
final String userId;
@override Widget build(BuildContext context, WidgetRef ref) { final user = ref.watch(userByIdProvider(userId)); return user.when( data: (user) => Card(child: Text(user.name)), loading: () => const CardSkeleton(), error: (e, _) => ErrorCard(error: e), ); }}
// Stateful with Riverpodclass SearchScreen extends ConsumerStatefulWidget { const SearchScreen({super.key});
@override ConsumerState<SearchScreen> createState() => _SearchScreenState();}
class _SearchScreenState extends ConsumerState<SearchScreen> { final _controller = TextEditingController();
@override void dispose() { _controller.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { final results = ref.watch(searchProvider(_controller.text)); return Column( children: [ TextField( controller: _controller, onChanged: (_) => setState(() {}), ), Expanded(child: SearchResults(results: results)), ], ); }}HookConsumerWidget (with flutter_hooks)
dart
class AnimatedCounter extends HookConsumerWidget { const AnimatedCounter({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { final controller = useAnimationController(duration: const Duration(milliseconds: 300)); final count = ref.watch(counterProvider);
useEffect(() { controller.forward(from: 0); return null; }, [count]);
return ScaleTransition( scale: controller, child: Text('$count'), ); }}Testing with Mocktail
Unit Tests
dart
import 'package:flutter_test/flutter_test.dart';import 'package:mocktail/mocktail.dart';import 'package:riverpod/riverpod.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() { late MockUserRepository mockRepository; late ProviderContainer container;
setUp(() { mockRepository = MockUserRepository(); container = ProviderContainer( overrides: [ userRepositoryProvider.overrideWithValue(mockRepository), ], ); });
tearDown(() { container.dispose(); });
test('usersProvider returns list of users', () async { final users = [User(id: '1', name: 'John', email: '[email protected]')]; when(() => mockRepository.getUsers()).thenAnswer((_) async => users);
final result = await container.read(usersProvider.future);
expect(result, equals(users)); verify(() => mockRepository.getUsers()).called(1); });}Widget Tests
dart
void main() { testWidgets('UserCard displays user name', (tester) async { final user = User(id: '1', name: 'John', email: '[email protected]');
await tester.pumpWidget( ProviderScope( overrides: [ userByIdProvider('1').overrideWith((_) => AsyncData(user)), ], child: const MaterialApp(home: UserCard(userId: '1')), ), );
expect(find.text('John'), findsOneWidget); });
testWidgets('UserCard shows loading indicator', (tester) async { await tester.pumpWidget( ProviderScope( overrides: [ userByIdProvider('1').overrideWith((_) => const AsyncLoading()), ], child: const MaterialApp(home: UserCard(userId: '1')), ), );
expect(find.byType(CircularProgressIndicator), findsOneWidget); });}pubspec.yaml
yaml
name: my_appdescription: A Flutter applicationpublish_to: 'none'version: 1.0.0+1
environment: sdk: '>=3.2.0 <4.0.0'
dependencies: flutter: sdk: flutter
# State management flutter_riverpod: ^2.4.9 riverpod_annotation: ^2.3.3
# Data models freezed_annotation: ^2.4.1 json_annotation: ^4.8.1
# Navigation go_router: ^13.0.0
# Networking dio: ^5.4.0
# Storage shared_preferences: ^2.2.2
# Utils intl: ^0.19.0
dev_dependencies: flutter_test: sdk: flutter
# Code generation build_runner: ^2.4.8 freezed: ^2.4.6 json_serializable: ^6.7.1 riverpod_generator: ^2.3.9
# Testing mocktail: ^1.0.2
# Linting flutter_lints: ^3.0.1GitHub Actions
yaml
name: Flutter CI
on: push: branches: [main] pull_request: branches: [main]
jobs: build: runs-on: ubuntu-latest
steps: - uses: actions/checkout@v4
- uses: subosito/flutter-action@v2 with: flutter-version: '3.16.0' channel: 'stable' cache: true
- name: Install dependencies run: flutter pub get
- name: Generate code run: dart run build_runner build --delete-conflicting-outputs
- name: Analyze run: flutter analyze --fatal-infos
- name: Run tests run: flutter test --coverage
- name: Build APK run: flutter build apk --releaseanalysis_options.yaml
yaml
include: package:flutter_lints/flutter.yaml
analyzer: exclude: - "**/*.g.dart" - "**/*.freezed.dart" errors: invalid_annotation_target: ignore language: strict-casts: true strict-inference: true strict-raw-types: true
linter: rules: - always_declare_return_types - avoid_dynamic_calls - avoid_print - avoid_type_to_string - cancel_subscriptions - close_sinks - prefer_const_constructors - prefer_const_declarations - prefer_final_locals - require_trailing_commas - unawaited_futures - use_super_parametersFlutter Anti-Patterns
- ❌ Provider without autoDispose - Use
.autoDisposeto prevent memory leaks - ❌ watch in callbacks - Use
ref.read()in onPressed/callbacks, notref.watch() - ❌ Business logic in widgets - Move to Notifiers/providers
- ❌ Mutable state in providers - Use Freezed for immutable models
- ❌ Not using AsyncValue - Handle loading/error states with
when() - ❌ setState with Riverpod - Use providers for shared state
- ❌ Passing ref to functions - Keep ref usage within widgets/providers
- ❌ Deeply nested Consumer - Use ConsumerWidget instead
- ❌ Not using family for params - Use
.familyfor parameterized providers - ❌ Global GoRouter instance - Use Provider for router with redirect logic
- ❌ BuildContext across async - Store values before await, not context
- ❌ Ignoring dispose - Clean up controllers in ConsumerStatefulWidget


