Skip to content

Jasolgar/flutter-learning-app

Repository files navigation

Flutter Learning App

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.


Table of Contents

  1. Tech Stack
  2. Project Structure
  3. Architecture
  4. Layers in Detail
  5. Dependency Injection
  6. State Management
  7. Navigation
  8. Class Reference
  9. Naming Conventions
  10. Flutter vs Android Concepts
  11. Lint & Code Quality

Tech Stack

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

Project Structure

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

Architecture

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 comparison

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.dartgetIt.registerLazySingleton
Koin single { } / factory { } registerLazySingleton / registerFactory

Layers in Detail

Domain layer — lib/domain/

Contains no Flutter imports, only pure Dart. Unit-testable without a device.

Productdomain/entities/product.dart

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;
}

ProductRepositorydomain/repositories/product_repository.dart

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 class is Dart 3 syntax equivalent to a Kotlin interface. Classes that implement it cannot also extend it.

GetProductsdomain/usecases/get_products.dart

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();
}

Data layer — lib/data/

Implements domain contracts. Swap the Impl class for a network/DB version without touching domain or presentation.

ProductModeldata/models/product_model.dart

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() => {...};
}

ProductRepositoryImpldata/repositories/product_repository_impl.dart

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.


Presentation layer — lib/presentation/

Providers (≈ ViewModels)

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.


Dependency Injection

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.


injection.dartcore/di/injection.dart

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>()),
  );
}

main.dart — entry point

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 registration types

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).


Dependency graph

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.


Resolving outside the widget tree

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 / Koin → get_it cheat-sheet

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

State Management

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.

How it works

// 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();

context.watch vs context.read

Method Rebuilds? Use in
context.watch<T>() Yes build()
context.read<T>() No onPressed, initState, callbacks

Exhaustive state with enum + switch expression

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`
};

Navigation

Flutter uses an imperative Navigator stack (Navigator 1.0).

Named routes — app/routes.dart

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));
    ...
  }
}

Passing arguments

// 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 Product

Bottom Navigation + IndexedStack

  • NavigationBar = Material 3 replacement for BottomNavigationBar
  • IndexedStack keeps all screens in the tree simultaneously → state is preserved on tab switch (equivalent to FragmentManager hide/show)
  • Screens are built once in initState so callbacks hold stable references

Class Reference

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.

Naming Conventions

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()

Private helpers in the same file

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

Flutter vs Android Concepts

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 vs StatefulWidget

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

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).


Lint & Code Quality

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 .

About

Flutter learning app covering common widgets, navigation, clean architecture, state management (Provider + get_it), forms and theming

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors