A reference Flutter project for Android developers learning Flutter. Covers Clean Architecture, get_it service locator, Provider state management, common widgets, navigation patterns, forms, and theming.
- Tech Stack
- Project Structure
- Architecture
- Layers in Detail
- Dependency Injection
- State Management
- Navigation
- Class Reference
- Naming Conventions
- Flutter vs Android Concepts
- Lint & Code Quality
| Package | Version | Role | Android Equivalent |
|---|---|---|---|
| Flutter SDK | ≥ 3.32 | UI framework | — |
| Dart | ≥ 3.11 | Language | Kotlin |
get_it |
^8.0.0 | Service locator / DI container | Hilt / Koin |
provider |
^6.1.2 | Widget-tree state propagation | ViewModel + LiveData observers |
flutter_lints |
^6.0.0 | Static analysis | detekt / ktlint |
lib/
├── main.dart # Entry point: calls setupDependencies() then runApp()
│
├── app/ # App-wide config (≈ Application class)
│ ├── app.dart # Root widget (MaterialApp)
│ ├── routes.dart # Named route constants + factory function
│ └── theme/
│ └── app_theme.dart # Light & dark ThemeData
│
├── core/ # Cross-cutting utilities, no Flutter/domain deps
│ ├── di/
│ │ └── injection.dart # ★ get_it registrations (composition root)
│ └── utils/
│ └── validators.dart # Pure Dart validation functions
│
├── domain/ # Business logic — framework-free pure Dart
│ ├── entities/
│ │ └── product.dart # Immutable data class (≈ domain model)
│ ├── repositories/
│ │ └── product_repository.dart # Abstract interface (dependency inversion)
│ └── usecases/
│ └── get_products.dart # Single-responsibility use case
│
├── data/ # Infrastructure / data sources
│ ├── models/
│ │ └── product_model.dart # DTO extending the domain entity
│ └── repositories/
│ └── product_repository_impl.dart # Concrete repo (in-memory / network)
│
└── presentation/ # UI layer
├── providers/
│ ├── product_provider.dart # ≈ ViewModel for products
│ └── theme_provider.dart # ≈ ViewModel for theme preference
├── screens/
│ ├── home/
│ │ ├── home_screen.dart # Shell: NavigationBar + IndexedStack
│ │ └── dashboard_screen.dart # Home tab: banner + topic grid
│ ├── widgets_showcase/
│ │ └── widgets_screen.dart # Catalogue of common Flutter widgets
│ ├── products/
│ │ ├── product_list_screen.dart
│ │ └── product_detail_screen.dart
│ ├── forms/
│ │ └── form_screen.dart # Form validation demo
│ └── settings/
│ └── settings_screen.dart # Theme switcher
└── widgets/
└── product/
└── product_tile.dart # Reusable list-item widget
The project follows Clean Architecture (Uncle Bob), adapted for Flutter. The dependency rule is enforced strictly: outer layers depend on inner layers, never the reverse.
┌──────────────────────────────────────────────┐
│ Presentation Layer │ ← Flutter widgets, Providers
│ (HomeScreen, ProductProvider, ...) │
├──────────────────────────────────────────────┤
│ Domain Layer │ ← Pure Dart, no framework
│ (Product, ProductRepository, GetProducts) │
├──────────────────────────────────────────────┤
│ Data Layer │ ← Implements domain contracts
│ (ProductModel, ProductRepositoryImpl) │
└──────────────────────────────────────────────┘
▲ all wired by get_it in injection.dart
| Android (MVVM + Clean) | Flutter equivalent |
|---|---|
data class User |
Product entity (domain/entities/) |
interface UserRepository |
abstract interface class ProductRepository |
UserRepositoryImpl |
ProductRepositoryImpl |
GetUsersUseCase |
GetProducts |
ViewModel |
ProductProvider extends ChangeNotifier |
LiveData<T> / StateFlow<T> |
fields + notifyListeners() |
Fragment / Activity |
*Screen widget |
RecyclerView |
ListView.builder / ListView.separated |
@Composable / ViewBinding |
Widget.build(BuildContext) |
Hilt module @Binds / @Provides |
injection.dart — getIt.registerLazySingleton |
Koin single { } / factory { } |
registerLazySingleton / registerFactory |
Contains no Flutter imports, only pure Dart. Unit-testable without a device.
Immutable data class. Equivalent to a Kotlin data class in a domain module.
class Product {
const Product({required this.id, required this.name, required this.price, ...});
final String id;
final String name;
final double price;
}Abstract interface. Defines what the domain needs; the data layer decides how.
abstract interface class ProductRepository {
Future<List<Product>> getProducts();
Future<Product?> getProductById(String id);
}
abstract interface classis Dart 3 syntax equivalent to a Kotlininterface. Classes that implement it cannot also extend it.
A use case with a single call() method, invokable like a function.
class GetProducts {
const GetProducts(this._repository); // injected by get_it
final ProductRepository _repository;
Future<List<Product>> call() => _repository.getProducts();
}Implements domain contracts. Swap the Impl class for a network/DB version
without touching domain or presentation.
Extends Product and adds JSON serialisation — the DTO pattern.
class ProductModel extends Product {
const ProductModel({...}) : super(...);
factory ProductModel.fromJson(Map<String, dynamic> json) => ProductModel(...);
Map<String, dynamic> toJson() => {...};
}Concrete repo registered in get_it behind the ProductRepository interface.
Replace this class to use Retrofit/Dio (HTTP) or Drift/Isar (local DB) with
zero changes to any other layer.
ProductProvider holds an async state machine driven by ProductStatus:
initial ──► loading ──► loaded
└──► error
ThemeProvider holds ThemeMode and exposes toggleTheme() / setThemeMode().
Both are registered as LazySingleton in get_it and wrapped in
ChangeNotifierProvider so the widget tree can react to their changes.
This project combines two complementary tools:
| Tool | Responsibility |
|---|---|
get_it |
Creates objects, manages their lifetime, resolves the dependency graph |
provider |
Makes ChangeNotifier instances observable inside the Flutter widget tree |
Think of it as: get_it = factory/scope, Provider = LiveData observer.
The composition root — the only file that imports concrete Impl classes.
All other files depend on abstractions.
final GetIt getIt = GetIt.instance; // global accessor
void setupDependencies() {
// ── Data ──────────────────────────────────────────────────────────────────
getIt.registerLazySingleton<ProductRepository>(
() => ProductRepositoryImpl(), // concrete bound to the interface
);
// ── Domain ────────────────────────────────────────────────────────────────
getIt.registerLazySingleton<GetProducts>(
() => GetProducts(getIt<ProductRepository>()), // resolved from get_it
);
// ── Presentation ──────────────────────────────────────────────────────────
getIt.registerLazySingleton<ThemeProvider>(
() => ThemeProvider(),
);
getIt.registerLazySingleton<ProductProvider>(
() => ProductProvider(getIt<GetProducts>()),
);
}void main() {
setupDependencies(); // 1. build the dependency graph
runApp(
MultiProvider(
providers: [
// 2. get_it resolves the instance
// 3. Provider makes it reactive in the widget tree
ChangeNotifierProvider(create: (_) => getIt<ThemeProvider>()),
ChangeNotifierProvider(create: (_) => getIt<ProductProvider>()),
],
child: const FlutterLearningApp(),
),
);
}| get_it method | Behaviour | Android equivalent |
|---|---|---|
registerLazySingleton<T>(() => T()) |
Created on first access, same instance forever | Hilt @Singleton / Koin single { } |
registerSingleton<T>(instance) |
Created immediately at registration, same instance forever | Hilt @Singleton (eager) |
registerFactory<T>(() => T()) |
New instance on every getIt<T>() call |
Hilt @ActivityScoped / Koin factory { } |
registerFactoryParam<T,P1,P2> |
Factory that accepts runtime parameters | Hilt AssistedInject |
This project uses registerLazySingleton for everything because all
dependencies are app-scoped (created once, live for the app's lifetime).
setupDependencies() in injection.dart
│
├── getIt<ProductRepository>()
│ └── ProductRepositoryImpl() ← concrete, hidden from all other layers
│
├── getIt<GetProducts>()
│ └── GetProducts( getIt<ProductRepository>() )
│
├── getIt<ThemeProvider>()
│ └── ThemeProvider()
│
└── getIt<ProductProvider>()
└── ProductProvider( getIt<GetProducts>() )
At runtime, resolving ProductProvider triggers the chain:
ProductProvider → GetProducts → ProductRepository → ProductRepositoryImpl.
Because get_it is a global service locator, you can resolve dependencies
anywhere — not just inside build():
// In a utility class, a background service, or anywhere:
final repo = getIt<ProductRepository>();
final products = await repo.getProducts();This is the key advantage over using Provider alone, which requires a
BuildContext from the widget tree.
| Hilt (Android) | Koin | get_it |
|---|---|---|
@Module @InstallIn(SingletonComponent) |
module { } block |
setupDependencies() function |
@Provides @Singleton fun provideRepo(): Repo = RepoImpl() |
single<Repo> { RepoImpl() } |
registerLazySingleton<Repo> { RepoImpl() } |
@Binds abstract fun bind(impl: RepoImpl): Repo |
same as above | same — just register against the interface type |
@Provides fun provideUseCase(repo: Repo) = UseCase(repo) |
single { UseCase(get()) } |
registerLazySingleton { UseCase(getIt()) } |
@Inject constructor(dep: Dep) |
automatic with get() |
pass getIt<Dep>() in the factory lambda |
@HiltViewModel class VM @Inject constructor(uc: UC) |
viewModel { VM(get()) } |
registerLazySingleton { ProductProvider(getIt()) } |
@Singleton scope |
single { } |
registerLazySingleton |
@ActivityScoped / @FragmentScoped |
factory { } |
registerFactory |
@AssistedInject + @AssistedFactory |
factoryOf |
registerFactoryParam |
Flutter has no built-in ViewModel. This project uses provider for
widget-tree reactivity. get_it creates the ChangeNotifier instances;
Provider propagates their changes into the widget tree.
// build() — subscribes; widget rebuilds when ProductProvider notifies.
// Equivalent to observing a LiveData / StateFlow in onCreateView.
final state = context.watch<ProductProvider>();
// Callback — one-shot; no subscription.
// Equivalent to calling viewModel.load() from an onClickListener.
context.read<ProductProvider>().load();| Method | Rebuilds? | Use in |
|---|---|---|
context.watch<T>() |
Yes | build() |
context.read<T>() |
No | onPressed, initState, callbacks |
return switch (state.status) {
ProductStatus.initial ||
ProductStatus.loading => const CircularProgressIndicator(),
ProductStatus.error => ErrorView(state.error),
ProductStatus.loaded => ProductListView(state.products),
// Dart 3 compiler enforces all enum cases are handled — like Kotlin `when`
};Flutter uses an imperative Navigator stack (Navigator 1.0).
const String homeRoute = '/';
const String productDetailRoute = '/product-detail';
Route<dynamic> generateRoute(RouteSettings settings) {
switch (settings.name) {
case productDetailRoute:
final args = settings.arguments;
if (args is! Product) return fallbackRoute;
// Smart cast — args is now Product (like Kotlin `as?` + null-check)
return MaterialPageRoute(builder: (_) => ProductDetailScreen(product: args));
...
}
}// Caller — push with typed argument
Navigator.pushNamed(context, productDetailRoute, arguments: product);
// Receiver — type-safe retrieval via is/is! check (no explicit cast needed)
final args = settings.arguments;
if (args is! Product) return fallback;
ProductDetailScreen(product: args) // args auto-typed as ProductNavigationBar= Material 3 replacement forBottomNavigationBarIndexedStackkeeps all screens in the tree simultaneously → state is preserved on tab switch (equivalent toFragmentManager hide/show)- Screens are built once in
initStateso callbacks hold stable references
| Class / Symbol | File | Description |
|---|---|---|
getIt |
core/di/injection.dart |
Global GetIt instance. The only entry point for dependency resolution. |
setupDependencies() |
core/di/injection.dart |
Registers all types. Called once in main() before runApp. |
FlutterLearningApp |
app/app.dart |
Root StatelessWidget. Configures MaterialApp, routes, and theme. |
appLightTheme / appDarkTheme |
app/theme/app_theme.dart |
Top-level ThemeData instances using Material 3 ColorScheme.fromSeed. |
generateRoute |
app/routes.dart |
Top-level route factory consumed by MaterialApp.onGenerateRoute. |
requiredField / emailField / minLengthField |
core/utils/validators.dart |
Top-level String? Function(String?) validators; plug directly into FormField.validator. |
Product |
domain/entities/product.dart |
Immutable domain entity. No framework dependencies. |
ProductRepository |
domain/repositories/product_repository.dart |
Abstract interface — domain contract for data access. |
GetProducts |
domain/usecases/get_products.dart |
Use case. Single call() method — invokable as a function. |
ProductModel |
data/models/product_model.dart |
DTO: extends Product, adds fromJson/toJson. |
ProductRepositoryImpl |
data/repositories/product_repository_impl.dart |
Concrete repo. In-memory with fake delay. Swap for HTTP/DB without any other changes. |
ThemeProvider |
presentation/providers/theme_provider.dart |
Holds ThemeMode. Exposes toggleTheme() and setThemeMode(). |
ProductProvider |
presentation/providers/product_provider.dart |
Holds ProductStatus state machine + filtered product list. |
ProductStatus |
presentation/providers/product_provider.dart |
enum { initial, loading, loaded, error } — drives UI state. |
HomeScreen |
presentation/screens/home/home_screen.dart |
Shell widget. Owns tab index. Builds screens once in initState. |
DashboardScreen |
presentation/screens/home/dashboard_screen.dart |
Landing tab. Receives onSwitchTab callback for card navigation. |
ProductTile |
presentation/widgets/product/product_tile.dart |
Reusable horizontal card. Card + InkWell + Clip.antiAlias. |
| Item | Convention | Example |
|---|---|---|
| Files | snake_case |
product_list_screen.dart |
| Classes | PascalCase |
ProductProvider |
| Methods & variables | camelCase |
loadProducts(), isLoading |
| Constants | camelCase (not SCREAMING_SNAKE) |
homeRoute, appLightTheme |
| Private members | leading _ |
_status, _loadScheduled |
| Unused parameters | _ wildcard (Dart 3) |
errorBuilder: (_, _, _) => ... |
| Full-page widgets | *Screen suffix |
ProductListScreen |
| Reusable components | descriptive noun | ProductTile, _WelcomeBanner |
| Private helpers in same file | _PascalCase |
_AppDrawer, _TopicCard |
| DI setup function | verb phrase | setupDependencies() |
Flutter splits screens into small _PrivateWidget classes within the same
file — lighter than creating many separate files:
// dashboard_screen.dart — one file, multiple private classes
class DashboardScreen extends StatelessWidget { ... } // public
class _AppDrawer extends StatelessWidget { ... } // private
class _WelcomeBanner extends StatelessWidget { ... } // private
class _TopicsGrid extends StatelessWidget { ... } // private
class _TopicCard extends StatelessWidget { ... } // private| Android | Flutter | Notes |
|---|---|---|
Activity / Fragment |
*Screen widget |
UI is code, no XML layout |
onCreate |
initState() |
Called once; one-time setup |
onDestroy |
dispose() |
Release controllers, streams, animations |
onResume / onPause |
WidgetsBindingObserver |
Opt-in mixin |
View (mutable object) |
Widget (immutable description) |
Widgets describe; Flutter renders |
ViewGroup |
Column, Row, Stack, Scaffold |
Composable layout widgets |
RecyclerView.Adapter |
ListView.builder |
Lazy, virtualized |
dp unit |
logical pixels (bare number) | EdgeInsets.all(16) = 16 lp |
strings.xml |
const String / AppLocalizations |
No XML resources |
themes.xml / colors.xml |
ThemeData + ColorScheme |
Theme.of(context).colorScheme.primary |
TextAppearance |
TextTheme |
Theme.of(context).textTheme.bodyLarge |
Parcelable |
Not needed | Pass objects directly via Navigator arguments |
suspend fun + Coroutines |
async / await / Future<T> |
Same mental model |
Flow<T> / StateFlow<T> |
Stream<T> / ChangeNotifier |
Provider wraps ChangeNotifier |
sealed class UiState |
enum + exhaustive switch |
Compiler-enforced in both |
@Composable |
Widget.build(BuildContext) |
UI as a function of state |
remember { mutableStateOf() } |
StatefulWidget + setState() |
State in the State<T> object |
LaunchedEffect |
initState + addPostFrameCallback |
Post-first-frame side effects |
Context |
BuildContext |
Tree-scoped; never use after dispose |
Hilt / Koin |
get_it |
See cheat-sheet above |
StatelessWidget → build() called when parent rebuilds.
No mutable state of its own.
≈ @Composable with no remember{} / no ViewModel.
StatefulWidget → Paired with a State<T> object that survives rebuilds.
Call setState() to trigger a rebuild.
Has initState(), didChangeDependencies(), dispose().
≈ @Composable using remember{} or a ViewModel reference.
BuildContext is the widget's handle to its position in the widget tree:
context.watch<T>() // subscribe — like observing LiveData
context.read<T>() // one-shot resolve — no subscription
Theme.of(context) // read inherited theme
Navigator.of(context).push // push a new route
ScaffoldMessenger.of(context).showSnackBar(...)Always check if (mounted) before using a BuildContext after an await
(the widget may have been removed from the tree in the meantime).
Strict rules in analysis_options.yaml:
| Rule | What it enforces |
|---|---|
always_use_package_imports |
All lib/ imports must use package:app/... (no relative ../) |
prefer_const_constructors |
Use const Widget() when no runtime fields are involved |
prefer_const_constructors_in_immutables |
StatelessWidget constructors should be const when possible |
prefer_final_fields |
Fields never reassigned must be final |
prefer_final_locals |
Local variables never reassigned must be final |
always_declare_return_types |
Every function must explicitly declare its return type |
avoid_classes_with_only_static_members |
Use top-level functions/variables instead of utility classes |
strict-inference |
No implicit dynamic; all generics must be fully inferrable |
strict-casts |
Unsafe casts to dynamic are errors |
use_enums |
Replace abstract classes with only static const members with enums |
# Run static analysis
flutter analyze
# Format all code (80-char page width)
dart format .