flutter-app-builder — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited flutter-app-builder (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Build Flutter applications following clean architecture principles, BLoC/Cubit state management, and complexity-appropriate patterns.
| Task | Reference File |
|---|---|
| Complexity tier selection | complexity-tiers.md |
| Architecture layers (UI, Domain, Data) | architecture.md |
| BLoC/Cubit patterns & state management | bloc-patterns.md |
| Navigation with go_router | navigation.md |
| Data layer (Drift, Dio, Repository) | data-layer.md |
| Dependency injection (get_it, injectable) | di.md |
| Testing approach | testing.md |
Before writing any code, classify the project into one of two tiers. This determines every architectural decision that follows.
Apply these signals in order. The first confident match wins.
Tier 1 — Simple (default for ambiguous small projects):
Tier 2 — Production:
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.
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.
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_runner → shared_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 + @TypedGoRoute → build_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
setState for business logicBlocProvider and wires DI; Screen is pure UI that reads stateStream<T> for all data that changes over time┌─────────────────────────────────────────┐
│ 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.
Use these patterns only when the project is classified as Tier 1. Do not add injectable, freezed, or code generation to Tier 1 projects.
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// 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);
}// 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,
));
}// 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.
// 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());
}
}// 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),
),
);
}// 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 usesGoRouteData+@TypedGoRoutefor 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.0No build_runner, no injectable, no freezed. This is intentional.
Use these patterns when the project is classified as Tier 2. Feature-based structure with get_it + injectable, freezed, and build_runner.
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// 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;
}// 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.
// 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)),
},
);
}// 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);
}// 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;
}// 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());
}// 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.0Run code generation:
dart run build_runner build --delete-conflicting-outputslib/features/myfeature/ directorymyfeature_state.dart — plain sealed state classesmyfeature_cubit.dart — Cubit with constructor-injected repositorymyfeature_page.dart — BlocProvider that creates the Cubitmyfeature_screen.dart — pure UI with BlocBuilderrouter.dartlib/features/myfeature/ with data/, domain/, presentation/ sub-packagesdomain/entities/domain/repositories/@LazySingleton) in data/repositories/data/datasource/local/data/datasource/remote/presentation/bloc/@injectable BLoC in presentation/bloc/presentation/GoRouteData route class in router/app_router.dartdart run build_runner build --delete-conflicting-outputsclass 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()));
}
}
}@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()));
}
}
}// 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(),
},
);
}// 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(),
),
);
}UseBlocConsumerwhen the same widget needs bothBlocBuilder(UI) andBlocListener(side effects).
| Package | Tier 1 | Tier 2 | Purpose |
|---|---|---|---|
| flutter_bloc | ✅ | ✅ | BLoC/Cubit state management |
| go_router | ✅ | ✅ | Navigation |
| get_it | ✅ | ✅ | Service locator |
| injectable | ❌ | ✅ | Code-gen DI annotations |
| freezed | ❌ | ✅ | Immutable data classes + sealed states |
| freezed_annotation | ❌ | ✅ | freezed annotations |
| drift | optional | ✅ | Type-safe SQLite ORM |
| drift_flutter | optional | ✅ | Flutter drift integration |
| dio | optional | ✅ | HTTP client |
| shared_preferences | ✅ | optional | Simple key-value storage |
| build_runner | ❌ | ✅ | Code generation runner |
| injectable_generator | ❌ | ✅ | injectable code generator |
| drift_dev | ❌ | ✅ | Drift code generator |
| Setting | Value |
|---|---|
| Flutter SDK | 3.27.x+ |
| Dart SDK | 3.6.x+ |
| flutter_bloc | 9.1.x |
| go_router | 17.x |
| get_it | 9.x |
| injectable | 2.x |
| freezed | 2.x |
| drift | 2.x |
| dio | 5.x |
# One-time build
dart run build_runner build --delete-conflicting-outputs
# Watch mode during development
dart run build_runner watch --delete-conflicting-outputsGenerated 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 queriesGenerated files to add to .gitignore — none. Commit all generated files. This avoids requiring all developers to run build_runner before building.
# analysis_options.yaml
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "**/*.drift.dart"
- "lib/injection.config.dart"~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.