moarch 9.11.0
moarch: ^9.11.0 copied to clipboard
Flutter CLI — scaffold Clean Architecture projects with Riverpod or flutter_bloc, FVM and your own conventions.
moarch #
A simple Dart/Flutter CLI for scaffolding Clean Architecture-style apps.
Built first as a helper for me and the people I work with — it encodes the conventions our projects share. Anyone is welcome to it: if the conventions match yours, use it as-is; if they almost do, clone it and make them yours — every template is a plain Dart string meant to be edited.
Install #
dart pub global activate moarch
If moarch is not found, make sure your Pub bin folder is on your PATH.
It needs Dart 3.9 or later (Flutter 3.35+).
Quick start #
flutter create my_app
cd my_app
moarch init
fvm use # creates .fvm/flutter_sdk — see below, do this before opening the editor
fvm flutter pub get
fvm dart run build_runner build --delete-conflicting-outputs # generates app_env.g.dart
moarch create feature auth
moarch create model auth login_response
init writes a .fvmrc pin and a .vscode/settings.json that points
dart.flutterSdkPath at .fvm/flutter_sdk — the symlink fvm use creates.
.fvm/ is gitignored, so every fresh clone has to run fvm use once.
Skip it and nothing errors: the Dart extension quietly falls back to the first
Flutter on your PATH, so debug, hot reload and the analyzer all run the SDK
the pin exists to avoid. moarch doctor flags it if you forget.
The generated .fvmrc says "flutter": "stable" so a new project starts on the
current stable. That is an alias, not a pin — fvm install on CI or a
teammate's machine resolves it to whatever stable is that day, which can be
a different SDK than your cache holds. Once the project ships, pin it for real:
fvm use 3.41.4 # rewrites .fvmrc to that exact version
fvm use also rewrites .vscode/settings.json to a versioned path and strips
its comments. Put ".fvm/flutter_sdk" back — it follows .fvmrc, so later SDK
switches never touch the editor config. moarch doctor --fix does exactly that.
Commands #
moarch init # interactive scaffold
moarch init --all # generate the default structure without prompts
moarch init --state bloc # pick the stack without the checklist (riverpod | bloc)
moarch create feature <featureName> # all layers, registered in the locator, with its route
moarch create model <featureName> <modelName> # generate the model
moarch create model <featureName> <modelName> --from-json sample.json # infer the fields from a JSON payload
moarch create model --empty <featureName> <modelName> # Inject a .empty() factory into an existing model.
moarch create flavors # dev/staging/prod via flutter_flavorizr — one main.dart, untouched
moarch create empty-factories # generate .empty() in all models
moarch create bloc <featureName> <blocName> # add a state+event+bloc trio to an existing feature
moarch create widget <name> # add a UI-kit widget on demand (e.g. switch, otp, list-tile)
moarch create widget all # generate the whole UI kit + the preview screen
moarch create widget --list # list every available widget
moarch create theme --dark # add the dark palette, AppTheme.dark and the saved theme-mode switch
moarch create theme --no-dark # ...and drop back to the single brand theme
moarch create tests [feature] # unit tests for every notifier/bloc, integration tests for every GET endpoint
moarch create scope <feature> <name> [--blocs A,B] [--parent XScope] # carry a screen's blocs to what it opens (bloc)
moarch update # refresh every generated file against the current templates
moarch update <name> # ...or just one (e.g. validation, extensions, theme)
moarch update <group># ...or one group (widgets, core, security, config, docs...)
moarch update --list # list every name and group update accepts
moarch doctor # check the project for common scaffolding issues
moarch doctor --fix # ...and apply the ones that don't need a decision
What it generates #
lib/main.dart,core/,config/,shared/, andfeatures/README.md— the project's own guide, written for someone who has never worked on a Clean Architecture Flutter app: the first run through FVM, a layer-by-layer walkthrough of the generated code, a state-management section for whichever stack you picked, flavors, the build commands, and the CI secrets (pointing atdocs/for the step-by-step). It replaces the stockflutter createREADME and only that one — a README you wrote is left alone, andmoarch update readme --diffshows what a refresh would change- Riverpod or flutter_bloc (see below) + optional GoRouter setup
- Envied-based
.envsupport - secure storage, logger, helpers, and a full shared UI kit / design system (see below)
- optional services such as notifications (local or Firebase push), URL launcher, media, debounce — each with what it needs declared in
AndroidManifest.xmlandInfo.plist(camera and photo permissions for media, the exact-alarm permission and receivers for scheduled notifications,<queries>for the URL launcher,INTERNETfor Dio's release build), since a missing declaration fails at runtime rather than at build time core/constants/api_constants.dart— every endpoint path the app calls, never a string at the call site.moarch create featuredeclares each new feature's above a// moarch:endpointsanchor, the way it registers the data layer and the route- an optional maintenance gate — a backend flag that empties the app (see below)
- an optional offline screen over the app, and a hook that runs when the connection comes back — for a sync (see below)
- optional deep links: https links open the app on the matching route, Android App Links and iOS Universal Links (see below)
- optional localization: flutter_localizations (
lib/l10n/+.arbfiles) or easy_localization (assets/translations/JSON files) — pick one, the checklist keeps them mutually exclusive - a backend: Dio against a REST API, Firebase (Firestore / Auth), or both (see below)
AGENTS.md+CLAUDE.md— the project's rules for coding agents (Codex, Cursor, Copilot, Gemini CLI, Claude Code): the layout, no entity layer, the state stack's patterns, the UI kit and tokens, and the commands that prove a change is done.CLAUDE.mdis only@AGENTS.md, so there is one set of instructions. Both are catalog entries, somoarch update agents claude-mdkeeps them in step with the templates; a project from before 9.0.0 gets them frommoarch doctor --fix.agents/skills/— step-by-step skills for the tasks agents get asked most, written for the project's stack and options:moarch-add-feature,moarch-plan-feature(an interview that settles a feature's data, screens, actions, route and platform needs before any code, one round of questions at a time, each with a recommended answer),moarch-add-endpoint,moarch-add-action,moarch-add-model,moarch-build-screen,moarch-preview-widget(@Previewfunctions for Flutter's widget previewer, in the app's theme through a projectAppPreviewannotation),moarch-add-env-key,moarch-write-tests,moarch-fix-bug(reproduce first: a failing test at the layer that owns the symptom, then the fix),moarch-update-scaffoldandmoarch-review(a checklist review of a diff against the rules). Codex, Cursor, Copilot and Gemini CLI read.agents/skills/; Claude Code reads only.claude/skills/, so each skill has a pointer there, the same wayCLAUDE.mdimportsAGENTS.md. Alongside them:.claude/settings.jsonallows the format / analyze / test / build_runner commands without a prompt and denies reading.envand editing*.g.dart,*.freezed.dartand.moarch.yaml, and.gemini/settings.jsonpoints Gemini CLI atAGENTS.md..mcp.json(Claude Code) and.gemini/settings.jsonboth start the Dart MCP server through FVM (fvm dart mcp-server), so an agent can launch the app, hot reload it and read its runtime errors and widget tree;AGENTS.mdand the build-screen and fix-bug skills tell it to when the server is there. For Claude Code there is also a mod in.claude/skills/moarch-mod/: function hooks that refuse edits to build_runner's output and to the// moarch:anchors, showbuild_runner pendingon the status line after a model or env change, open a project pane with/moarch(stack, features, files changed since moarch wrote them, session cost), and add a haikumoarch-mod:runnersubagent for long-output checks. Claude Code loads it once the workspace is trusted. All of it is theaigroup:moarch update airefreshes it, a project from before 9.1.0 gets it frommoarch doctor --fix, and so does a project that has the skills but not the ones added since, or not the mod, or no.mcp.json. Generic skills can be installed beside these — Flutter ones (likeflutter/skills'flutter-fix-layout-issues) and process ones (likemattpocock/skills'grill-me);AGENTS.mdsays that where one disagrees with the project's rules, the rules win.vscode/—settings.jsonpointing the Dart extension at the fvm SDK.fvmrcpins, andlaunch.jsonwith debug/profile/release entries plus a flavored pair fordev,stagingandprod(ready for when the native side declares them)android/app/proguard-rules.pro— the R8 keep rules for the Flutter engine, Firebase, OkHttp and coroutines. Inert until you enable minification for the release build type, so it costs the debug build nothing; the gradle block that turns it on is indocs/SECURITY_BEFORE_DEPLOYMENT.md
Riverpod or flutter_bloc #
The first question moarch init asks. It decides the shape of every
state-bearing file, and nothing else about the architecture moves: the same
layers, the same file names, the same AppException reaching the same
AppAsyncView.
| Riverpod | flutter_bloc | |
|---|---|---|
| state holder | AsyncNotifier<OrdersState> |
Bloc<OrdersEvent, OrdersState> |
| lives in | presentation/notifiers/orders_notifier.dart |
presentation/blocs/orders_bloc.dart (+ orders_event.dart) |
| the state | one class inside AsyncValue, in presentation/states/ |
one class with an AppStatus field, in presentation/blocs/ beside the bloc |
| you call | ref.read(p.notifier).refresh() |
context.read<OrdersBloc>().add(const OrdersStarted()) |
| the screen | presentation/views/orders_view.dart |
presentation/pages/orders_page.dart provides the bloc, presentation/views/orders_view.dart draws it |
| the view uses | AppAsyncView + ref.listenAction |
BlocConsumer + AppStatusView |
| dependencies | get_it, in config/di/injector.dart |
get_it, in config/di/injector.dart |
| extra packages | get_it |
flutter_bloc, bloc, equatable, get_it, bloc_lint |
Every command reads the choice back off pubspec.yaml, so there is no flag to
remember: moarch create feature orders in a bloc project generates a bloc.
The state a screen is in #
The two stacks answer this differently on purpose, because they already disagree about it.
Riverpod has AsyncValue, which is the four states, so the generated
OrdersState is only the data plus the one-shot action fields — unchanged
from previous versions:
class OrdersState implements ActionState<OrdersState> {
final bool isLoadingAction;
final String? error; // cleared by every copyWith, so it toasts once
final String? success;
}
Bloc gets the same one class, with the phase as a field:
enum AppStatus { initial, loading, success, failure } // core/utils/
class OrdersState extends Equatable {
final AppStatus status;
final String? errorMessage; // dropped by every copyWith, so it toasts once
final String? successMessage; // ← and you add the screen's own fields
}
The state is generated with nothing but those three, and a TODO. What a screen
shows is the screen's business, and a scaffolded List<OrderModel> items that
half the features do not want is a line to delete rather than a head start.
One class rather than a sealed state per phase, because the data outlives
the phase. A screen that keeps its list up while a save runs leaves the
status on success; with a class per phase, that list has to be re-declared on
every phase that can show it, and the view grows a body per shape. Here the
view has one — and does not switch at all:
builder: (context, state) => AppStatusView(
status: state.status,
message: state.errorMessage,
onRetry: () => context.read<OrdersBloc>().add(const OrdersStarted()),
isEmpty: state.items.isEmpty,
skeleton: (context) => const OrdersSkeleton(),
builder: (context) => _body(context, state),
),
// handed the whole state, whatever the status
Widget _body(BuildContext context, OrdersState state) => ...;
AppStatusView is bloc's half of the pair AppAsyncView is Riverpod's: the
same four screens — skeleton, failure, empty, body — reached from the status
the state already carries instead of from an AsyncValue. The status lives in
core/utils/app_status.dart rather than per feature precisely so one widget
can switch over it.
Equatable is load-bearing rather than decorative: bloc drops an emit whose
state equals the current one, and BlocConsumer rebuilds — and fires its
listener — on the same test.
Without value equality every emit is a new object, so every emit repaints —
including the Firestore snapshots that changed nothing.
A bloc feature #
sealed class OrdersEvent extends Equatable {}
final class OrdersStarted extends OrdersEvent {} // the route, and the retry
// TODO: one per action the screen can take
class OrdersBloc extends Bloc<OrdersEvent, OrdersState> {
OrdersBloc(this._repo) : super(const OrdersState()) {
on<OrdersStarted>(_onStarted);
on<OrdersDeleted>((event, emit) async {
emit(state.copyWith(status: AppStatus.loading));
try {
await _repo.delete(event.id);
emit(state.copyWith(
status: AppStatus.success,
successMessage: 'Deleted',
));
} on AppException catch (e) {
emit(state.copyWith(
status: AppStatus.failure,
errorMessage: e.message,
));
}
});
}
}
No mixin and no base class of moarch's own — that is the whole handler.
The screen is plain flutter_bloc, split across two files. pages/orders_page.dart
owns the bloc so leaving the route closes it, and with it anything it holds
— it is what a GoRoute points at. views/orders_view.dart draws
the state with one switch and reacts to it with one listener, and never
touches the locator, so a widget test can pump it with a bloc of its own:
// presentation/pages/orders_page.dart
class OrdersPage extends StatelessWidget {
Widget build(context) => BlocProvider(
create: (_) => getIt<OrdersBloc>()..add(const OrdersStarted()),
child: const OrdersView(),
);
}
// presentation/views/orders_view.dart — inside OrdersView
BlocConsumer<OrdersBloc, OrdersState>(
// The two message fields are one-shot, so this fires once each.
listenWhen: (previous, current) =>
previous.errorMessage != current.errorMessage ||
previous.successMessage != current.successMessage,
listener: (context, state) {
final error = state.errorMessage;
if (error != null) AppToast.error(context, error);
final success = state.successMessage;
if (success != null) AppToast.success(context, success);
},
builder: (context, state) => AppStatusView(
status: state.status,
message: state.errorMessage,
onRetry: () => context.read<OrdersBloc>().add(const OrdersStarted()),
// presentation/widgets/orders_skeleton.dart — the same rows over
// BoneMock data, shimmered while the first load runs.
skeleton: (context) => const OrdersSkeleton(),
builder: (context) => _body(context, state),
),
)
_body takes the whole OrdersState, so a phase that has to draw over data
already loaded needs nothing extra — no second body, and no case to add.
listener is the bloc answer to ref.listen: it runs once per new state,
which is where a toast, a dialog or a context.push belongs. builder runs on
every rebuild, so the same toast raised there would repeat.
AppAsyncView and ref.listenAction are not generated into a bloc
project — the first because bloc has AppStatusView instead, the second
because BlocConsumer's own listener already does that job. moarch create widget async-view in a bloc project says so rather than writing a file that
cannot compile, and create widget status-view says the same in a Riverpod
one. Each stack's shared base follows: core/utils/action_notifier.dart on
Riverpod, core/utils/app_status.dart on bloc.
AuthState is the exception that stays sealed — AuthInitial (restoring —
what parks the router on splash), AuthLoading, AuthAuthenticated,
AuthUnauthenticated and AuthFailure. Signed in versus signed out is a real
either/or the router guard switches on, and the two carry different things.
Dependencies live in one file #
lib/config/di/injector.dart holds every dependency, in both stacks:
clients, services, datasources and repositories are constructed once and
handed out by type. moarch create feature writes into it at the
// moarch:registrations anchor:
getIt.registerLazySingleton<OrdersRepository>(
() => OrdersRepositoryImpl(getIt<OrdersRemoteDataSource>()),
);
// bloc only. A factory, not a singleton: the screen's BlocProvider creates it
// and closing the route closes it, subscriptions and all.
getIt.registerFactory<OrdersBloc>(() => OrdersBloc(getIt<OrdersRepository>()));
What differs is only the state holder. A bloc is registered like anything
else. A Riverpod notifier is not: an AsyncNotifier needs the Ref Riverpod
hands it, so it stays behind its provider and reaches into the locator from
there —
final ordersNotifierProvider =
AsyncNotifierProvider<OrdersNotifier, OrdersState>(OrdersNotifier.new);
class OrdersNotifier extends AsyncNotifier<OrdersState>
with ActionNotifierMixin<OrdersState> {
OrdersRepository get _repo => getIt<OrdersRepository>();
}
Riverpod holds the state; get_it holds everything the state is built from.
So hasInternetProvider, maintenanceStatusProvider, languageProvider,
routerProvider and the feature notifiers are still providers — they are
state — while the services beneath them come out of getIt.
That anchor comment is load-bearing. Delete it and create feature still
generates the feature but says it could not register it; moarch doctor
flags it too.
AuthBloc is the one bloc registered as a singleton: the router's redirect
and every screen have to read the same session. Its submit events (login,
register, logout, delete) are registered with droppable() from
bloc_concurrency, so a double tap sends one request.
feature_module.dart is where a feature's long-lived services go: a socket
the chat feature keeps open, a call engine. They are not cross-cutting enough
for core_module.dart, and they are not repositories. It also carries
openScope / closeScope for what one flow owns. A scope is opened when
the flow starts, shared by its screens, and disposed when it ends, which a
singleton (outlives the flow) or a factory (one per screen) cannot do.
bloc_lint #
A bloc project gets bloc_lint as a dev dependency and the recommended
ruleset in analysis_options.yaml. Those rules are read by the bloc analysis
server rather than by dart analyze, so they need their own run:
dart pub global activate bloc_tools # once
bloc lint .
The generated CI workflow runs it alongside flutter analyze, and a freshly
scaffolded project passes with no findings.
prefer_bloc and prefer_cubit are deliberately left out of the generated
ruleset. Features scaffold as event-driven Blocs, but a holder with one value
and no vocabulary of events — the locale, the maintenance flag — is a Cubit on
purpose, and neither rule can tell the two cases apart.
Dio or Firebase #
The backend you pick in the first checklist decides what the data layer is made
of. Nothing else about the architecture moves: the same layers, the same class
names, the same AppException reaching the same AppAsyncView.
| Dio | Firebase | |
|---|---|---|
| datasource holds | final Dio _dio; |
final FirebaseFirestore _firestore; |
| calls go through | safeApiCall |
safeFirebaseCall / safeFirebaseStream |
model id |
int |
String — a document id |
| model shape | freezed + json_serializable | plus fromDoc, an id kept out of the body, and dates stored as Timestamp |
| errors mapped by | AppException.fromDioError |
fromFirebaseError + fromFirebaseAuthError |
| auth feature | tokens in secure storage, refresh interceptor, the user from GET /auth/me |
Firebase Auth, email/password + Google |
moarch create feature <name> follows the same choice. In a Firestore project
the datasource comes out with fetchAll / fetchOne / watchAll /
create / save / delete over one collection; in a project with both
backends installed, the layer checklist asks which one this feature talks to.
A Firestore feature is live end to end. watchAll is not left on the
datasource for you to wire up: the repository exposes it, the notifier
subscribes to it in build() and the view renders state.items — so the screen
redraws whenever the collection changes, on this device or another, with no
pull-to-refresh and no invalidate anywhere. One subscription serves both the
first frame and every change after it (taking .first for the initial load and
then listening would register the query twice — twice the billed reads), and
Riverpod cancels it with the provider. Writes don't touch items either:
Firestore applies them to the local cache before the server confirms, and the
subscription re-emits. The REST feature is unchanged — a Future, and a TODO
where the fetch goes.
Selecting Firebase Auth together with the auth feature generates it against
Firebase instead of REST: email/password, Google sign-in, password reset, account
deletion, and a session restored from authStateChanges() rather than from a
stored refresh token. With Firestore also selected it keeps a users/{uid}
profile document in step with the account. Dio is not pulled in for it, and no
tokens are stored — Firebase persists the session itself.
Google sign-in needs work outside Dart that nothing in the build will remind you
about, so init writes docs/FIREBASE_SETUP.md with all of it: enabling the
providers, the Android SHA-1/SHA-256 fingerprints, and the two iOS Info.plist
keys (GIDClientID and the REVERSED_CLIENT_ID URL scheme). Those two are
written into Info.plist for you when GoogleService-Info.plist already exists;
otherwise placeholders go in and moarch doctor --fix copies the real values
across once flutterfire configure has run.
Input validation #
core/security/validation_service.dart checks a value against an InputType
(email, url, phone, password, username, number, creditCard, cardExpiry, cvv,
filePath, text) and hands back the cleaned form. It is what AppInput calls, and
what AppInputFormat maps onto.
It deliberately does not blocklist SQL keywords and does not HTML-escape
what it returns: O'Brien is a name, Create the report is a note, and escaping
on the way in is how Tom & Jerry ends up stored as Tom & Jerry. Injection
belongs to parameterised queries on the server; escaping belongs to
ValidationService.escapeHtml at the point you build HTML. What it does enforce
is scoped: shape per type, control characters stripped everywhere, markup in free
text, path traversal in a file path, and an http/https allowlist on a URL.
The password rule is one assignment at startup:
ValidationService.passwordPolicy = PasswordPolicy.lengthOnly; // 12+ chars
ValidationService.passwordPolicy = const PasswordPolicy(minLength: 8);
Maintenance gate #
A kill switch your backend owns. MaintenanceGate watches a flag and, while it
is on, replaces the entire app with a screen carrying whatever title and message
the backend sent — so the team taking the API down can empty the app without an
app release, and change the wording without one either.
MaterialApp.router(
builder: (context, child) => MaintenanceGate(child: child!),
routerConfig: router,
)
It goes in MaterialApp.builder, which wraps the Navigator — so it sits above
every route the router can reach, including anything pushed after the flag
flips. And it replaces the app rather than covering it: with no Navigator
mounted there is nothing left to tap, nothing for the back button to pop, and no
route that can push itself on top of the gate.
It fails open. While the first read is in flight, and if the flag cannot be read at all — offline, endpoint down, Firestore rules denied — the app runs normally. A fault in the check itself must not be able to lock out every user at once. The trade-off runs the other way: a backend that is completely unreachable shows your usual error states rather than the maintenance screen.
The source follows the backend you picked. Firestore gets a live snapshots()
listener on config/maintenance, so flipping the flag empties every open app
within a second. Dio gets the endpoint polled every five minutes and again on
resume. With neither, you get a stub provider to point at whatever you use.
Either way it is one provider, and the gate above it is identical:
{ "active": true, "title": "Back at 14:00", "message": "Upgrading the database." }
Whichever source you use, make it readable without a token. A signed-out user, or one whose session expired during the outage, still has to be told the app is down — and a permission denial fails open.
moarch create widget maintenance-gate # or take it in the init checklist
Update gate #
The maintenance gate's sibling for a minimum version. UpdateGate compares the
installed version (package_info_plus) with the minimum your backend sends and,
while the app is older, replaces it with an "update required" screen whose
button opens the store. Mounted in MaterialApp.builder too, inside the
maintenance gate, so an outage is announced before an update is asked for:
builder: (context, child) => MaintenanceGate(child: UpdateGate(child: child!)),
Shared keys apply everywhere and a platform object overrides them, since store review can leave one platform a release behind:
{
"min_version": "2.4.0",
"android": { "store_url": "https://play.google.com/store/apps/details?id=com.example.app" },
"ios": { "min_version": "2.3.0", "store_url": "https://apps.apple.com/app/id0000000000" }
}
It fails open the same way. Until both the installed version and the policy are
known, or if either cannot be read or parsed, the app runs. The source follows
the backend: a live config/app_version document on Firestore, or
GET /config/app-version on Dio at launch and on every resume (no timer, since a
minimum changes with a release, not by the minute).
moarch create widget update-gate # or take it in the init checklist
Offline screen and reconnect #
Tick Offline screen + reconnect hook in init and OfflineGate covers the
app with a "You're offline" screen while the device has no connection, and
lifts it the moment the connection is back. It is mounted in
MaterialApp.builder with the other gates, innermost:
builder: (context, child) => MaintenanceGate(child: UpdateGate(child: OfflineGate(child: child!))),
It covers rather than replaces. The maintenance gate unmounts the app on purpose; being offline is no reason to lose the screen the user was on or the form they were half way through, so the app stays mounted underneath. It fails open, like the other gates.
What should happen when the connection returns goes through
ConnectivityService.onReconnect, which fires only on the way back from
offline, never at start-up. main.dart gets the app-wide hook:
getIt<ConnectivityService>().onReconnect(() async {
// TODO: sync what changed while offline.
});
A feature that owns its own sync subscribes the same way and cancels the
subscription in close() / dispose(). Screens can watch the connection too:
hasInternetProvider on Riverpod, context.watch<ConnectivityCubit>() on bloc
(the gate provides it above the navigator). If parts of your app work offline,
take the gate out of main.dart and show an AppBanner there instead.
Offline-first cache #
Tick Offline-first cache (drift) under Backend / networking in init and
the project gets a local database the REST features cache into:
lib/core/database/app_database.dart # drift: one table of records as JSON
lib/core/database/local_cache.dart # readAll / watchAll / replaceAll / clearAll
Both are registered in the locator (AppDatabase in external_module.dart,
LocalCache in core_module.dart), and drift / drift_dev run with the
same build_runner as the models. From then on moarch create feature ticks the
local datasource by default and writes the data layer against the cache:
<name>_local_datasource.dartkeeps the feature's records inLocalCache, under the feature's name. It stores the freezed model's own JSON, so a new feature needs no table and no migration.<name>_repository_impl.dart:fetchAll()saves the API's list to the cache and, on aNetworkException, answers from the cache instead. It rethrows only when nothing is cached yet.watchAll()follows the cache.- On Riverpod the notifier loads through
fetchAll()and then followswatchAll(), so every save redraws the screen, andrefresh()refetches without dropping the list. A bloc loads throughfetchAll(), which already answers offline.
It needs Dio: Firestore keeps its own offline copy, so a Firestore feature is
never cached twice, and the option is skipped without Dio. It also replaces
the offline screen, since an app that works offline has no business covering
itself. The generated auth feature empties the cache on logout and account
deletion, and the REST variant also empties it at every sign-in, which covers a
session that expired while the app was away. Anything else that switches
accounts calls getIt<LocalCache>().clearAll().
Offline sync #
Tick Offline sync (outbox + background) as well, and writes stop needing a
connection. A cached feature's repository gets create, update and delete:
each changes the cache at once, so the screen redraws, and queues the request
the remote datasource describes:
// orders_remote_datasource.dart — describes the call, never sends it
SyncRequest update(OrdersModel item) =>
SyncRequest.put('${ApiConstants.orders}/${item.id}', item.toJson());
// orders_repository_impl.dart
Future<void> update(OrdersModel item) async {
await _local.save(item); // the screen, now
await _sync.enqueue(OrdersLocalDataSource.collection, _remote.update(item));
}
SyncService (lib/core/sync/) sends the queue in order at start-up, after each
write, on reconnect and on resume. A 2xx leaves the queue; offline or a 401
stops and waits; any other 4xx is dropped and reported on
SyncService.rejected; a 5xx is retried with a growing wait. Afterwards it
refetches every collection it touched, so the server's version wins, and a
record created offline swaps its temporary negative id for the real one.
A workmanager task sends the queue every ~15 minutes while the app is closed.
Android needs nothing; init adds the two Info.plist keys iOS needs, and
docs/SYNC_SETUP.md in the generated project covers the rules and how to
trigger a background run on each platform, and docs/OFFLINE_FIRST.md (written
with the cache, sync or not) explains the whole flow step by step, with a
timeline.
A connection is a network interface, not a working internet: a captive portal
reads as online. The gate decides what to show; requests still handle
NetworkException.
Deep links #
Tick Deep links in init (it needs the router) and https://<your domain>/orders opens the app on AppRoutes.orders, on Android (App Links) and
iOS (Universal Links). GoRouter does the routing — Flutter hands it the link's
path — so there is no package and no Dart to write. init adds the
autoVerify intent filter to MainActivity with example.com as the domain,
and docs/DEEP_LINKS.md covers what no file in the repository can do: the
assetlinks.json and apple-app-site-association your domain serves (filled in
with the project's application id and bundle id), the iOS Associated Domains
capability, and the commands that check it all. moarch doctor notes the
placeholder domain until you replace it.
With the auth feature, a link survives sign-in. The router's redirect carries
where the user was going as ?from= through the splash route (a cold start
while the session is restored) and through login, and continues there
afterwards, so a signed-out user who opens a link lands on it after signing in.
Only in-app paths are followed. This is in every project with the auth feature,
deep links or not — a notification tap that opens a route needs it too.
A route a link can open gets only its URL, so everything its screen loads has to
be in the path or the query (/orders/42), never in extra. AGENTS.md says
so when the option is on.
Extensions #
core/utils/extensions.dart carries the small helpers every screen reaches for:
context.theme / colorScheme / isDarkMode / isTablet / isKeyboardOpen /
unfocus(), formKey.isValid for a whole form's validators, controller.trimmed /
trimmedOrNull for what a text field submits, string helpers (initials, capitalizeWords, truncate,
withoutDiacritics, searchKey, matchesSearch, isBlank, digitsOnly),
date and time helpers (startOfDay, endOfMonth, isTomorrow, yearsSince,
format(pattern), timeAgo(), TimeOfDay.onDate), Duration.formatted, and
number formatting (formatCurrency, formatDecimal, formatCompact,
formatPercent) — alongside the formattedDateToDatabase /
formatedDateTimeToDatabase pair the API layer uses.
Design system #
moarch init sets up the design foundation — tokens (spacing, radius, a wired-up
type scale, colors) in core/constants/app_constants.dart, an AppTheme built
from them, and a lean common set of widgets under lib/shared/widgets/: inputs (AppInput,
AppInputFormat, AppInputStyle, InputTitle), AppButton, AppLeadingIcon, the
state screens (AppAsyncView, ErrorView, EmptyView, AppLoadingData) and
overlays (AppToast, AppConfirmDialog, dialog/bottom-sheet helpers).
The palette is laid out by the 60-30-10 rule:
- 60% is
surface, the background. - 30% is the
surfaceContainer*layers,onSurfaceMutedandoutline: cards, bars, inputs and secondary text. - 10% is
primary: the main action, selected and focused states, and progress. It is the only accent.
AppButtonVariant.secondary is a tonal neutral and .tertiary an outline,
so a screen never has three competing accents. The colors moarch ships pass
WCAG AA. The comment at the top of app_constants.dart lists the pairs to
check once you fill in your brand.
AppInput is driven by an AppInputFormat: one enum that picks the keyboard, the
input formatters that shape the value as it is typed, the autofill hints and the
validation rule together.
AppInput(label: 'Amount', format: AppInputFormat.money) // 1,234.50
AppInput(label: 'Card', format: AppInputFormat.creditCard) // 4111 1111 1111 1111
AppInput(label: 'Expiry', format: AppInputFormat.cardExpiry) // 12/25
AppInputFormat.money.unformat(controller.text) // '1234.50'
A screen describes its content once #
Every generated feature used to hand-roll the same mapping: .when(...), a
Skeletonizer over a nullable body, an ErrorView, and a ref.listen with
// SHOW UI ERROR left in it. AppAsyncView and ref.listenAction are that
mapping, so the view is only the part that differs:
@override
Widget build(BuildContext context) {
ref.listenAction<OrdersState>(
context,
ordersNotifierProvider,
errorOf: (state) => state.error,
successOf: (state) => state.success,
);
return Scaffold(
appBar: AppAppBar(title: 'Orders'),
body: AppAsyncView<OrdersState>(
value: ref.watch(ordersNotifierProvider),
onRetry: () => ref.invalidate(ordersNotifierProvider),
isEmpty: (state) => state.orders.isEmpty,
skeleton: (context) => const OrdersSkeleton(),
builder: _body,
),
);
}
AppAsyncView draws whichever of the four states the value is in, and a reload
does not blank the screen: once there is data, a later loading or error state
leaves it on screen instead of replacing a list mid-read with a spinner. It
builds inline, so the Scaffold keeps its app bar throughout. An error that
carries no message of its own shows no detail — a stringified exception tells the
user nothing and leaks how the app is put together.
The skeleton is its own widget, presentation/widgets/<name>_skeleton.dart,
which moarch create feature writes beside the view. Skeletonizer shimmers the
widget tree it is given, so the skeleton draws real rows over fake data: a
list of stand-in models whose text comes from skeletonizer's BoneMock, whose
length is the width of the bone drawn over it. Keep its rows in step with
_body and the loading state stays the real layout arriving rather than a
spinner interrupting:
class OrdersSkeleton extends StatelessWidget {
const OrdersSkeleton({super.key});
static final _items = List.generate(
8,
(_) => OrdersModel.empty().copyWith(name: BoneMock.name),
);
@override
Widget build(BuildContext context) => ListView.separated(
physics: const NeverScrollableScrollPhysics(),
itemCount: _items.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) => OrderTile(_items[index]),
);
}
The value can come from anywhere. A notifier, a FutureProvider and a
StreamProvider all hand back an AsyncValue; for the sources that never became
a provider — a Firestore query, a socket, a one-off fetch — there are two more
constructors that take the source itself:
AppAsyncView<List<Order>>.stream(
stream: repository.watchOrders(),
isEmpty: (orders) => orders.isEmpty,
builder: (context, orders) => OrderList(orders),
)
AppAsyncView<Order>.future(
future: repository.fetchOrder(id),
builder: (context, order) => OrderDetail(order),
)
Same four states, same copy, same retry — only where the data comes from
changes. The subscription is the widget's: it starts with the element, is
cancelled with it, and is swapped when a different stream is passed. It holds
the same line on refreshes too, so a stream that errors, or one replaced by
another, keeps what it had already emitted on screen. A Future that completes
after the screen moved on is dropped rather than drawn over what came next.
listenAction reads the one-shot error / success fields the generated state
already clears on every copyWith, and toasts whichever arrived — one outcome
per action, never both. Pass onError / onSuccess to navigate or log instead;
that replaces the toast, so a screen that pops on success does not also flash a
message on the way out.
Not every result of an action is a message, so the same extension has
listenChange for the rest of them — select any value off the state and be
called once when it appears or changes:
ref.listenChange<OrdersState, String>(
context,
ordersNotifierProvider,
select: (state) => state.createdOrderId,
onChange: (id) => ref.read(routerProvider).push(AppRoutes.orderOf(id)),
);
A null selection means there is nothing to react to, so a request the notifier
has cleared does not fire again — (state) => state.isDone ? true : null is how
a plain flag says now. The notifier reports what happened; the screen decides
what to do about it, which is what keeps routes, focus and controllers out of
the notifier.
All of it is part of moarch init, and moarch create feature writes it into an
older project rather than generating a view that cannot compile.
Lists that load in pages — page, offset or cursor #
A project with Dio gets lib/core/network/paginated.dart and
lib/core/utils/paged_list.dart, and moarch create widget paged-list adds
the infinite-scroll widgets. This is where
mo_infinite_scroll went. The
package kept its pages in its own controller. Here they live in the notifier
or bloc state, like the rest of a screen's data.
The datasource reads the envelope with whichever factory fits the backend, and
Paginated.next holds the key of the next page. Nothing above the datasource
reads that key: the repository returns it, the state stores it and the mixin
passes it back. So moving an endpoint from page numbers to cursors only
changes the datasource.
OrderModel order(Object? e) => OrderModel.fromJson(e! as Map<String, dynamic>);
Paginated.fromPageJson(json, order); // {page, limit, total, data}
Paginated.fromOffsetJson(json, order); // {offset, limit, total, data}
Paginated.fromCursorJson(json, order); // {data, next_cursor}
The state holds a PagedList<T>. The first page loads like any other screen
data, and AppAsyncView / AppStatusView draw its skeleton, error and empty
states. A notifier mixes in PagedNotifierMixin and a bloc mixes in
PagedBlocMixin. Each provides loadMore(), which loads one page at a time,
turns a failure into a retry row instead of an error screen, and drops a page
that finishes loading after the list was refreshed.
AppPagedList<OrderModel>(
items: state.orders.items,
hasMore: state.orders.hasMore,
isLoadingMore: state.orders.isLoadingMore,
error: state.orders.error,
onLoadMore: ref.read(ordersNotifierProvider.notifier).loadMore,
onRefresh: () => ref.refresh(ordersNotifierProvider.future),
itemBuilder: (context, order) => OrderTile(order: order),
)
AppPagedGrid takes a gridDelegate. AppPagedSliver (and
AppPagedSliver.grid) goes inside a CustomScrollView with other slivers.
All three start loading the next page a few items before the end, and support
pull-to-refresh on vertical lists.
Phone numbers mask themselves, per country #
moarch create widget phone-input adds AppPhoneInput: a field that punctuates
what is typed the way the selected country writes numbers, with a searchable
country picker built into its prefix.
AppPhoneInput(
initialCountry: 'PT',
onChanged: (number) => _phone = number.e164, // '+351912345678'
)
The field holds only the national number — 912 345 678 in Portugal,
(555) 010 9999 in the US — so the calling code can never be typed twice or
deleted by accident. Changing country re-masks what is already there instead of
clearing it.
It ships a table of 238 countries (AppCountry), each carrying every shape
its numbering plan allows rather than one mask: Hungary takes 8 or 9 digits,
Germany 10 through 13, so the mask widens as the number grows. Validation holds
a number to those exact lengths — Armenia accepts 8 or 10 digits and refuses the
9 in between, which a generic 7-to-15 check would pass. Flags are derived from
the ISO code, so there are no assets to ship.
AppCountries.byIso('PT')!.format('912345678') // '912 345 678'
AppCountries.split('+351912345678') // (Portugal, '912345678')
AppCountries.initial = AppCountries.byIso('PT')!; // move the default
Numbering plans are a good description of how numbers are written, not a replacement for libphonenumber — validate server-side too if you need carrier-level correctness.
When the month is the content, not an answer in a form #
AppDateInput opens the platform picker, which is what a date-shaped field
wants. An agenda, a booking screen or a streak wants the grid itself, and that
is moarch create widget calendar — a wrapper over
table_calendar that keeps its sixty
parameters out of your screens.
AppCalendar(
selected: _day,
events: {for (final a in appointments) a.startsAt: 1}, // dots under the day
onSelected: (day) => setState(() => _day = day),
onMonthChanged: (first, last) => ref.read(p.notifier).load(first, last),
)
Two DateTimes in the same day are not equal, which is the usual reason a
marker never appears — so events is re-keyed to the day each entry falls
on. Pass the instants your data already carries; two appointments at 09:00 and
14:00 count as two dots on one day rather than missing the grid entirely.
Those dots are the accent color. When they mean different things — a status, a
category, which calendar an entry came from — name the day in eventColors
instead and it draws one dot per color:
AppCalendar(
selected: _day,
eventColors: {
for (final a in appointments) a.startsAt: [a.status.color],
},
onSelected: (day) => setState(() => _day = day),
)
A day named there takes both its dots and how many of them from that list, so
there is no count to keep in sync; events still speaks for every day that is
not named — including a day named with an empty list, since {day: []} says
nothing rather than blanking a count. Three markers is the cap either way, and
a day with more spends its last one on a +N — four events never looks the
same as three.
onMonthChanged reports the month's own bounds, not the six weeks drawn
around it — the range you actually want to fetch events for. Colors come from
AppInputVariant like the rest of the family, canChangeFormat offers the
month/2-week/week toggle (and only then is a vertical swipe live), and leaving
onSelected off makes it a read-only display.
One sheet for "what do you want to do with this?" #
moarch create widget action-sheet adds AppActionSheet — the thing behind a
three-dot button or a long press. Material rows on Android, the iOS grouped
cards everywhere else, off the same split AppDateInput uses for its pickers.
final picked = await AppActionSheet.show<String>(
context,
title: 'Order #1042',
actions: [
const AppSheetAction(label: 'Edit', icon: Icons.edit_outlined, value: 'edit'),
const AppSheetAction(label: 'Share', icon: Icons.ios_share, value: 'share'),
const AppSheetAction.destructive(
label: 'Delete',
icon: Icons.delete_outline,
value: 'delete',
),
],
);
Dismissing resolves to null, so a switch on the result has one honest "the
user backed out" branch. A row can carry an onTap instead of a value, and
it runs after the sheet has closed — a handler that pushes a route while
the sheet is still closing otherwise fights the navigator for it. Destructive
rows are drawn in the theme's error color and confirm nothing on their own:
pair one with AppConfirmDialog when the answer should be deliberate.
Unlike AppDialogs and AppBottomModals it takes a BuildContext rather than
the router's navigator key, so it costs the project no GoRouter.
Audio you configure rather than wire #
moarch create widget audio-player adds AppAudioPlayer over
just_audio. It owns the AudioPlayer,
loads the source and disposes both, so a screen never holds a controller.
AppAudioPlayer(
source: const AppAudioSource.url('https://example.com/episode.mp3'),
title: 'Episode 12',
showSpeed: true,
)
Every part is a switch, which is what makes one widget serve a podcast screen and a voice note in a chat bubble:
AppAudioPlayer(
source: AppAudioSource.file(recording.path),
style: AppAudioPlayerStyle.compact, // one row
showSkip: false, // a 6-second note has nothing to skip
showSpeed: false,
showRemaining: true, // -0:04 rather than the total
)
showControls, showProgress, allowScrub, showTimes and showSpeed are
independent, and the skip buttons take durations rather than a fixed 15/30 —
the number is drawn inside the arrow, so any interval works without an icon per
value:
AppAudioPlayer(
source: ...,
skipBackward: const Duration(seconds: 10),
skipForward: const Duration(seconds: 10),
)
Buffered progress rides in the bar's secondary track, a scrub is not dragged
back by the position stream mid-drag, and a finished clip restarts on the next
tap rather than sitting at the end. It plays audio and nothing else — the same
split just_audio makes; lock-screen controls are just_audio_background's
job and adding it changes nothing here.
Children you can drag into a new order #
moarch create widget drag-section adds AppDragSection. It reports the move
and nothing else — the list stays yours, so it can live in a notifier, in
storage, or on a server without the widget holding a second copy of the truth.
AppDragSection(
items: [
for (final card in _cards)
AppDragItem(id: card.id, child: DashboardCard(card)),
],
onReorder: (from, to) => setState(
() => _cards = AppDragSection.reorder(_cards, from, to),
),
)
Each item says how big it is and whether it moves; the section says which way it runs:
AppDragSection(
orientation: Axis.horizontal,
items: [
AppDragItem(id: 'a', size: AppDragSize.large, child: ChartCard()),
AppDragItem(id: 'b', size: AppDragSize.small, child: TotalCard()),
AppDragItem(id: 'new', draggable: false, child: AddTile()), // pinned
],
onReorder: _move,
)
A pinned item is a wall, not just an item that cannot be picked up —
nothing can be dropped past it, so the "add" tile above keeps the last slot
however the rest are shuffled. AppDragSize.small/medium/large come from
AppDragSizes (retune the whole section at once), or give one item an exact
extent.
A long press starts the drag by default, because an immediate listener over the
item's whole surface fights the scroll; trigger: AppDragTrigger.handle puts a
grip on the trailing edge instead, for an item that is already tappable.
onReorder hands you indices ready to use — measured against the list
with the dragged item taken out, and stopped at any pinned item in the way —
and AppDragSection.reorder does the remove-and-insert.
A table that fits a phone #
moarch create widget table adds AppTable — no dependency, and sized for a
screen narrower than the data.
AppTable(
columns: const [
AppTableColumn(label: 'Item', flex: 2),
AppTableColumn.numeric(label: 'Qty', width: 56),
AppTableColumn.numeric(label: 'Total'),
],
rows: const [
AppTableRow(cells: ['Coffee', '2', '€7.00']),
AppTableRow(cells: ['Pastry', '1', '€2.40']),
],
footer: const AppTableRow(cells: ['Total', '3', '€9.40']),
)
Columns are fixed (width) or flexible (flex), and a flexible one never
squeezes below its minWidth. Past the point where the minimums no longer fit,
the table pans sideways instead of crushing the columns further.
AppTableColumn.numeric right-aligns and switches on tabular figures, so a
column of money reads down cleanly. Rows take onTap, selected and a colour
of their own; striped, showRowDividers, showColumnDividers, showBorder
and density decide the rest. Cells are strings by default, or pass widgets
for a chip or an avatar.
It deliberately does not scroll vertically — a table that owns a vertical
scroll cannot sit in a page that also scrolls without one of them being wrong.
Drop it into AppSingleScrollView or a ListView.
One country list, two ways in #
moarch create widget country-picker adds AppCountryPicker over the same
238-country AppCountry table AppPhoneInput reads. As a field:
AppCountryPicker(
label: 'Country',
selectedIso: _iso,
required: true,
onChanged: (country) => setState(() => _iso = country.iso),
)
Or as a sheet on its own, from anywhere that is not a form:
final country = await AppCountryPicker.show(context, selectedIso: _iso);
show is the single place the country sheet is configured — the flag
leading each row, the calling code trailing it, and the ranked search that makes
PT find Portugal rather than the first country whose name contains those
letters. AppPhoneInput now opens that same sheet for its prefix instead of
carrying its own copy, so the two cannot drift apart.
It hands back the whole AppCountry rather than a code, since the caller
usually wants the dial code or the flag too. display picks what the closed
field reads as — 🇵🇹 Portugal, the name alone, 🇵🇹 +351, or just the flag —
and countries narrows the list to the ones you ship to.
Long lists get a search instead of a menu #
A menu stops being usable somewhere around thirty options, so past that an
AppDropdownInput opens a SearchPickerSheet instead — the same field, the
same callback, a list you can type into. It counts its own options and decides;
searchable: true or false overrules it for one field, and
AppInputConfig.searchableThreshold moves the line for the whole app.
AppDropdownInput<CategoryModel>(
label: 'Category',
items: categories,
idOf: (c) => c.id,
labelOf: (c) => c.name,
required: true,
selectedId: _categoryId,
onChanged: (id) => setState(() => _categoryId = id),
onSelected: (category) => _prefillFrom(category),
onCleared: () => setState(() => _categoryId = null),
)
Either form is a real form field: required: true is rejected by
Form.validate(), and validator replaces the rule.
AppMultiSelectInput is the same field in the plural — the same item list,
the same sheet with a checkbox on every row, and a maxSelected the sheet
enforces as you tick rather than leaving to the form to refuse afterwards:
AppMultiSelectInput<TagModel>(
label: 'Tags',
items: tags,
idOf: (t) => t.id,
labelOf: (t) => t.name,
selectedIds: _tagIds,
maxSelected: 3,
required: true,
onChanged: (ids) => setState(() => _tagIds = ids),
)
It hands back the whole selection in items order, and shows it as removable
chips, as labels, or as "3 selected".
required means the form actually refuses #
Every input that takes required enforces it — the text, phone, dropdown, date
and time fields, and the controls that carry a selection rather than text:
AppCheckboxLabel(
label: 'Accept the terms',
value: _accepted,
required: true, // Form.validate() fails until it is ticked
onChanged: (v) => setState(() => _accepted = v),
)
AppRadioGroup<Plan>(
values: Plan.values,
groupValue: _plan,
labelOf: (p) => p.name,
required: true, // ...until one is chosen
onChanged: (p) => setState(() => _plan = p),
)
A checkbox has no border to turn red and no helper line to explain itself, so
these render the message underneath through SelectionFormField — which is
exposed, if you want to put a control of your own into a form the same way.
One file decides how every input looks #
shared/widgets/inputs/app_input_config.dart is the whole family's answer to
"where does the label go, and what does a field look like by default?" — read by
AppInput, the date/time/dropdown/OTP fields, and the checkbox, switch,
segmented, chips, radio, slider, stepper and search widgets alike.
Edit the literal in that file and you are done — no wiring, no main():
// app_input_config.dart
static AppInputConfig defaults = const AppInputConfig(
labelMode: AppInputLabelMode.floating, // above | floating | placeholder | none
type: AppInputType.outlined,
shape: AppInputShape.pill,
requiredMarker: ' (required)',
autovalidateMode: AutovalidateMode.onUserInteraction,
);
It stays assignable for what a literal can't do — a flavor or white-label build
picking at startup (AppInputConfig.defaults = ... before runApp).
Any field still overrides it: AppInput(label: 'Email', labelMode: AppInputLabelMode.above).
It also owns the numbers that used to be private constants — border widths, the
resting-border and fill opacities, and the font/icon/padding metrics behind
small / medium / large. Raw sizes stay in AppConstants; the config
decides which token each input size picks.
What it deliberately does not own is color. AppInputConfig.variant starts
at null, and that null is the rule the whole kit is painted by:
No variant means the theme paints it. A checkbox with no variant is colored by
checkboxTheme, a card bycardTheme, a chip bychipTheme, a field byinputDecorationTheme— solib/config/theme/app_theme.dartis the one file that restyles the app. Naming a variant hands that widget's colors back to the kit:AppInput(label: 'Amount', variant: AppInputVariant.danger)is danger-colored whatever the theme says.
Geometry stays the kit's either way — AppInputType, AppInputShape and
AppInputSize decide a field's borders and padding, because a theme has no way
to describe four variants at once. A filled field with no variant takes
inputDecorationTheme.fillColor untinted, which is the same color AppCard
paints, so a field and a card standing next to each other match.
Read-only is not disabled #
Every widget in the kit that holds a value takes readOnly alongside its
enabled/onChanged switch, and the two say different things:
AppCheckboxLabel(label: 'Terms', value: true) // greyed out
AppCheckboxLabel(label: 'Terms', value: true, readOnly: true) // normal, inert
A disabled control greys itself out because its value is not the user's to set
yet. A read-only one keeps every color at full strength — the value it is
showing is real and worth reading — and simply stops answering, to the pointer
and to the keyboard both. It also keeps validating, so a required field the
user cannot reach still fails the form rather than passing quietly.
readOnly: true is the whole of what you write. No callback is required, and
none has to be invented: a Checkbox, a Switch or a Slider greys itself out
the moment its callback goes null, so each control hands Material a no-op of its
own — one the read-only gate guarantees is never reached. The picker-backed
fields assert that a field the user can pick in was given an onChanged, so
the looser signature cannot quietly hide a forgotten one.
Everything else in the kit is one command away, catalogued in the generated
docs/UI_KIT.md:
moarch create widget switch # AppSwitch (+ any widgets it depends on)
moarch create widget otp # AppOtpInput
moarch create widget all # the whole kit + the DesignSystemView preview
moarch create widget --list # print the catalog in the terminal
Widget dependencies are pulled in automatically, and any pub package a widget needs
(cached_network_image for avatars/images) is added to pubspec.yaml.
The kit covers:
- inputs — switch, segmented, choice chips, radio group, slider, date/time,
date range, dropdown (searchable on request), multi-select, checkbox, OTP,
rating, file picker,
AppSearchField,AppStepper(quantity −/+),AppPhoneInput(per-country masking),AppCalendar(inline month),AppCountryPicker(238 countries) - overlays —
AppActionSheet(platform-shaped),AppToast,AppConfirmDialog,AppBottomSheetScaffold, dialog/bottom-sheet helpers - buttons & icons —
AppButton,AppTextButton(the quiet, text-first action),AppLeadingIcon,AppIconButton,AppFab - layout —
AppListTile,AppCard,AppCardTile,AppTag,AppBadge,AppSectionHeader,AppExpansionTile,AppTimeline,AppTable,AppDragSection,AppRichText(a style and a tap handler per span, with the recognizers handled for you) - feedback —
AppAsyncView,ref.listenAction/ref.listenChange,AppBanner,AppProgressBar,AppScreenLock, skeleton list, loading overlay,ErrorView,EmptyView - media —
AppAvatar,AppImage,AppCarousel,AppAudioPlayer - navigation —
AppAppBar,AppBottomNav(Material 3, classic, pill or dot; labels beside, below or nowhere; each of which can float as a stadium, rounded or square card),AppTabs,AppDrawer,AppNavRail/AppAdaptiveNav,AppStepIndicator
Every control shares one vocabulary — variant, type, shape, size — and
moarch create widget design-system (or all) generates a screen previewing them
all. It renders with the same AppTheme main.dart uses — so what you see there
is what ships: edit lib/config/theme/app_theme.dart and the preview follows
(with a light/dark toggle in its app bar when the project has both themes). Set
AppConstants.fontFamily (or swap in google_fonts) to restyle the whole app's
typography from one place.
One theme, or two #
A project scaffolds with one brand palette: AppConstants declares a single
set of colors and AppTheme has a single light getter that main.dart hands
to MaterialApp. That is the common case, and it keeps the file you actually
edit — the palette — half the size.
Tick Dark theme + theme mode switch in the init checklist to get the
other half: a *Dark counterpart for every color token, an AppTheme.dark
built from them, and darkTheme wired into main.dart. The design-system
preview gets its toggle.
It also brings the user's light / dark / system choice, saved across launches.
core/services/theme_mode_service.dart holds it — themeModeProvider on
Riverpod, ThemeModeCubit on bloc — and main.dart watches it for themeMode.
A settings screen changes it with
ref.read(themeModeProvider.notifier).setMode(ThemeMode.dark) or
context.read<ThemeModeCubit>().setMode(ThemeMode.dark). It is stored through
PreferencesService (core/services/preferences_service.dart), a small
wrapper over shared_preferences for any non-secret setting. The locator loads
it before runApp, so the first frame already has the saved theme, with no
flash of the wrong one. Tokens stay in TokenStorage. With one palette there
is nothing to choose between, so there is no switch either.
Success, warning and info have no slot in ColorScheme, so they live in
AppStatusColors (config/theme/app_status_colors.dart), a ThemeExtension
that each ThemeData registers. A widget reads context.statusColors.success
and gets the right value for the current brightness. AppToast, AppTag and
AppBanner read it, which is what makes them follow a dark theme. A project from
before 9.0.0 keeps the AppConstants lookups until moarch doctor --fix adds the
file; then moarch update theme tag banner toast moves them onto it.
AppConstants also carries the motion curves: curveStandard for movement on
screen, and curveEnter / curveExit for things arriving and leaving. An older
app_constants.dart you have edited, which therefore never refreshes, does not
declare them. Widgets refreshed into such a project get the literal curves
instead, so they still compile. Shadows are not tokens: AppCard takes its
shadow color from cardTheme, so elevation stays a theme setting.
Either way it is reversible, and moarch reads the scope off app_theme.dart
rather than remembering it, so moarch update keeps regenerating what the
project actually is:
moarch create theme --dark # add the dark half (and the switch) to a one-theme project
moarch create theme --no-dark # drop it again
moarch create theme --dark -d # ...or just print the diff first
A project that has the dark theme from before 9.2.0 has no switch: moarch create theme --dark adds it, writing the two services, registering
PreferencesService in core_module.dart, and adding shared_preferences to the
pubspec. (A project whose locator is still the single pre-9.0.0 injector.dart
keeps following the system.)
The palette and everything reading it are generated against each other, so the
switch is all of those files at once. Files moarch wrote and nobody edited are
rewritten silently; if you have edited one, nothing is written and the diffs are
yours to apply (or --force).
Models #
A feature has one data type, and it is a freezed class: the model. There is
no separate entity — domain/models/<x>_model.dart is freezed + json_serializable,
the repository hands it to the state layer as it is, and the screens draw it.
Every field is declared once. Run fvm dart run build_runner build after
editing one.
The model lives in domain/ because it is what the domain speaks: the
repository interfaces return it, so the dependencies keep pointing inward
(presentation → domain ← data). The trade is that domain/ imports the
freezed and json_serializable annotations, and that a change to a payload's
shape reaches the screens that read it, where an entity layer would have
absorbed it in a mapping.
A model shared by several features — an address, a money amount — belongs in
lib/core/, not in whichever feature happened to need it first.
Equality is freezed's, which covers every field. The hand-written == this
replaced was keyed on id alone, and a multi-step create form builds drafts
that all share an empty id — so every draft compared equal, Bloc.emit
short-circuited, and the form quietly lost what had been typed into it.
copyWith improves too: freezed's can set a nullable field back to null,
which ?? this.x cannot.
.empty() is not something freezed writes, so moarch still does.
From a JSON sample #
Hand create model a sample of the payload the API actually returns and the
fields are inferred instead of left as TODOs:
moarch create model orders order --from-json sample.json
// from {"id": 7, "customer_name": "Ana", "total": 12.5,
// "created_at": "2026-08-01T10:30:00Z", "tags": ["vip"]}
required int id,
@JsonKey(name: 'customer_name') required String customerName,
required double total,
@JsonKey(name: 'created_at') required DateTime createdAt,
required List<String> tags, // homogeneous lists keep their element type
A key that differs from the Dart name is stated once as a @JsonKey, and
json_serializable owns both directions. A top-level JSON list is sampled at
its first element — the common shape of a list endpoint's response. A null in
the sample can only type as dynamic (a null says nothing about its type), and
the command calls those fields out so you can tighten them.
On a Firestore project, add --doc when the sample is a document:
moarch create model works fatura --from-json doc-sample.json --doc
That adds fromDoc and a String id — including one the sample does not
have, since a document's id is its name rather than a field of its data. An
id the sample typed as something other than String is retyped, because
doc.id always is one. Without --doc you get the nested-value shape, for a
map that lives inside someone else's document.
--doc marks a Firestore document root on a --from-json model. It
adds fromDoc and keeps the id out of the body, since add() assigns it after
the write. Leave it off for a value object nested inside a document: one can
carry an id of its own and still be a plain map. Either way, on a Firestore
project every DateTime gets a @TimestampConverter(), because a nested map
lives inside a document just the same — so dates stay sortable and queryable
server-side instead of being stored as ISO text.
Flavors #
moarch create flavors sets up dev / staging / prod — or the names you
pass — through flutter_flavorizr,
configured so the project keeps one main.dart: yours, untouched.
It writes flavorizr.yaml with the app name and the Android/iOS ids read from
the project (non-production flavors get suffixed ids, so the builds install
side by side) and adds the dev dependency. The instructions list in that
file is the point: flavorizr runs only the processors that patch the native
side and generate lib/flavors.dart — no main_<flavor>.dart entry points,
no replaced main.dart.
moarch create flavors
flutter pub get
dart run flutter_flavorizr # applies the instructions in flavorizr.yaml
flutter run --flavor dev
That one run is equivalent to applying the processors by hand:
dart run flutter_flavorizr -p android:flavorizrGradle,android:buildGradle,android:androidManifest
dart run flutter_flavorizr -p ios:xcconfig,ios:plist
dart run flutter_flavorizr -p flutter:flavors
The app reads the current flavor off the generated lib/flavors.dart, and the
flavored entries init writes into .vscode/launch.json (a debug/release
pair per flavor) work as soon as the native side is patched.
With Firebase, each suffixed application id is its own app in the Firebase
console — register them there and re-run flutterfire configure.
Staying up to date #
Generated code goes stale: the templates keep improving, and a project scaffolded
two versions ago still has the old app_input.dart and the old
validation_service.dart. moarch update closes that gap without ever gambling
with your edits.
moarch update # the whole project — refresh what's safe, review the rest
moarch update --list # every name and group update accepts
moarch update --dry-run # report only, write nothing
moarch update --diff # show what would change, line by line
moarch update validation # one file, on its own
moarch update input button # or several
moarch update core docs # or whole groups
It covers everything the CLI generates, not just widgets — each addressable on its own:
| group | what's in it |
|---|---|
widgets |
the whole lib/shared/widgets/ kit (input, button, toast…) |
core |
extensions, logger, constants, api-constants, action-notifier / action-bloc, exception, main |
network |
dio-client, safe-api-call, safe-firebase-call |
security |
validation, secure-storage, biometric |
services |
media-service, notifications-service, permission-service, debouncer… |
config |
theme, env, router, routes, firebase-providers, injector |
auth |
the generated auth feature, REST or Firebase, notifier or bloc |
docs |
ui-kit, deploy-checklist, security-checklist, jks-doc, workflow-doc, firebase-doc |
workflows |
the four GitHub Actions workflows |
project |
analysis-options, splash, fvmrc, widget-test, vscode-settings, vscode-launch |
ios |
ios-entitlements, ios-profile-entitlements, xcode-script |
android |
proguard |
Only files your project actually has are ever touched: update refreshes what is
there, it never scaffolds what you chose not to generate. Templates that vary
with your setup — app_logger.dart with Crashlytics, main.dart with the router
and localization, the auth feature against REST or Firebase — are rebuilt against
the project they're being written into.
It sorts every generated file into one of three buckets:
| up to date | already matches the current template — nothing to do |
| can be refreshed | moarch wrote it, you never touched it, the template has since changed |
| needs review | the template changed and so did your copy — listed and diffed, never overwritten |
The distinction comes from .moarch.yaml, a manifest written at generation time
recording the moarch version, your init selections, and a hash of every file the
CLI wrote. Commit it. Without it moarch can't prove a file is untouched, so
it falls back to treating everything as needing review — safe, just less useful.
Nothing in the third bucket is written unless you pass --force, which discards
those edits. Run git diff after any update before committing.
A project scaffolded by an older moarch has no record of the non-widget files, so they report as needs review until the first
updatere-records them. That's the safe direction: nothing is overwritten on the strength of a guess.
Checking a project #
moarch doctor looks for the things that break a scaffolded project in practice:
build_runnernever run, soconfig/env/app_env.g.dartdoesn't exist yet (the most common first-run failure)- both localization approaches installed, leaving
MaterialAppwith two competing sets of delegates - a generated widget whose dependency or pub package was never added — a broken import either way
go_routerandconfig/router/out of sync- Firebase half-wired: no
Firebase.initializeApp()inmain.dart, a missinggoogle-services.json/GoogleService-Info.plist,firebase_coreorgoogle_sign_inabsent frompubspec.yaml, orInfo.pliststill carrying the placeholder Google client ids — each of which fails at runtime, on the first call, with an error that doesn't name the step that was missed - a generated service missing its platform declarations — the camera and
photo permissions
MediaServicerequests, the notification receivers, the URL launcher's<queries>— which denies or throws at runtime instead - a REST auth feature from before 9.3.0 without the
UserModelits refreshed files import
Each finding says what to do about it, and moarch doctor --fix applies the
mechanical ones — generating a missing widget dependency, adding a missing
package, copying CLIENT_ID and REVERSED_CLIENT_ID out of
GoogleService-Info.plist into Info.plist, declaring a service's
permissions, writing the auth UserModel. Anything that's a genuine choice
(which localization package to drop) is reported and left to you.
Scopes (bloc) #
A route you push, a bottom sheet and a dialog are siblings of the screen that
opened them in the Navigator, not children, so context.read<MatterBloc>()
from inside them finds nothing. A scope carries the screen's blocs across:
moarch create scope matter matter # every bloc the feature declares
moarch create scope matter matter --parent LawfirmScope # nested: the lawfirm's blocs travel too
moarch create scope orders orders --blocs OrdersBloc,OrderFiltersCubit
That writes features/<feature>/presentation/scopes/<name>_scope.dart:
context.showMatterSheet((_) => const NewTodoSheet()); // a sheet that reads the matter's blocs
context.showMatterDialog((_) => const ConfirmDialog());
context.push(AppRoutes.newDocument, extra: MatterScope.of(context));
GoRoute(
path: AppRoutes.newDocument,
builder: (context, state) =>
(state.extra! as MatterScope).provide(child: const NewDocumentPage()),
)
MatterScope.of(context) collects the blocs, and provide(child:) provides the
same instances again with BlocProvider.value. Every screen shares one state,
and the screen that created the blocs still closes them. With --parent, the
outer scope's blocs are provided around this one's.
For routes nested under a screen, the command also prints a ShellRoute
that provides the blocs above every child route. Prefer it where it fits:
extra is not part of the URL, so a deep link or a web refresh opens a route
with no scope, while a shell's providers are always there. Scopes remain the
tool for sheets and dialogs.
Riverpod has no equivalent to generate: providers live above the Navigator, so a pushed route already reads the same notifier.
Tests #
Tests are generated from the code the project already has, so they come once a feature has real methods:
moarch create tests # every feature
moarch create tests orders # one feature
moarch create tests --dry-run # list what would be written
Unit tests. One file per notifier, bloc or cubit, under
test/unit/features/<feature>/, with mocktail mocks for every dependency and a
success and an error test per action:
- A Riverpod notifier that reads its repository out of the locator
(
OrdersRepository get _repo => getIt<OrdersRepository>();) gets the mock registered ingetItinsetUp, andgetIt.reset()intearDown. Dependencies read throughref.read(provider)are overridden on theProviderContainerinstead. - A bloc gets
bloc_test: its constructor takes the mocks, and eachon<Event>becomes a group that adds a real event. A handler going throughrunActionhas its failure asserted onstate.errorMessage, which is whererunActionputs it. A handler that only acts from one state (if (current is! OrdersLoaded) return;) gets that state as itsseed:. - Calls
build()or the constructor makes are stubbed once insetUp. The error path throwsconst ServerException(message: 'test'), a realAppException, so no test-only factory lives in production code. Stubbed models areModel.empty(); the command says so when one lacks the factory, andmoarch create empty-factoriesadds it.
Integration tests. One file per GET endpoint a remote datasource calls,
under test/integration/features/<feature>/, against the real API. The path
may be a literal or an ApiConstants field. They check the wiring, not the
data: the test fails only when the server cannot be reached (NetworkException
from safeApiCall), since an error status still proves the endpoint answers.
test/integration/dio_helper.dart builds the client from AppEnv.baseUrl. It
is written once and never overwritten, which makes it the place for a test
login.
Re-running is how the tests follow the code. Every generated file starts
with a GENERATED BY moarch line, and a re-run refreshes those. Delete that
line once you edit a file, and it is left alone (--force overwrites anyway).
The first run adds mocktail (and bloc_test) to dev_dependencies if they
are missing.
This replaced the mogen_unit_tests and mogen_integration_tests packages.
Their output is recognised and refreshed, and moarch doctor --fix swaps the
two dev dependencies for mocktail. Running inside moarch rather than as a dev
dependency also means the generator no longer puts analyzer in your app,
where it used to collide with freezed and riverpod.
Make it your own #
moarch is a helper built first for my own projects and my coworkers' — the templates are our conventions, and the pub.dev package will keep following them. When your conventions differ, the intended move is not a feature request: clone it and make the templates yours.
git clone https://github.com/SuperMoooo/moarch.git
cd moarch
# edit lib/src/templates/** — every generated file is a plain Dart string
dart pub global activate --source path ./
Every template lives under lib/src/templates/, one function per generated
file, and the catalogs (lib/src/utils/widget_catalog.dart and
scaffold_catalog.dart) are the lists of what exists — add an entry there and
init, create widget and update all pick it up. Once activated from your
path, moarch update in your projects refreshes toward your templates: the
manifest machinery doesn't care whose they are.
License #
MIT © André Montoito