Skip to content

tganzerli/todo_list

Repository files navigation

Todo List

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.

📌 Technology

The technology chosen for the development of this project was Flutter. Find out more at: flutter.dev

Required Versions

Main Packages Used

💡 Usage

To start the project, use the following command:

flutter run -t lib/main.dart --dart-define-from-file .env

🧪 Running Tests

To ensure the functionality of the exception classes, run the unit tests using:

flutter test --dart-define-from-file .env

Running tests with coverage

To generate a coverage report:

flutter test --coverage --dart-define-from-file .env

To 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  # Windows

📖 Documentation

Environment Variables (.env)

Why Use .env in Flutter?

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

How to Use String.fromEnvironment in a Class

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.

Error and Results Handling

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

1. Either

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.

2. Standardized Error - BaseException

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

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,
	});
}
Usage Example
try {
  throw DefaultException("Something went wrong!");
} catch (e) {
  print(e); // Output: DefaultException: Something went wrong!
}

3. Typed Result Handling - Output<T>

Output<T> is a specialized version of Either designed for error handling. It always returns:

  • Left<BaseException> for errors.
  • Right<T> for successful values.
Definition
typedef Output<T> = Either<BaseException, T>;
Helper Functions

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);
Usage Examples - Handling Parsing
Output<int> parseNumber(String input) {
  try {
    return success(int.parse(input));
  } catch (e) {
    return failure(BaseException("Invalid input"));
  }
}
Usage Examples - Handling API Calls
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"));
  }
}

4. Handling Async Operations - AsyncOutput<T>

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 mapbind, and fold, making asynchronous programming safer and more composable.

5. Placeholder Return Type - Unit

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.

Usage Scenarios - Replacing void

Instead of:

void logMessage(String message) {
  print(message);
}

Use:

Unit logMessage(String message) {
  print(message);
  return unit;
}
Usage Scenarios - Using Unit in Either
Output<Unit> saveData(String data) {
  if (data.isEmpty) {
    return failure(BaseException("Cannot save empty data"));
  }
  return success(unit);
}

State Pattern

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.

How to Use?

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

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.

Command Pattern

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.


Why Use the Command Pattern?

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

Implementing Commands

This project provides three types of commands:

  1. Command0<T> – No parameters.
  2. Command1<T, P> – One parameter.
  3. Command2<T, P1, P2> – Two parameters.

Each extends Command<BaseException, T> and ensures execution control.

Command0 (No Parameters)

For simple asynchronous actions without parameters:

final command = Command0<int>(() async {
  return Output.right(42);
});

await command.execute();
print(command.rightResult); // 42
Command1<T, P> (Single Parameter)

For 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"
Command2<T, P1, P2> (Two Parameters)

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

Command 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 results

Dependency Inversion Principle (DIP)

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

Dependency Injection (Auto Injector)

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.


About

Todo List

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages