flutter_gen_cat 1.0.0
flutter_gen_cat: ^1.0.0 copied to clipboard
FlutterGenCat is a CLI scaffold tool that brings MVVM and Clean Architecture into Flutter projects. Starts from plain MVVM and grows into Clean Architecture one flag at a time.
FlutterGenCat #
A CLI that scaffolds MVVM and Clean Architecture features for Flutter apps.
Most architecture generators make you choose everything up front. FlutterGenCat
starts you at plain MVVM and lets each feature earn its extra layers one flag at
a time — so a counter screen stays six files, while a checkout flow gets the
full domain layer.
Works with Provider, Riverpod and BLoC.
The name #
Cats settle into whatever shape the moment calls for. FlutterGenCat generates Flutter architecture the same way — freely and flexibly, taking the form each feature actually needs rather than one imposed up front.
Requirements #
| Minimum | |
|---|---|
| Dart SDK | 3.5.0 |
| Flutter | 3.24.0 |
Flutter 3.24.0 is the stable release that shipped Dart 3.5.0 — the two bounds
are the same requirement expressed twice. Only the Dart bound is declared in
pubspec.yaml; FlutterGenCat is a pure Dart CLI and does not depend on the
Flutter SDK to run.
Install #
dart pub global activate flutter_gen_cat
Or, to pin it to a project:
# pubspec.yaml
dev_dependencies:
flutter_gen_cat: ^1.0.0
dart run flutter_gen_cat --help
Both flutter_gen_cat and the shorter fgc are installed.
Quick start #
# Plain MVVM, Provider — six files, no ceremony (walked through in example/)
fgc create counter
# Riverpod, with a use case between the view model and the repository
fgc create cart -s riverpod --with-usecase
# The full Clean Architecture slice, on BLoC
fgc create checkout -s bloc --clean
# See what would happen, write nothing
fgc create orders --clean --dry-run
Generated files are written already formatted, at your project's own Dart
language version — the first dart format after generation is a no-op.
See example/ for every file create counter produces,
one layer at a time.
The progression #
The base is always the same six layers. Each flag inserts one more indirection:
| adds | why you would | |
|---|---|---|
| (none) | model · repository (+impl) · view model · view · injector | The feature is a screen over an API. |
--with-entity |
entity, mapper | The wire format and the domain shape have started to diverge. |
--with-usecase |
use case | Business rules exist and do not belong in the view model. |
--with-datasource |
data source (+impl) | Transport is worth faking in tests, or there is more than one source. |
--clean |
all three | You already know this feature is complex. |
Flags compose freely — --with-usecase alone is a perfectly reasonable place to
sit for a long time.
Folder layout follows the layers #
Plain MVVM gets a flat layout, because there is nothing to group yet:
lib/features/counter/
model/ counter_model.dart
repository/ counter_repository.dart # interface
counter_repository_impl.dart
view_model/ counter_view_model.dart
view/ counter_view.dart
di/ counter_injector.dart
Turn on any Clean Architecture layer and it switches to the layered one:
lib/features/checkout/
data/
model/ checkout_model.dart
mapper/ checkout_mapper.dart
datasource/ checkout_remote_data_source.dart
repository/ checkout_repository_impl.dart
domain/
entity/ checkout_entity.dart
repository/ checkout_repository.dart # interface
usecase/ get_checkout_use_case.dart
presentation/
view_model/ checkout_view_model.dart
view/ checkout_view.dart
di/ checkout_injector.dart
Override the choice with --layout flat or --layout layered whenever you
disagree.
The repository interface lives in domain/ and the implementation in data/.
That split is what keeps the dependency arrow pointing inwards: the presentation
layer depends on the contract, and only the injector knows which class satisfies
it.
Growing a feature #
add generates one layer into a feature that already exists. It reads the
feature off disk first, so the new file matches the layout and the layers that
are already there:
# Detects that `cart` is layered and has an entity
fgc add usecase clear_cart --feature cart
# -> lib/features/cart/domain/usecase/clear_cart_use_case.dart
# Future<CartEntity> call() => _repository.fetch();
# Detects that `counter` is flat and has no entity
fgc add usecase reset_counter --feature counter
# -> lib/features/counter/usecase/reset_counter_use_case.dart
# Future<CounterModel> call() => _repository.fetch();
A second model, view or data source in the same feature is named after the artifact rather than the feature:
fgc add model cart_summary --feature cart
# -> lib/features/cart/model/cart_summary_model.dart (class CartSummaryModel)
Explicit flags override what was detected. --layout is the only way to move a
feature between layouts — adding --with-entity to a flat feature gives you an
entity-shaped file in the flat layout, rather than silently scattering the
feature across two conventions.
What you get per library #
The data and domain layers are identical for all three. Only the presentation three differ:
| Provider | Riverpod | BLoC | |
|---|---|---|---|
| View model | ChangeNotifier |
AsyncNotifier |
Cubit<XState> |
| View | StatelessWidget + context.watch |
ConsumerWidget + ref.watch |
BlocBuilder |
| Async state | isLoading / error / data |
AsyncValue.when |
fields on the state class |
| Injector | XInjector.provide(child:) |
top-level providers | XInjector.provide(child:) |
Mounting a feature:
// Provider and BLoC
CounterInjector.provide(child: const CounterView())
// Riverpod — the providers are top-level; just wrap the app once
ProviderScope(child: MaterialApp(home: CounterView()))
No view model imports material.dart, so all three are unit testable without a
widget test.
Why Cubit rather than Bloc #
An MVVM view model is a state holder, and an event class per method buys
nothing at scaffold time. Promote it to a full Bloc when the feature genuinely
needs an event log — the state class is already there.
Command reference #
create <feature> #
-s, --state-management provider (default) | riverpod | bloc
-o, --output Root features directory (default: lib/features)
--layout flat | layered
--with-entity Split a domain entity out of the model, plus its mapper
--with-usecase Put a use case between the view model and the repository
--with-datasource Make the repository delegate transport to a data source
--clean All three of the above
--usecase-name Name of the generated use case (default: get_<feature>)
--only Generate only these layers, comma separated
--dry-run Print the plan, write nothing
--overwrite Replace files that already exist
add <layer> <name> #
Takes every option above, plus:
-f, --feature Feature to add to (default: <name>)
Layers: model, entity, mapper, datasource, repository,
repository-impl, usecase, viewmodel, view, injector.
Generated code is yours #
Files are written once and never touched again. There is no .g.dart, no
build_runner step, no "do not edit" banner — create and add refuse to
overwrite an existing file unless you pass --overwrite.
Fill in the TODO(FlutterGenCat) markers and the scaffold stops being a
scaffold.
Using it as a library #
The CLI is a thin shell over an API that never touches the disk, which is handy if you are building your own tooling:
import 'package:flutter_gen_cat/flutter_gen_cat.dart';
void main() {
final context = GenerationContext(
feature: 'checkout',
architecture: ArchitectureOptions.clean,
stateManagement: StateManagement.riverpod,
outputDir: 'lib/features', // the default
);
final files = FeatureScaffold(
context,
// Format for the target project rather than the newest language version
// the bundled formatter knows — this is what the CLI does.
languageVersion: resolveProjectLanguageVersion('.'),
).build();
for (final file in files) {
print('${file.path}\n${file.contents}');
}
}
GenerationContext derives its FeaturePaths from the feature name, the
output directory and the architecture's layout, so none of those has a second
place to be set — and therefore no way to disagree with itself.
Development #
dart pub get
dart test
dart analyze
dart format .
License #
See LICENSE.