These rules govern architecture, code style, and file layout for this Flutter repo. They are enforceable guidelines for PR review and AI-assisted edits in Cursor.
Goal: Separate concerns by feature, keep UI and data apart, and share only stable cross-cutting utilities.
lib/
app/
core/
constants/ # spacing, durations, zIndex, config, api timeouts
routing/ # AppRoutes (constants), router init, nav helpers
di/ # get_it registrations
error/ # Failure + typed exceptions
l10n/ # gen-l10n outputs + context extensions
bootstrap.dart # runApp + DI + router
features/
<feature>/
data/
models/
dtos/
sources/ # api/local data sources
repositories/ # impl only; throws typed Failures
domain/
entities/
repositories/ # abstract contracts only
usecases/ # thin orchestration; may throw Failures
presentation/
cubit/ # Cubit + State (Freezed allowed)
widgets/
pages/
Import boundaries (must):
presentation � domain�data � domain�presentation � data� (UI must never importdata)domain � presentation� (no upward deps)- Cross-feature imports go only through
app/core/*shared utilities.
File naming (should):
- snake_case files;
*_page.dart,*_widget.dart,*_cubit.dart,*_state.dart,*_usecase.dart,*_repository.dart,*_entity.dart.
Policy:
- Use
throw/try-catchwith typedFailureexceptions. NoEither, noResult.
// app/core/error/failures.dart
sealed class Failure implements Exception {
final String message;
const Failure(this.message);
}
class NetworkFailure extends Failure { const NetworkFailure(super.message); }
class ApiFailure extends Failure { const ApiFailure(super.message); }
class CacheFailure extends Failure { const CacheFailure(super.message); }data/*converts low-level errors � typedFailure.domain/*may rethrowFailureafter domain guards.presentation/*catchesFailureand maps to user-friendly states (use l10n keys for messages).
Rules:
- One Cubit per page/flow.
- One sealed State class per Cubit (
idle|loading|success|error|emptyminimal set). - Expose side-effects via methods; keep constructors DI-friendly.
class ExampleCubit extends Cubit<ExampleState> {
ExampleCubit(this._getData) : super(const ExampleState.idle());
final GetData _getData;
Future<void> load() async {
emit(const ExampleState.loading());
try {
final data = await _getData();
emit(ExampleState.success(data));
} on Failure catch (e) {
emit(ExampleState.error(e.message)); // message = l10n key preferred
} catch (_) {
emit(const ExampleState.error('unexpected_error'));
}
}
}Must:
- All paths live in
AppRoutesconstants. No route enums. No magic strings.
// app/core/routing/app_routes.dart
abstract final class AppRoutes {
static const splash = '/';
static const home = '/home';
static const details = '/details/:id';
static String detailsPath(String id) => '/details/$id';
}- Router defined once in
app/core/routing/router.dart. - Each feature exposes its own
featureXRoutes: List<RouteBase>and is registered in the root.
final GoRouter appRouter = GoRouter(
routes: [
GoRoute(path: AppRoutes.splash, builder: (_, __) => const SplashPage()),
...featureXRoutes, // provided by features/<feature>/presentation/<feature>_routes.dart
],
);- Prefer helper for navigation (avoid inline path formatting):
abstract final class AppNav {
static void toDetails(BuildContext c, String id) => c.go(AppRoutes.detailsPath(id));
}Must:
- Every user-visible string is localized via
gen-l10n. - Keys are feature-scoped:
home_title,details_buy_button,auth_error_network.
ARB files: app/l10n/arb/app_en.arb, app_uk.arb, etc.
Accessor:
extension L10nX on BuildContext {
AppLocalizations get l10n => AppLocalizations.of(this)!;
}Usage: Text(context.l10n.home_title)
Lint (regex suggestions for reviews):
- Disallow raw strings in widgets:
- Flag:
Text\(['"].+?['"]\)(allowlist: test files, debug-only). - Flag any
SnackBar\(.*?content:\s*Text\(['"]not using l10n.
- Flag:
Must:
- UI numbers go in
app/core/constants/ui.dart. - Config/timeouts/limits go in
app/core/constants/config.dart. - Colors, spacing, radii, durations, z-indices centralised.
abstract final class Gaps {
static const xs = 4.0; static const s = 8.0;
static const m = 16.0; static const l = 24.0; static const xl = 32.0;
}
abstract final class DurX {
static const fast = Duration(milliseconds: 150);
static const normal = Duration(milliseconds: 250);
}Review checklist:
- � No numeric literals in widgets/layout except
0or1for index/opacity toggles. - � No route strings outside
AppRoutes. - � Prefer
constconstructors andfinalfields.
Policy:
- Use plain
get_it(noinjectableunless explicitly added). - Register interfaces � implementations; presentation requests use cases or repositories via DI.
final getIt = GetIt.instance;
void setupDi() {
// data
getIt.registerLazySingleton<HttpClient>(() => HttpClientImpl());
getIt.registerLazySingleton<ExampleRepository>(() => ExampleRepositoryImpl(getIt()));
// domain
getIt.registerFactory(() => GetData(getIt<ExampleRepository>()));
// presentation
getIt.registerFactory(() => ExampleCubit(getIt<GetData>()));
}Must:
domain: unit test use cases & repository contracts (throwsFailurecorrectly).data: mock IO/http; JSON fixtures for models; mapping & error translation.presentation: Cubit tests for state sequences (success/typed failure).widget: critical pages (golden optional).
Required lints (suggested):
flutter_lints+ custom rules:prefer_final_locals,exhaustive_switch,avoid_print,unawaited_futures,no_logic_in_build,always_use_package_imports.
Style:
- Small widgets as classes (not build methods).
- No business logic in
build(). - Use
sealedclasses where appropriate (states/failures). - Avoid
dynamic; prefer explicit types. asyncAPIs returnFuture<T>; cancel long ops indispose.
- ❌�� UI importing
data/* - ❌�� Raw strings in widgets/snackbars/dialogs
- ❌�� Route strings outside
AppRoutes - ❌�� Magic numbers for padding/sizes/durations
- ❌�� Business logic inside
build()/ Widgets - ❌�� Swallowing exceptions (always map to
Failureor rethrow)