agentsclimarketplace

Flutter app builder

Skill mouchegmouradian/claude-code-skills/skills/flutter-app-builder

Production-ready skills that give Claude deep domain expertise. Install once, get senior-engineer-quality output from natural language.

Install
npx -y skills add mouchegmouradian/claude-code-skills --skill flutter-app-builder

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Create Flutter applications scaled to project complexity. Automatically detects whether to use a simple single-module architecture (manual DI, shared_preferences) or a full production structure with get_it+injectable, freezed, Drift, and clean architecture. Use when building Flutter apps with Dart, BLoC/Cubit, go_router, and MVVM-like patterns. Triggers on requests to create Flutter projects, screens, BLoCs, Cubits, repositories, feature modules, or when asked about Flutter architecture patterns.

SKILL.md

24.8 KB, as published. Nobody here has run it

Flutter Development

Build Flutter applications following clean architecture principles, BLoC/Cubit state management, and complexity-appropriate patterns.

Quick Reference

TaskReference File
Complexity tier selectioncomplexity-tiers.md
Architecture layers (UI, Domain, Data)architecture.md
BLoC/Cubit patterns & state managementbloc-patterns.md
Navigation with go_routernavigation.md
Data layer (Drift, Dio, Repository)data-layer.md
Dependency injection (get_it, injectable)di.md
Testing approachtesting.md

Step 0: Detect Complexity Tier

Before writing any code, classify the project into one of two tiers. This determines every architectural decision that follows.

Detection Heuristics

Apply these signals in order. The first confident match wins.

Tier 1 — Simple (default for ambiguous small projects):

  • Described as: demo, prototype, personal app, learning project, side project, sample, or proof-of-concept
  • 1–3 distinct user-facing features
  • Features are independent — no described need to share data across screens
  • Solo developer with no production deployment requirements
  • No described need for background sync, complex caching, or multi-team workflow
  • Examples: counter app, note-taking app, flashcard app, unit converter, habit tracker

Tier 2 — Production:

  • 3+ distinct user-facing features, or
  • Team or production release, or
  • Needs testable DI (injectable), type-safe code generation, or clean architecture boundaries, or
  • Complex local persistence (relational data, sync), or
  • Existing codebase being extended with production expectations
  • Examples: e-commerce app, social app, fitness tracker with sync, real-time chat app

Fallback: Ask When Uncertain

If the description is ambiguous, ask exactly one clarifying question before proceeding:

"To choose the right architecture, how many distinct features does this app need, and is this a personal/prototype app or something for a team or production release?"

Use the answer to re-apply the heuristics above.

After Classification

State the selected tier and its rationale in one sentence before generating any code. Example:

"This is a Tier 1 (Simple) project — a personal prototype with 2 features and no shared data layer."

Then follow only the blueprint for that tier. Do not mix patterns across tiers.


Workflow Decision Tree

After detecting the tier (see Step 0 above):

Tier 1 — Creating a new project? → Single lib/ folder with flat feature packages → Manual DI — pass dependencies via constructor in main.dart or router → No injectable, no freezed, no build_runnershared_preferences for key-value storage; Drift only if explicitly needed → Plain Dart sealed classes for state → See Simple Tier Patterns below

Tier 2 — Creating a new project? → Feature-based package structure inside lib/get_it + injectable for DI → freezed for all domain models and state classes → drift for local database; dio for networking → Type-safe go_router with GoRouteData + @TypedGoRoutebuild_runner for code generation (freezed, injectable, drift) → See Production Tier Patterns below

Adding a new feature? (all tiers) → Tier 1: Add a new package under lib/features/featurename/ → Tier 2: Create full feature package with data/, domain/, presentation/ layers

Building UI screens? (all tiers) → Always create Page + Screen separation (Page owns BlocProvider, Screen is pure UI) → Always use sealed state classes (Loading/Success/Error)

Setting up data layer? → Tier 1: Concrete class, no interface required for simple cases → Tier 2: Interface + implementation; read data-layer.md

Working with streams/async? (all tiers) → Use Stream<T> for all data that changes over time → Cubits/BLoCs emit state — never expose mutable state directly

Core Principles

  1. BLoC/Cubit for all tiers: UI state is always managed by a BLoC or Cubit — never setState for business logic
  2. Page/Screen separation: Page creates BlocProvider and wires DI; Screen is pure UI that reads state
  3. Sealed states: Always model Loading/Success/Error as sealed classes (plain in Tier 1, freezed in Tier 2)
  4. Reactive streams: Use Stream<T> for all data that changes over time
  5. No mocking libraries: Use test doubles that implement the same interfaces
  6. Offline-first (Tier 2): Local Drift database is source of truth when network sync is required
  7. Clean architecture (Tier 2): UI → Domain (optional UseCases) → Data

Architecture Layers

┌─────────────────────────────────────────┐
│              UI Layer                    │
│  (Flutter Widgets + BLoC/Cubit)         │
├─────────────────────────────────────────┤
│           Domain Layer                   │
│  (Use Cases - optional, Tier 2 only)    │
├─────────────────────────────────────────┤
│            Data Layer                    │
│  (Repositories + DataSources)            │
└─────────────────────────────────────────┘

Tier 2 only. Domain layer with UseCases is optional and applies only when there is real reuse or transformation logic to encapsulate. Tier 1 projects connect the Cubit directly to the repository.


Simple Tier Patterns (Tier 1 Only)

Use these patterns only when the project is classified as Tier 1. Do not add injectable, freezed, or code generation to Tier 1 projects.

Project Structure (Tier 1)

lib/
├── main.dart                    # Entry point, DI wiring, router
├── app.dart                     # MaterialApp.router
├── router.dart                  # GoRouter configuration
├── data/
│   └── item_repository.dart     # Concrete class, no interface required
└── features/
    └── items/
        ├── item_page.dart       # Creates BlocProvider
        ├── item_screen.dart     # Pure UI with BlocBuilder
        ├── item_cubit.dart      # Cubit state machine
        └── item_state.dart      # Sealed state classes

State Pattern (Tier 1 — Plain Sealed Classes, No freezed)

// item_state.dart
sealed class ItemState {}

class ItemInitial extends ItemState {}

class ItemLoading extends ItemState {}

class ItemLoaded extends ItemState {
  final List<Item> items;
  ItemLoaded(this.items);
}

class ItemError extends ItemState {
  final String message;
  ItemError(this.message);
}

Cubit Pattern (Tier 1)

// item_cubit.dart
class ItemCubit extends Cubit<ItemState> {
  ItemCubit(this._repository) : super(ItemInitial());

  final ItemRepository _repository;

  Future<void> loadItems() async {
    emit(ItemLoading());
    try {
      final items = await _repository.getItems();
      emit(ItemLoaded(items));
    } catch (e) {
      emit(ItemError(e.toString()));
    }
  }

  Future<void> deleteItem(String id) async {
    await _repository.deleteItem(id);
    await loadItems();
  }
}

For very simple state (single value, no loading/error), a minimal Cubit is acceptable:

// counter_state.dart
sealed class CounterState {}
class CounterInitial extends CounterState {}
class CounterLoaded extends CounterState {
  final int count;
  CounterLoaded(this.count);
}

// counter_cubit.dart
class CounterCubit extends Cubit<CounterState> {
  CounterCubit() : super(CounterInitial());
  void increment() => emit(CounterLoaded(
    state is CounterLoaded ? (state as CounterLoaded).count + 1 : 1,
  ));
}

Page/Screen Separation (Tier 1)

// item_page.dart — owns the Cubit via BlocProvider
class ItemPage extends StatelessWidget {
  const ItemPage({super.key});

  @override
  Widget build(BuildContext context) => BlocProvider(
    create: (context) => ItemCubit(
      context.read<ItemRepository>(),
    )..loadItems(),
    child: const ItemScreen(),
  );
}

// item_screen.dart — pure UI, reads state via BlocBuilder
class ItemScreen extends StatelessWidget {
  const ItemScreen({super.key});

  @override
  Widget build(BuildContext context) => BlocBuilder<ItemCubit, ItemState>(
    builder: (context, state) => switch (state) {
      ItemInitial() => const SizedBox.shrink(),
      ItemLoading() => const Center(child: CircularProgressIndicator()),
      ItemLoaded(:final items) => ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) => ListTile(title: Text(items[index].name)),
        ),
      ItemError(:final message) => Center(child: Text(message)),
    },
  );
}

The Page/Screen split ensures the BloC/Cubit lifecycle is tied to the route, not a parent widget. Screen never creates or owns state — it only reads it.

Repository Pattern (Tier 1 — Concrete Class)

// item_repository.dart
class ItemRepository {
  Future<List<Item>> getItems() async {
    // In-memory, shared_preferences, or simple local file
    final prefs = await SharedPreferences.getInstance();
    final raw = prefs.getStringList('items') ?? [];
    return raw.map(Item.fromJson).toList();
  }

  Future<void> saveItem(Item item) async {
    final prefs = await SharedPreferences.getInstance();
    final raw = prefs.getStringList('items') ?? [];
    prefs.setStringList('items', [...raw, item.toJson()]);
  }

  Future<void> deleteItem(String id) async {
    final prefs = await SharedPreferences.getInstance();
    final raw = prefs.getStringList('items') ?? [];
    prefs.setStringList('items', raw.where((e) => e != id).toList());
  }
}

Manual DI in main.dart (Tier 1)

// main.dart
void main() {
  final repository = ItemRepository();
  runApp(MyApp(repository: repository));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key, required this.repository});

  final ItemRepository repository;

  @override
  Widget build(BuildContext context) => RepositoryProvider.value(
    value: repository,
    child: MaterialApp.router(
      routerConfig: buildRouter(repository),
    ),
  );
}

go_router Setup (Tier 1 — String Routes)

// router.dart
GoRouter buildRouter(ItemRepository repository) => GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (_, __) => const ItemPage(),
    ),
    GoRoute(
      path: '/detail/:id',
      builder: (context, state) => DetailPage(
        id: state.pathParameters['id']!,
      ),
    ),
  ],
);

Tier 1 uses plain string path routes for simplicity. Tier 2 uses GoRouteData + @TypedGoRoute for compile-time safety.

pubspec.yaml (Tier 1 — No Code Generation)

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^9.1.1
  go_router: ^17.1.0
  get_it: 9.2.1
  shared_preferences: ^2.5.4
  # drift: ^2.0.0  # only if explicitly needed for relational data

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0

No build_runner, no injectable, no freezed. This is intentional.


Production Tier Patterns (Tier 2 Only)

Use these patterns when the project is classified as Tier 2. Feature-based structure with get_it + injectable, freezed, and build_runner.

Project Structure (Tier 2)

lib/
├── main.dart                    # Entry point, configureDependencies()
├── app.dart                     # MaterialApp.router
├── injection.dart               # get_it setup (@InjectableInit)
├── injection.config.dart        # Generated by injectable
├── router/
│   ├── app_router.dart          # GoRouter with all typed routes
│   └── app_router.g.dart        # Generated
├── core/
│   ├── error/                   # Failure types
│   └── usecase/                 # UseCase base class
├── features/
│   └── items/
│       ├── data/
│       │   ├── datasource/
│       │   │   ├── local/       # Drift DAO
│       │   │   └── remote/      # Dio API client
│       │   ├── models/          # DTOs (freezed)
│       │   └── repositories/   # ItemRepositoryImpl
│       ├── domain/
│       │   ├── entities/        # Item (freezed)
│       │   ├── repositories/    # ItemRepository (interface)
│       │   └── usecases/        # GetItemsUseCase (optional)
│       └── presentation/
│           ├── bloc/
│           │   ├── item_bloc.dart
│           │   ├── item_event.dart
│           │   └── item_state.dart
│           ├── item_page.dart
│           └── item_screen.dart

freezed State + Event Classes (Tier 2)

// item_state.dart
@freezed
sealed class ItemState with _$ItemState {
  const factory ItemState.loading() = _Loading;
  const factory ItemState.success(List<Item> items) = _Success;
  const factory ItemState.error(String message) = _Error;
}

// item_event.dart
@freezed
sealed class ItemEvent with _$ItemEvent {
  const factory ItemEvent.started() = _Started;
  const factory ItemEvent.refreshed() = _Refreshed;
  const factory ItemEvent.deleted(String id) = _Deleted;
}

BLoC Pattern (Tier 2 — With Injectable)

// item_bloc.dart
@injectable
class ItemBloc extends Bloc<ItemEvent, ItemState> {
  ItemBloc(this._repository) : super(const ItemState.loading()) {
    on<_Started>(_onStarted);
    on<_Refreshed>(_onStarted);
    on<_Deleted>(_onDeleted);
  }

  final ItemRepository _repository;

  Future<void> _onStarted(_Started event, Emitter<ItemState> emit) async {
    emit(const ItemState.loading());
    try {
      final items = await _repository.getItems();
      emit(ItemState.success(items));
    } catch (e) {
      emit(ItemState.error(e.toString()));
    }
  }

  Future<void> _onDeleted(_Deleted event, Emitter<ItemState> emit) async {
    await _repository.deleteItem(event.id);
    add(const ItemEvent.started());
  }
}

Use BLoC (event-driven) in Tier 2 when the feature has multiple distinct events. Use Cubit when state transitions are simple method calls without complex event routing.

Page/Screen Separation (Tier 2)

// item_page.dart — wires DI via getIt, owns BlocProvider
class ItemPage extends StatelessWidget {
  const ItemPage({super.key});

  @override
  Widget build(BuildContext context) => BlocProvider(
    create: (context) => getIt<ItemBloc>()..add(const ItemEvent.started()),
    child: const ItemScreen(),
  );
}

// item_screen.dart — pure UI
class ItemScreen extends StatelessWidget {
  const ItemScreen({super.key});

  @override
  Widget build(BuildContext context) => BlocBuilder<ItemBloc, ItemState>(
    builder: (context, state) => switch (state) {
      _Loading() => const Center(child: CircularProgressIndicator()),
      _Success(:final items) => ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) => ListTile(
            title: Text(items[index].name),
            trailing: IconButton(
              icon: const Icon(Icons.delete),
              onPressed: () => context.read<ItemBloc>().add(
                ItemEvent.deleted(items[index].id),
              ),
            ),
          ),
        ),
      _Error(:final message) => Center(child: Text(message)),
    },
  );
}

Repository Interface + Implementation (Tier 2)

// domain/repositories/item_repository.dart
abstract interface class ItemRepository {
  Future<List<Item>> getItems();
  Stream<List<Item>> watchItems();
  Future<void> deleteItem(String id);
}

// data/repositories/item_repository_impl.dart
@LazySingleton(as: ItemRepository)
class ItemRepositoryImpl implements ItemRepository {
  ItemRepositoryImpl(this._localDataSource, this._remoteDataSource);

  final ItemLocalDataSource _localDataSource;
  final ItemRemoteDataSource _remoteDataSource;

  @override
  Future<List<Item>> getItems() async {
    try {
      final remote = await _remoteDataSource.fetchItems();
      await _localDataSource.upsertAll(remote);
    } catch (_) {
      // Fall through to local data on error
    }
    return _localDataSource.getItems();
  }

  @override
  Stream<List<Item>> watchItems() => _localDataSource.watchItems();

  @override
  Future<void> deleteItem(String id) => _localDataSource.deleteItem(id);
}

freezed Domain Model (Tier 2)

// domain/entities/item.dart
@freezed
class Item with _$Item {
  const factory Item({
    required String id,
    required String name,
    required DateTime createdAt,
    @Default(false) bool isCompleted,
  }) = _Item;
}

get_it + injectable Setup (Tier 2)

// injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart';

final getIt = GetIt.instance;

@InjectableInit()
void configureDependencies() => getIt.init();

// main.dart
void main() {
  configureDependencies();
  runApp(const MyApp());
}

Type-Safe go_router (Tier 2)

// router/app_router.dart

@TypedGoRoute<HomeRoute>(path: '/', routes: [
  TypedGoRoute<ItemDetailRoute>(path: 'items/:id'),
])
class HomeRoute extends GoRouteData {
  const HomeRoute();

  @override
  Widget build(BuildContext context, GoRouterState state) => const ItemPage();
}

class ItemDetailRoute extends GoRouteData {
  const ItemDetailRoute({required this.id});

  final String id;

  @override
  Widget build(BuildContext context, GoRouterState state) =>
      ItemDetailPage(id: id);
}

// Usage — navigate type-safely:
// const ItemDetailRoute(id: '123').go(context);

pubspec.yaml (Tier 2 — With Code Generation)

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^9.1.1
  go_router: ^17.1.0
  get_it: 9.2.1
  injectable: ^2.7.1+4
  freezed_annotation: ^3.1.0
  drift: ^2.32.0
  drift_flutter: ^0.3.0
  dio: ^5.9.2
  shared_preferences: ^2.5.4  # optional, for lightweight settings

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^6.0.0
  build_runner: ^2.4.0
  injectable_generator: ^2.0.0
  freezed: ^2.0.0
  drift_dev: ^2.0.0

Run code generation:

dart run build_runner build --delete-conflicting-outputs

Creating a New Feature

Tier 1

  1. Create lib/features/myfeature/ directory
  2. Add myfeature_state.dart — plain sealed state classes
  3. Add myfeature_cubit.dart — Cubit with constructor-injected repository
  4. Add myfeature_page.dartBlocProvider that creates the Cubit
  5. Add myfeature_screen.dart — pure UI with BlocBuilder
  6. Register route in router.dart

Tier 2

  1. Create lib/features/myfeature/ with data/, domain/, presentation/ sub-packages
  2. Add domain entity (freezed) in domain/entities/
  3. Add repository interface in domain/repositories/
  4. Add repository implementation (@LazySingleton) in data/repositories/
  5. Add local datasource (Drift DAO) in data/datasource/local/
  6. Add remote datasource (Dio) in data/datasource/remote/
  7. Add freezed state + event in presentation/bloc/
  8. Add @injectable BLoC in presentation/bloc/
  9. Add Page (getIt wiring) and Screen (pure UI) in presentation/
  10. Add GoRouteData route class in router/app_router.dart
  11. Run dart run build_runner build --delete-conflicting-outputs

Standard File Patterns

Cubit Pattern (Tier 1)

class FeatureCubit extends Cubit<FeatureState> {
  FeatureCubit(this._repository) : super(FeatureInitial());

  final FeatureRepository _repository;

  Future<void> load() async {
    emit(FeatureLoading());
    try {
      final data = await _repository.getData();
      emit(FeatureLoaded(data));
    } catch (e) {
      emit(FeatureError(e.toString()));
    }
  }
}

BLoC Pattern (Tier 2)

@injectable
class FeatureBloc extends Bloc<FeatureEvent, FeatureState> {
  FeatureBloc(this._repository) : super(const FeatureState.loading()) {
    on<_Started>(_onStarted);
  }

  final FeatureRepository _repository;

  Future<void> _onStarted(_Started event, Emitter<FeatureState> emit) async {
    emit(const FeatureState.loading());
    try {
      final data = await _repository.getData();
      emit(FeatureState.success(data));
    } catch (e) {
      emit(FeatureState.error(e.toString()));
    }
  }
}

Screen Pattern (All Tiers)

// Page — owns BlocProvider, never displays UI directly
class FeaturePage extends StatelessWidget {
  const FeaturePage({super.key});

  @override
  Widget build(BuildContext context) => BlocProvider(
    // Tier 1: pass repository from context or constructor
    // Tier 2: create: (_) => getIt<FeatureBloc>()..add(const FeatureEvent.started())
    create: (_) => FeatureCubit(context.read<FeatureRepository>())..load(),
    child: const FeatureScreen(),
  );
}

// Screen — pure UI, only reads from BlocBuilder
class FeatureScreen extends StatelessWidget {
  const FeatureScreen({super.key});

  @override
  Widget build(BuildContext context) => BlocBuilder<FeatureCubit, FeatureState>(
    builder: (context, state) => switch (state) {
      FeatureLoading() => const Center(child: CircularProgressIndicator()),
      FeatureLoaded(:final data) => ContentWidget(data: data),
      FeatureError(:final message) => ErrorWidget(message: message),
      FeatureInitial() => const SizedBox.shrink(),
    },
  );
}

Side Effects with BlocListener

// Use BlocListener for one-time effects (navigation, snackbars, dialogs)
class FeaturePage extends StatelessWidget {
  const FeaturePage({super.key});

  @override
  Widget build(BuildContext context) => BlocProvider(
    create: (_) => getIt<FeatureBloc>()..add(const FeatureEvent.started()),
    child: BlocListener<FeatureBloc, FeatureState>(
      listenWhen: (previous, current) => current is _Error,
      listener: (context, state) {
        if (state case _Error(:final message)) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(message)),
          );
        }
      },
      child: const FeatureScreen(),
    ),
  );
}

Use BlocConsumer when the same widget needs both BlocBuilder (UI) and BlocListener (side effects).


Key Dependencies

PackageTier 1Tier 2Purpose
flutter_blocBLoC/Cubit state management
go_routerNavigation
get_itService locator
injectableCode-gen DI annotations
freezedImmutable data classes + sealed states
freezed_annotationfreezed annotations
driftoptionalType-safe SQLite ORM
drift_flutteroptionalFlutter drift integration
diooptionalHTTP client
shared_preferencesoptionalSimple key-value storage
build_runnerCode generation runner
injectable_generatorinjectable code generator
drift_devDrift code generator

Flutter/Dart SDK Versions

SettingValue
Flutter SDK3.27.x+
Dart SDK3.6.x+
flutter_bloc9.1.x
go_router17.x
get_it9.x
injectable2.x
freezed2.x
drift2.x
dio5.x

Build Configuration

Running Code Generation (Tier 2 Only)

# One-time build
dart run build_runner build --delete-conflicting-outputs

# Watch mode during development
dart run build_runner watch --delete-conflicting-outputs

Generated files to commit to version control:

  • injection.config.dart — injectable output
  • *.g.dart — go_router typed routes
  • *.freezed.dart — freezed models
  • *.drift.dart — Drift generated queries

Generated files to add to .gitignore — none. Commit all generated files. This avoids requiring all developers to run build_runner before building.

Analysis Options

# analysis_options.yaml
include: package:flutter_lints/flutter.yaml

analyzer:
  exclude:
    - "**/*.g.dart"
    - "**/*.freezed.dart"
    - "**/*.drift.dart"
    - "lib/injection.config.dart"

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.