This To-Do List app is built using the latest architectural guidelines introduced by the Flutter team, ensuring a seamless, intuitive, and highly scalable experience.
This documentation covers the project's structure, design patterns, and how modern Flutter architecture principles have been implemented to create a modular, testable, and maintainable codebase.
The technology chosen for the development of this project was Flutter. Find out more at: flutter.dev
-
Flutter:
Official Flutter Documentation
- Auto Injector: Dependency injection in the app
To start the project, use the following command:
flutter run -t lib/main.dart --dart-define-from-file .envTo ensure the functionality of the exception classes, run the unit tests using:
flutter test --dart-define-from-file .envTo generate a coverage report:
flutter test --coverage --dart-define-from-file .envTo view coverage results in an HTML report:
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html # macOS
xdg-open coverage/html/index.html # Linux
start coverage/html/index.html # WindowsIn many applications, certain settings—such as API URLs, API keys, endpoints, etc.—vary depending on the environment (development, staging, production). Using environment variables helps you:
- Centralize Configuration: Keep all environment-specific settings in one place.
- Enhance Security: Avoid hardcoding sensitive information directly in your source code.
- Simplify Environment Switching: Easily change configurations without modifying your code.
Although Flutter doesn’t support .env files out of the box, you can simulate this behavior by using the --dart-define flag during the build process, and then access these values in your code with String.fromEnvironment.
You can create a dedicated class to centralize and manage your environment variables. For example, consider the following AppConfig class:
class AppConfig {
static const String apiUrl = String.fromEnvironment(
'API_URL',
defaultValue: 'https://api.example.com',
);
static const String apiKey = String.fromEnvironment(
'API_KEY',
defaultValue: 'default_api_key',
);
}In this example, if you don't pass any values for API_URL or API_KEY during the build, the default values will be used.
In operations that may return results or errors, we can use the typedef Output<T> to represent the output of these operations. This typedef allows us to encapsulate both success and failure in a single type using Either.
- Operation can return a success (T) or an error
(BaseException). - This typedef is parameterized with a generic type T, which represents the type of data returned in case of success.
typedef Output<T> = Either<BaseException, T>;
Either<TLeft, TRight> is a functional programming construct used to represent a value that can be one of two types:
- Left (
TLeft): Represents a failure, usually an error or exceptional case. - Right (
TRight): Represents a success, containing the expected result.
This eliminates the need for exceptions (try-catch) by making errors explicit and type-safe.
BaseException is a custom exception class used in Output<T> to handle errors in a structured way.
Implementation
class BaseException implements Exception {
final String message;
BaseException(this.message);
@override
String toString() => "BaseException: $message";
}Creating exception classes to handle specific errors is possible by extending the BaseException class. Use the DefaultException structure as a basis:
class DefaultException extends BaseException {
const DefaultException({
required super.message,
super.stackTracing,
});
}try {
throw DefaultException("Something went wrong!");
} catch (e) {
print(e); // Output: DefaultException: Something went wrong!
}Output<T> is a specialized version of Either designed for error handling. It always returns:
Left<BaseException>for errors.Right<T>for successful values.
typedef Output<T> = Either<BaseException, T>;To simplify result handling, we define:
Output<T> success<T>(T value) => right<BaseException, T>(value);
Output<T> failure<T>(BaseException exception) => left<BaseException, T>(exception);Output<int> parseNumber(String input) {
try {
return success(int.parse(input));
} catch (e) {
return failure(BaseException("Invalid input"));
}
}Future<Output<String>> fetchData() async {
try {
await Future.delayed(Duration(seconds: 1));
return success("Data fetched successfully!");
} catch (e) {
return failure(BaseException("Failed to fetch data"));
}
}AsyncOutput<T> encapsulates an asynchronous computation that can result in either:
- Success (
T). - Failure (
BaseException).
Instead of using Future<T> directly, AsyncOutput<T> enables functional operations like map, bind, and fold, making asynchronous programming safer and more composable.
The Unit class represents a singleton value that signifies the absence of meaningful return data. It is inspired by functional programming paradigms (Scala, Kotlin).
Instead of using void, we return Unit when we need a function to return something but do not want to use null.
Instead of:
void logMessage(String message) {
print(message);
}Use:
Unit logMessage(String message) {
print(message);
return unit;
}Output<Unit> saveData(String data) {
if (data.isEmpty) {
return failure(BaseException("Cannot save empty data"));
}
return success(unit);
}This project follows the State Pattern, leveraging Flutter's native state management tools to control and notify state changes efficiently. The architecture is based on an abstract class called ViewModel, which manages UI state using ValueNotifier<T>. This ensures a reactive and lightweight state management approach while maintaining compatibility with Flutter’s built-in [ValueListenableBuilder], [ListenableBuilder], and [AnimatedBuilder] widgets.
Each screen or component should have a dedicated ViewModel that extends ViewModel<T>. Additionally, you can create specific state classes to represent different UI states.
Defining States
Define states by extending ViewState. Example:
final class HomeSuccess extends ViewState {
final String message;
HomeSuccess(this.message);
}This approach ensures that each state is explicitly defined, making it easier to manage and debug.
Creating a ViewModel
A ViewModel extends ViewModel<T> and manages state transitions.
class HomeViewModel extends ViewModel<ViewState> {
HomeViewModel() : super(HomeInitial());
Future<void> fetchData() async {
emit(HomeLoading());
await Future.delayed(Duration(seconds: 2)); // Simulating an API call
emit(HomeSuccess("Data Loaded Successfully"));
}
}- The
fetchData()function:- Emits a loading state (
HomeLoading()). - Performs an asynchronous operation (simulated delay for API calls).
- Emits a success state (
HomeSuccess()).
- Emits a loading state (
Listening to State Updates
The ViewModel can be consumed using Flutter’s built-in listeners:
ValueListenableBuilder<ViewState>(
valueListenable: homeViewModel,
builder: (context, state, _) {
if (state is HomeLoading) {
return CircularProgressIndicator();
} else if (state is HomeSuccess) {
return Text(state.message);
} else {
return Text("Initial State");
}
},
);- Why use
ValueListenableBuilder?- It listens only to relevant state changes.
- More efficient than
setState(). - Works well with dependency injection.
The Command Pattern is used in this project to encapsulate asynchronous actions while ensuring proper state management, execution control, and error handling.
This implementation follows Flutter’s Command Pattern approach and uses the Command<BaseException, T> class to manage execution safely.
- Encapsulates asynchronous operations (
Future<T>) while keeping execution state. - Prevents concurrent execution issues (ensures only one execution at a time).
- Automatically manages success (
Right<T>) and failure (Left<BaseException>) results. - Provides utility getters (
rightResult,leftResult,isExecuting,isSuccess,isException).
This project provides three types of commands:
Command0<T>– No parameters.Command1<T, P>– One parameter.Command2<T, P1, P2>– Two parameters.
Each extends Command<BaseException, T> and ensures execution control.
For simple asynchronous actions without parameters:
final command = Command0<int>(() async {
return Output.right(42);
});
await command.execute();
print(command.rightResult); // 42For actions requiring one parameter:
final command = Command1<String, int>((value) async {
return Output.right("Number: $value");
});
await command.execute(5);
print(command.rightResult); // "Number: 5"For actions requiring two parameters:
final command = Command2<bool, int, int>((a, b) async {
return Output.right(a + b > 10);
});
await command.execute(5, 10);
print(command.rightResult); // trueCommand Execution Control
All commands prevent concurrent execution and provide useful utilities:
if (command.isExecuting) {
print("Already executing...");
}
await command.waitForCompletion(); // Waits for execution to finish
command.clean(); // Clears previous resultsThe Dependency Inversion Principle (DIP) is a cornerstone of the SOLID principles in object-oriented design. It dictates that high-level modules should not depend on low-level modules; rather, both should depend on abstractions. In essence, dependencies should point to abstract interfaces or classes rather than to concrete implementations. This approach makes systems more modular, testable, and maintainable. For an in-depth discussion, check out this article.
To effectively apply DIP in your projects, consider the following practices:
-
Define Clear Abstractions:
Identify interfaces or abstract classes that specify the required behaviors of your high-level modules. Ensure these abstractions remain independent of any concrete implementations. -
Inject Dependencies:
Rather than instantiating objects directly within a class, supply them via constructors, methods, or properties. This strategy allows you to swap out concrete implementations with alternative versions that adhere to the same abstraction without altering the high-level module's code. -
Embrace Inversion of Control (IoC):
In DIP, the control flow is inverted so that concrete implementations depend on abstractions. This inversion is often achieved with a dependency injection container that manages the instantiation and resolution of dependencies. -
Test in Isolation:
By relying on abstractions and injecting dependencies, you can easily substitute real implementations with mocks or stubs during testing. This isolation facilitates thorough unit testing of each component.
Implementing these practices results in a codebase that is flexible, modular, and easier to maintain.
Here's an example demonstrating DIP in practice:
class GetShowUseCaseImpl extends GetShowUseCase {
final ShowRepository showRepository;
GetShowUseCaseImpl({
required this.showRepository,
});
}In this example, GetShowUseCaseImpl depends on the abstract ShowRepository rather than a concrete implementation, adhering to the DIP and promoting a design that is both decoupled and easy to test.
Coupling occurs when one class directly depends on another—for example, when a class calls another to execute an operation or retrieve data. In such cases, the first class is tied to the implementation details of the second, creating a dependency that can make maintenance and testing more challenging.
To decouple classes from their dependencies, we use Dependency Injection. This technique involves supplying an object’s dependencies through its constructor, setters, or methods, rather than having the object create them itself. You can see an example of this approach in our discussion on the Dependency Inversion Principle (DIP).
The Auto Injector strategy facilitates dependency injection by managing the registration and creation of objects within a module. All objects are registered with the injector, which then constructs them on demand or as singletons (i.e., a single instance per module). The entire registration process is handled by the auto_injector system.
There are several methods available for binding (registering) object instances:
injector.add: Creates an instance on demand (factory).injector.addSingleton: Creates an instance once when the module starts.injector.addLazySingleton: Creates an instance once, at the time of first request.injector.addInstance: Registers an already existing instance.
Here’s an example of how to configure the injector:
final injector = AutoInjector(on: (injector) {
injector.addInjector(coreModule);
injector.addInjector(showScheduleModule);
injector.addInjector(weatherForecastModule);
injector.addLazySingleton<HomeController>(HomeController.new);
injector.commit();
});In this setup, the dependencies for each instance are automatically resolved using the auto_injector mechanisms.
To retrieve an instance, use the autoInjector.get method:
// Retrieve an instance of Client
final client = autoInjector.get<Client>();
// Retrieve an instance with a default value if not found
final clientWithDefault = autoInjector.get<Client>(defaultValue: Client());
// Alternatively, use tryGet to return null if not available, then provide a fallback
Client clientOrFallback = autoInjector.tryGet<Client>() ?? Client();
// Retrieve an instance using a specific key
Client keyedClient = autoInjector.get(key: 'OtherClient');By using these strategies, your code becomes more modular, easier to test, and simpler to maintain.