flutter

SKILL.md

Flutter Guide

Applies to: Flutter 3.x, Dart 3.x, Mobile (iOS/Android), Web, Desktop

Core Principles

  1. Widget Composition: Build complex UIs by composing small, focused widgets
  2. Declarative UI: Describe what the UI should look like; Flutter handles rendering
  3. Immutable Widgets: Widgets are configuration objects; state lives in State classes or providers
  4. Single Codebase: One Dart codebase targets iOS, Android, Web, macOS, Windows, Linux
  5. Riverpod for State: Use Riverpod as the primary state management solution
  6. Material 3 First: Default to Material 3 design system with useMaterial3: true

Guardrails

Widget Rules

  • Keep widget build methods under 50 lines (extract sub-widgets)
  • Prefer const constructors for all stateless widgets
  • Always use super.key in widget constructors
  • Use ConsumerWidget / ConsumerStatefulWidget when accessing providers
  • Dispose controllers, subscriptions, and animation controllers in dispose()
  • Never perform async work directly in build() -- use providers or FutureBuilder
  • Prefer composition over deep widget nesting (max 5-6 levels in one build method)

State Management (Riverpod)

  • Wrap app root in ProviderScope
  • Use Provider for synchronous values and dependency injection
  • Use StateProvider for simple mutable state (toggles, filters, counters)
  • Use AsyncNotifierProvider for async business logic with CRUD operations
  • Use StreamProvider for real-time data (auth state, WebSocket, Firestore)
  • Use ref.watch() in build methods; use ref.read() in callbacks and event handlers
  • Use ref.listen() for side effects (showing snackbars, navigation)
  • Never call ref.watch() outside of build methods or provider bodies

Navigation (go_router)

  • Define all routes in a single GoRouter configuration
  • Use named routes with context.goNamed() / context.pushNamed()
  • Implement redirect guards for authentication
  • Use ShellRoute for persistent navigation scaffolds (bottom nav, drawer)
  • Use pathParameters for required values, queryParameters for optional filters
  • Define an errorBuilder for unknown routes

File Naming

  • Widgets/screens: snake_case.dart (e.g., user_profile_screen.dart)
  • Providers: snake_case_provider.dart (e.g., auth_provider.dart)
  • Models: snake_case_model.dart (e.g., user_model.dart)
  • Repositories: snake_case_repository.dart
  • Tests: *_test.dart (co-located or in test/ mirroring lib/)

Project Structure

myapp/
├── lib/
│   ├── main.dart                 # Entry point, ProviderScope
│   ├── app.dart                  # MaterialApp.router configuration
│   ├── features/                 # Feature-first organization
│   │   └── auth/
│   │       ├── data/             # Models, repos impl, datasources
│   │       ├── domain/           # Entities, abstract repos, use cases
│   │       └── presentation/     # Screens, widgets, providers
│   ├── core/
│   │   ├── constants/            # App-wide constants
│   │   ├── errors/               # Failure/exception classes
│   │   ├── network/              # API client, interceptors
│   │   ├── router/               # GoRouter configuration
│   │   ├── theme/                # Material 3 theme
│   │   ├── utils/                # Validators, formatters
│   │   └── widgets/              # Shared reusable widgets
│   └── l10n/                     # Localization ARB files
├── test/
│   ├── unit/                     # Provider and logic tests
│   ├── widget/                   # Widget tests
│   └── integration/              # End-to-end tests
├── integration_test/             # Integration test driver
├── pubspec.yaml
└── analysis_options.yaml
  • features/ follows clean architecture: data, domain, presentation layers
  • core/ for cross-cutting concerns shared across features
  • domain/ contains pure Dart (no Flutter imports)
  • data/ handles serialization, networking, and storage

Application Setup

Entry Point

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Initialize services (Firebase, Hive, etc.) here
  runApp(const ProviderScope(child: MyApp()));
}

App Widget

// lib/app.dart
class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.watch(routerProvider);
    return MaterialApp.router(
      title: 'My App',
      theme: AppTheme.light,
      darkTheme: AppTheme.dark,
      themeMode: ThemeMode.system,
      routerConfig: router,
      debugShowCheckedModeBanner: false,
    );
  }
}

Widget Composition

Screen Widget (ConsumerWidget)

class HomeScreen extends ConsumerWidget {
  const HomeScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final usersAsync = ref.watch(filteredUsersProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Users')),
      body: usersAsync.when(
        data: (users) => UserListView(users: users),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, _) => ErrorRetryWidget(
          message: error.toString(),
          onRetry: () => ref.invalidate(usersProvider),
        ),
      ),
    );
  }
}

Stateful Widget Pattern

Use ConsumerStatefulWidget when you need TextEditingController, AnimationController, or other objects that require dispose(). Key patterns:

  • Create controllers in the state class, dispose them in dispose()
  • Use ref.watch() in build() for reactive state
  • Use ref.read() in callbacks like _submit()
  • Use ref.listen() for side effects (snackbars, navigation on error/success)

See references/patterns.md for the full LoginForm example.

State Management (Riverpod)

AsyncNotifier for CRUD

final usersProvider = AsyncNotifierProvider<UsersNotifier, List<User>>(
  UsersNotifier.new,
);

class UsersNotifier extends AsyncNotifier<List<User>> {
  
  Future<List<User>> build() async {
    final repo = ref.read(userRepositoryProvider);
    return repo.getUsers();
  }

  Future<void> addUser(User user) async {
    final repo = ref.read(userRepositoryProvider);
    await repo.createUser(user);
    state = AsyncData([...state.value ?? [], user]);
  }

  Future<void> deleteUser(String id) async {
    final repo = ref.read(userRepositoryProvider);
    await repo.deleteUser(id);
    state = AsyncData(
      state.value?.where((u) => u.id != id).toList() ?? [],
    );
  }
}

Derived / Filtered Providers

final searchQueryProvider = StateProvider<String>((ref) => '');

final filteredUsersProvider = Provider<AsyncValue<List<User>>>((ref) {
  final users = ref.watch(usersProvider);
  final query = ref.watch(searchQueryProvider).toLowerCase();
  return users.whenData((list) {
    if (query.isEmpty) return list;
    return list.where((u) => u.name.toLowerCase().contains(query)).toList();
  });
});

Stream Provider (Auth State)

final authStateProvider = StreamProvider<User?>((ref) {
  final repo = ref.watch(authRepositoryProvider);
  return repo.authStateChanges;
});

final currentUserProvider = Provider<User?>((ref) {
  return ref.watch(authStateProvider).valueOrNull;
});

Navigation (go_router)

final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authStateProvider);

  return GoRouter(
    initialLocation: '/',
    redirect: (context, state) {
      final isLoggedIn = authState.valueOrNull != null;
      final isOnLogin = state.matchedLocation == '/login';
      if (!isLoggedIn && !isOnLogin) return '/login';
      if (isLoggedIn && isOnLogin) return '/';
      return null;
    },
    routes: [
      GoRoute(
        path: '/login', name: 'login',
        builder: (_, __) => const LoginScreen(),
      ),
      ShellRoute(
        builder: (_, __, child) => ScaffoldWithNavBar(child: child),
        routes: [
          GoRoute(path: '/', name: 'home', builder: (_, __) => const HomeScreen()),
          GoRoute(path: '/profile', name: 'profile', builder: (_, __) => const ProfileScreen()),
          GoRoute(
            path: '/users/:id', name: 'user-detail',
            builder: (_, state) => UserDetailScreen(userId: state.pathParameters['id']!),
          ),
        ],
      ),
    ],
    errorBuilder: (_, state) => ErrorScreen(error: state.error),
  );
});

Theming (Material 3)

class AppTheme {
  AppTheme._();

  static ThemeData get light => ThemeData(
    useMaterial3: true,
    colorScheme: ColorScheme.fromSeed(
      seedColor: const Color(0xFF6750A4),
      brightness: Brightness.light,
    ),
    appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
    inputDecorationTheme: InputDecorationTheme(
      border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
      filled: true,
    ),
  );

  static ThemeData get dark => ThemeData(
    useMaterial3: true,
    colorScheme: ColorScheme.fromSeed(
      seedColor: const Color(0xFF6750A4),
      brightness: Brightness.dark,
    ),
  );
}

Data Layer

Freezed Models


class UserModel with _$UserModel {
  const UserModel._();
  const factory UserModel({
    required String id,
    required String email,
    required String name,
    (name: 'avatar_url') String? avatarUrl,
    (name: 'created_at') required DateTime createdAt,
  }) = _UserModel;

  factory UserModel.fromJson(Map<String, dynamic> json) =>
      _$UserModelFromJson(json);

  User toEntity() => User(
    id: id, email: email, name: name,
    avatarUrl: avatarUrl, createdAt: createdAt,
  );
}

Repository Pattern

Use abstract interfaces in domain/ and implementations in data/. Repositories return Either<Failure, T> for error handling. Inject via Riverpod Provider:

// domain layer: abstract contract
abstract class AuthRepository {
  Stream<User?> get authStateChanges;
  Future<Either<Failure, User>> signInWithEmailAndPassword(String email, String password);
  Future<Either<Failure, void>> signOut();
}

// provider: inject implementation
final authRepositoryProvider = Provider<AuthRepository>((ref) {
  return AuthRepositoryImpl(
    remoteDataSource: ref.watch(authRemoteDataSourceProvider),
    localDataSource: ref.watch(authLocalDataSourceProvider),
  );
});

See references/patterns.md for full repository implementation and API client patterns.

API Client (Dio)

final dioProvider = Provider<Dio>((ref) {
  return Dio(BaseOptions(
    baseUrl: AppConstants.apiBaseUrl,
    connectTimeout: const Duration(seconds: 30),
    receiveTimeout: const Duration(seconds: 30),
    headers: {'Content-Type': 'application/json'},
  ))..interceptors.addAll([AuthInterceptor(ref), LogInterceptor()]);
});

Platform Channels

When native platform functionality is needed beyond existing packages:

// MethodChannel for one-off calls
static const _channel = MethodChannel('com.example.app/battery');
Future<int> getBatteryLevel() async {
  final level = await _channel.invokeMethod<int>('getBatteryLevel');
  return level ?? -1;
}

// EventChannel for continuous streams
static const _eventChannel = EventChannel('com.example.app/sensors');
Stream<SensorData> get sensorStream =>
    _eventChannel.receiveBroadcastStream().map((e) => SensorData.fromMap(e));

Testing Overview

Widget Test

Widget createWidget() {
  return ProviderScope(
    overrides: [authRepositoryProvider.overrideWithValue(mockRepo)],
    child: const MaterialApp(home: Scaffold(body: LoginForm())),
  );
}

testWidgets('shows validation errors for empty fields', (tester) async {
  await tester.pumpWidget(createWidget());
  await tester.tap(find.text('Sign In'));
  await tester.pump();
  expect(find.text('Email is required'), findsOneWidget);
});

Provider Test

final container = ProviderContainer(
  overrides: [authRepositoryProvider.overrideWithValue(mockRepo)],
);
addTearDown(container.dispose);

test('login success clears error state', () async {
  when(() => mockRepo.signInWithEmailAndPassword(any(), any()))
      .thenAnswer((_) async => Right(testUser));
  await container.read(loginProvider.notifier).login('a@b.com', 'pass');
  expect(container.read(loginProvider).hasError, false);
});

Commands

# Create project
flutter create myapp

# Run
flutter run                           # Default device
flutter run -d chrome                 # Web
flutter run -d macos                  # macOS desktop

# Build
flutter build apk                    # Android APK
flutter build ios                    # iOS archive
flutter build web                    # Web build

# Code generation (Freezed, json_serializable, Riverpod codegen)
dart run build_runner build --delete-conflicting-outputs
dart run build_runner watch           # Continuous generation

# Testing
flutter test                          # All unit + widget tests
flutter test --coverage               # With coverage report
flutter test integration_test/        # Integration tests

# Quality
flutter analyze                       # Static analysis
dart format .                         # Format all files
dart fix --apply                      # Auto-apply lint fixes

Recommended Dependencies

Package Purpose
flutter_riverpod State management
go_router Declarative routing
dio HTTP client with interceptors
freezed_annotation + freezed Immutable data classes (codegen)
json_annotation + json_serializable JSON serialization (codegen)
dartz Either type for error handling
shared_preferences Key-value local storage
flutter_secure_storage Encrypted credential storage
mocktail Mocking for tests (no codegen)
flutter_lints Recommended lint rules

References

For detailed patterns and examples, see:

  • references/patterns.md -- Widget patterns, animations, networking, local storage, testing, platform-specific code, performance

External References

Weekly Installs
6
Repository
ar4mirez/samuel
GitHub Stars
3
First Seen
13 days ago
Installed on
cline6
github-copilot6
codex6
kimi-cli6
gemini-cli6
cursor6