DEV Community

Cover image for Flutter Clean Architecture [3]: An Overview & Project Structure
marwaMejri
marwaMejri

Posted on

Flutter Clean Architecture [3]: An Overview & Project Structure

You can find the completed project repository here

In the previous section, we exposed the secure center layer of our app, which operates independently of other layers. Now, let's explore how our app interacts with the outside world, including APIs and third-party libraries.

Data Layer

The data layer is composed of repositories, data sources, and models. The repositories are classes that can interact with zero or some data sources in order to expose the data to the rest of the app, while the data sources, as the name implies, are in charge of giving the app the data it needs to perform a function. They can represent data from a variety of sources, including the network, a local database, files, and even memory.

Each data source function should produce a model that captures the information that was retrieved from a particular source. The model is actually an entity with some extra functionality on top of it. In our case, we'll add to/from JSON functions to each model in order to convert it to Dart objects.

Implementing the Models

The model file will be located in the data/models folder:

Image description

Each model represents the API data, as was already mentioned. However, we also know that each interface of the app will interact with entities to create its UI, so what should we do?

Image description

I came up with an abstract class called DataMapper whose main role is to map a model into the desired entity. This class will be shared among all app models and will be located within the 'core/utils/mapper' folder:

abstract class DataMapper<Type> {
  Type mapToEntity();
}
Enter fullscreen mode Exit fullscreen mode

The DataMapper class, as we can see, only has one function that returns the entity Type. To map the appropriate entity, each model will now extend this class and override the mapToEntity function.

Creating the model class with the use of the data mapper class

So, under data/models/weather_info_remote_response_model, I created a new class called WeatherInfoResponseModel to represent the weather data:

class WeatherInfoResponseModel extends DataMapper<WeatherInfoEntity> {
  WeatherInfoResponseModel(
      {this.coordinateData,
      this.weatherDescription,
      this.mainWeatherData,
      this.weatherVisibility,
      this.windData,
      this.cloudsData,
      this.date,
      this.sunsetAndSunriseData,
      this.timezone,
      this.id,
      this.cityName,
     });
  CoordinateResponseModel? coordinateData;
  List<WeatherDescriptionResponseModel>? weatherDescription;
  MainWeatherInfoResponseModel? mainWeatherData;
  int? weatherVisibility;
  WindInfoResponseModel? windData;
  CloudsResponseModel? cloudsData;
  int? date;
  SunsetSunriseResponseModel? sunsetAndSunriseData;
  int? timezone;
  int? id;
  String? cityName;
  @override
  WeatherInfoEntity mapToEntity() {
    final List<WeatherDescriptionEntity>? _weatherDescription =
        weatherDescription
            ?.map((WeatherDescriptionResponseModel weatherDescriptionEntity) =>
                weatherDescriptionEntity.mapToEntity())
            .toList();
    final WeatherTypeEnum? weatherTypeEnum =
        _weatherDescription?[0].main?.toWeatherType();
    return WeatherInfoEntity(
      main: mainWeatherData?.mapToEntity() ?? const MainWeatherInfoEntity(),
      id: id ?? 0,
      visibility: weatherVisibility?.toKM() ?? '',
      clouds: cloudsData?.mapToEntity() ?? const CloudsEntity(),
      weather: _weatherDescription,
      dt: date?.fromTimestampToDate(),
      name: cityName ?? '',
      sys: sunsetAndSunriseData?.mapToEntity() ?? const SunsetSunriseEntity(),
      timezone: timezone ?? 0,
      wind: windData?.mapToEntity() ?? const WindInfoEntity(),
      weatherTheme: weatherTypeEnum.toWeatherTheme(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

To map this model into the WeatherInfoEntity class:

1. Each sub-model in the WeatherInfoResponseModel should override the mapToEntity function(ex: coordinateData,mainWeatherData,windData..).
2. To ensure that WeatherInfoEntity is suitable for creating our interfaces, it is essential to define the appropriate value for each field. For example, I provided an alternative value for null output and made some conversions to meet the desired result, such as converting the visibility field from meters to kilometers.

All that is left is to add to/from JSON functions:

 WeatherModel.fromJson(Map<String, dynamic> json) {
    coord = json['coord'] != null ? Coord.fromJson(json['coord']) : null;
    if (json['weather'] != null) {
      weather = <Weather>[];
      json['weather'].forEach((Weather v) {
        weather?.add(Weather.fromJson(v));
      });
    }
    base = json['base'];
    main = json['main'] != null ? Main.fromJson(json['main']) : null;
    visibility = json['visibility'];
    wind = json['wind'] != null ? Wind.fromJson(json['wind']) : null;
    clouds = json['clouds'] != null ? Clouds.fromJson(json['clouds']) : null;
    dt = json['dt'];
    sys = json['sys'] != null ? Sys.fromJson(json['sys']) : null;
    timezone = json['timezone'];
    id = json['id'];
    name = json['name'];
    cod = json['cod'];
  }

  Map<String, dynamic> toJson() {
    final Map<String, dynamic> map = <String, dynamic>{};
    if (coord != null) {
      map['coord'] = coord?.toJson();
    }
    if (weather != null) {
      map['weather'] = weather?.map((Weather v) => v.toJson()).toList();
    }
    map['base'] = base;
    if (main != null) {
      map['main'] = main?.toJson();
    }
    map['visibility'] = visibility;
    if (wind != null) {
      map['wind'] = wind?.toJson();
    }
    if (clouds != null) {
      map['clouds'] = clouds?.toJson();
    }
    map['dt'] = dt;
    if (sys != null) {
      map['sys'] = sys?.toJson();
    }
    map['timezone'] = timezone;
    map['id'] = id;
    map['name'] = name;
    map['cod'] = cod;
    return map;
  }
Enter fullscreen mode Exit fullscreen mode

In the following code snippet, I have to write every line of those functions myself, and if I want to add or delete an attribute of my model, I have to edit it all over again, which makes maintainability difficult.

Image description

After researching, I found that the most popular JSON serialization solution for Flutter is the 'json_serializable' package created by Google developers. By using this package in combination with 'build_runner' and 'json_annotation' packages, the fromJSON and toJSON functions can be automatically generated for models that are properly configured and annotated. This eliminates the need for manually writing out all the JSON keys.
As a result of adding configurations, the WeatherInfoResponseModel will look like this:

part 'weather_info_response_model.g.dart';
@JsonSerializable()
class WeatherInfoResponseModel extends DataMapper<WeatherInfoEntity> {
  WeatherInfoResponseModel(
      {this.coordinateData,
      this.weatherDescription,
      this.mainWeatherData,
      this.weatherVisibility,
      this.windData,
      this.cloudsData,
      this.date,
      this.sunsetAndSunriseData,
      this.timezone,
      this.id,
      this.cityName,
     });
  factory WeatherInfoResponseModel.fromJson(Map<String, dynamic> json) =>
      _$WeatherInfoResponseModelFromJson(json);
  @JsonKey(name: 'coord')
  CoordinateResponseModel? coordinateData;
  @JsonKey(name: 'weather')
  List<WeatherDescriptionResponseModel>? weatherDescription;
  @JsonKey(name: 'main')
  MainWeatherInfoResponseModel? mainWeatherData;
  @JsonKey(name: 'visibility')
  int? weatherVisibility;
  @JsonKey(name: 'wind')
  WindInfoResponseModel? windData;
  @JsonKey(name: 'clouds')
  CloudsResponseModel? cloudsData;
  @JsonKey(name: 'dt')
  int? date;
  @JsonKey(name: 'sys')
  SunsetSunriseResponseModel? sunsetAndSunriseData;
  int? timezone;
  int? id;
  @JsonKey(name: 'name')
  String? cityName;
  @override
  WeatherInfoEntity mapToEntity() {
    final List<WeatherDescriptionEntity>? _weatherDescription =
        weatherDescription
            ?.map((WeatherDescriptionResponseModel weatherDescriptionEntity) =>
                weatherDescriptionEntity.mapToEntity())
            .toList();
    final WeatherTypeEnum? weatherTypeEnum =
        _weatherDescription?[0].main?.toWeatherType();
    return WeatherInfoEntity(
      main: mainWeatherData?.mapToEntity() ?? const MainWeatherInfoEntity(),
      id: id ?? 0,
      visibility: weatherVisibility?.toKM() ?? '',
      clouds: cloudsData?.mapToEntity() ?? const CloudsEntity(),
      weather: _weatherDescription,
      dt: date?.fromTimestampToDate(),
      name: cityName ?? '',
      sys: sunsetAndSunriseData?.mapToEntity() ?? const SunsetSunriseEntity(),
      timezone: timezone ?? 0,
      wind: windData?.mapToEntity() ?? const WindInfoEntity(),
      weatherTheme: weatherTypeEnum.toWeatherTheme(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

So, at the top of the file, we included the 'weather info response model.g.dart' part to provide the WeatherInfoResponseModel class access to the methods inside the file that will be generated later using the build_runner plugin. We also annotated our class with @JsonSerializable() to tell the build runner that this class is involved in the build. Finally, we added the fromJson factory constructor as well as the JsonKey() annotation in front of each field to indicate the API key.
Following our exploration of the model structure, we will begin work on the repository implementation and data source contracts.

Implementing repository the contract

The Repositories are the app's data layer's brain; they are in charge of:

1. Exposing data to the rest of the app.
2. Centralizing changes to the data.
3. Resolving conflicts between multiple data sources.
4. Abstracting sources of data from the rest of the app.

Besides this, the repository implements the logic for deciding whether to collect data from a remote data source or to use results cached in a local database. Each data source class, on the other hand, acts as a bridge between the application and the system for data operations; it is responsible for interacting with just one source of data.

In our case, we'll have two data sources: one for network data and another for local data to ensure that the user sees anything even when he's not connected to the internet. This indicates that we'll also require a method to determine the user's current network connection state.

Image description

To meet this requirement by creating the 'ConnectivityCheckerHelper' class, which allows for checking the connectivity whenever desired. This class can be found in the directory core/utils/helpers/connectivity_helper:
Image description
I used the 'Connectivity_plus' plugin to determine the user's connection status. It offers two functions: one to check the device's connection status, and another that triggers whenever the connectivity state changes:

@injectable
class ConnectivityCheckerHelper {
  Future<bool> checkConnectivity() async {
    final ConnectivityResult connectivityResult =
        await Connectivity().checkConnectivity();
    return _handleResult(connectivityResult);
  }

  static Stream<ConnectivityResult> listenToConnectivityChanged() {
    return Connectivity().onConnectivityChanged;
  }

  bool _handleResult(ConnectivityResult connectivityResult) {
    final bool connected;
    if (connectivityResult == ConnectivityResult.mobile ||
        connectivityResult == ConnectivityResult.wifi) {
      connected = true;
    } else {
      connected = false;
    }

    return connected;
  }
}

Enter fullscreen mode Exit fullscreen mode

In the previous class, two functions were created: 'checkConnectivity()', which returns true if the user is connected and false otherwise, and 'listenToConnectivityChanged()', which listens to any changes in the connection status.

Implementing Data Sources

Now that the 'ConnectivityCheckerHelper' class has been created, it's time to implement our two data sources.

Remote Data Source

The data retrieved from the network source is represented by the 'Remote Data Source'. Its implementation can be found in the 'data/datasources/remote_datasource' directory, consisting of two files: the data source contract and its implementation:

Image description

The interface of the WeatherRemoteDataSource will be nearly identical to that of the Repository, with two methods: getWeatherDataByCoordinates and getWeatherDataByCity. As previously noted, the return type of these methods will be based on the response models rather than the entities, therefore each data source method will return the created WeatherInfoResponseModel (converted from JSON) using the shared ApiResultModel.


abstract class WeatherRemoteDataSource {
  Future<ApiResultModel<WeatherInfoResponseModel?>> getWeatherDataByCoordinates(
      {WeatherByCoordinatesRequestModel? weatherByCoordinatesRequestModel});

  Future<ApiResultModel<WeatherInfoResponseModel?>> getWeatherDataByCity(
      {String? cityName});
}
Enter fullscreen mode Exit fullscreen mode

I implemented the new interface by creating a class called WeatherRemoteDataSourceImpl. This class will use HTTP requests to communicate with the server and obtain the needed data.

So, to maintain clean code and follow the DRY principle, I created a helper class that manages the creation of the HTTP client and provides generic HTTP request methods. The helper class can be found in the core/utils/helpers/api_call_helper directory:

Image description

And for the ApiCallHelper class will look like this:

@injectable
class ApiCallHelper {
  ApiCallHelper(this.connectivityCheckerHelper);

  final ConnectivityCheckerHelper connectivityCheckerHelper;
  final String? baseUrl = devBaseUrl;

  Map<String, String> _sharedDefaultHeader = <String, String>{};

  Future<void> initSharedDefaultHeader(
      [String contentValue = contentTypeValue]) async {
    _sharedDefaultHeader = <String, String>{};
    _sharedDefaultHeader.addAll(<String, String>{
      contentTypeKey: contentValue,
    });
  }

  Future<bool> _getConnectionState() async {
    final bool _result = await connectivityCheckerHelper.checkConnectivity();
    return _result;
  }

  ///@[params] should be added to the url as the api params
  Future<ApiResultModel<http.Response>> getWS({
    required String uri,
    Map<String, String> headers = const <String, String>{},
    Map<String, dynamic> params = const <String, dynamic>{},
  }) async {
    await initSharedDefaultHeader();
    _sharedDefaultHeader.addAll(headers);
    if (await _getConnectionState()) {
      try {
        final String _url = '$baseUrl$uri';
        final http.Response response = await http
            .get(_url.parseUri(params: params), headers: _sharedDefaultHeader)
            .timeout(timeOutDuration);
        if (response.statusCode >= 200 && response.statusCode < 300) {
          return ApiResultModel<http.Response>.success(data: response);
        } else {
          return ApiResultModel<http.Response>.failure(
            errorResultEntity: ErrorResultModel(
              message: response.reasonPhrase,
              statusCode: response.statusCode,
            ),
          );
        }
      } on TimeoutException catch (_) {
        return const ApiResultModel<http.Response>.failure(
          errorResultEntity: ErrorResultModel(
            message: commonErrorUnexpectedMessage,
            statusCode: timeoutRequestStatusCode,
          ),
        );
      } on IOException catch (_) {
        throw CustomConnectionException(
          exceptionMessage: commonConnectionFailedMessage,
          exceptionCode: ioExceptionStatusCode,
        );
      }
    } else {
      throw CustomConnectionException(
        exceptionMessage: commonConnectionFailedMessage,
        exceptionCode: ioExceptionStatusCode,
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

1. The ApiCallHelper class will utilize the previously created ConnectivityCheckerHelper, which will be injected through the constructor.
2. The first method I wrote is initSharedDefaultHeader, which will set the default configuration for each request header. For example, I specified application/Json as the default header content type.
3. Second, using the connection helper, I wrote a private method called getConnectionState that returns whether or not the user is connected.
4. The getWS function, which represents the HTTP GET method, is the file's last function. Its usage requires the specification of the request URI, and it also allows for the inclusion of optional extra headers or request parameters. This function returns an HTTP response utilizing the ApiResultModel sealed class. When the response has a 2xx status code, the function returns a success state along with the received data. If the response returns a different status code, the function returns a failure state accompanied by error information. To handle exceptions, particularly network exceptions, the function throws a CustomConnectionException that holds the custom exception message.

We may use the same logic to create the other HTTP requests, but our application only needs the GET method. Now that all of the dependencies have been established, it is time to write the remote datasource implementation class:

@Injectable(as: WeatherRemoteDataSource)
class WeatherRemoteDataSourceImpl implements WeatherRemoteDataSource {
  WeatherRemoteDataSourceImpl(this._apiCallHelper);

  final ApiCallHelper _apiCallHelper;

  @override
  Future<ApiResultModel<WeatherInfoResponseModel?>> getWeatherDataByCity(
      {String? cityName}) async {
    try {
      final ApiResultModel<Response> _result = await _apiCallHelper.getWS(
          uri: getWeatherDetails,
          params: <String, dynamic>{
            cityNameKey: cityName,
            appIdKey: appIdValue
          });
      return _result.when(
        success: (Response response) {
          return ApiResultModel<WeatherInfoResponseModel?>.success(
            data: WeatherInfoResponseModel.fromJson(
              response.decodeJson(),
            ),
          );
        },
        failure: (ErrorResultModel errorModel) {
          return ApiResultModel<WeatherInfoResponseModel?>.failure(
              errorResultEntity: errorModel);
        },
      );
    } on CustomConnectionException catch (exception) {
      throw CustomConnectionException(
        exceptionMessage: exception.exceptionMessage,
        exceptionCode: exception.exceptionCode,
      );
    }
  }

  @override
  Future<ApiResultModel<WeatherInfoResponseModel?>> getWeatherDataByCoordinates(
      {WeatherByCoordinatesRequestModel?
          weatherByCoordinatesRequestModel}) async {
    try {
      final ApiResultModel<Response> _result = await _apiCallHelper
          .getWS(uri: getWeatherDetails, params: <String, dynamic>{
        latitudeKey: weatherByCoordinatesRequestModel?.lat,
        longitudeKey: weatherByCoordinatesRequestModel?.lon,
        appIdKey: appIdValue,
      });
      return _result.when(
        success: (Response response) {
          return ApiResultModel<WeatherInfoResponseModel?>.success(
            data: WeatherInfoResponseModel.fromJson(
              response.decodeJson(),
            ),
          );
        },
        failure: (ErrorResultModel errorModel) {
          return ApiResultModel<WeatherInfoResponseModel?>.failure(
              errorResultEntity: errorModel);
        },
      );
    } on CustomConnectionException catch (exception) {
      throw CustomConnectionException(
        exceptionMessage: exception.exceptionMessage,
        exceptionCode: exception.exceptionCode,
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The WeatherRemoteDataSourceImpl class implements the remote data source contract by overriding its methods. To obtain its response model, each method calls the getWS function given by the injected ApiCallHelper object. In addition, if the user is disconnected from the Internet, a CustomConnectionException is thrown.

Local Data Source

Until now, the methods we created were always concerned with retrieving data from the server. And now it's time to write the WeatherLocalDataSourceImpl class, which is in charge of caching weather data in a local database, retrieving all local weathers, and getting the most recently saved weather data. Additionally, this class will include an abstract class and other classe for implementation. But before that, we will start by creating our local database.

Local Database

Image description

In Flutter, we have numerous options for a local database, but in our situation, I chose the ObjectBox database, which is an excellent fit for our needs. This database consumes the least amount of CPU, memory, and battery power, ensuring that your program is not only effective but also long-lasting:

1. The objectbox plugin is a Flutter database that allows for super-fast NoSQL ACID (atomicity, consistency, isolation, and durability) object persistence.
2. The objectbox_flutter_libs plugin, which offers the platform's native ObjectBox library.
3. The objectbox_generator plugin is used to generate code for our database.

The following step involves adding the local database class, located in the 'datasources/local_datasource/local_database' directory:

class AppLocalDatabase {
  static Store? _store;

  /// Create an instance of ObjectBox to use throughout the app.
  static Future<AppLocalDatabase> create() async {
    final Directory docsDir = await getApplicationDocumentsDirectory();
    _store = await openStore(
      directory: p.join(docsDir.path, 'objectbox'),
    );
    return AppLocalDatabase();
  }

  int? insert<T>(T object) {
    final Box<T>? box = _store?.box<T>();
    return box?.put(object);
  }

  Future<List<T>?> getAll<T>() async {
    final Box<T>? box = _store?.box<T>();
    return box?.getAll();
  }
}
Enter fullscreen mode Exit fullscreen mode

1. First, I created a static private object of type Store to serve as the starting point for implementing ObjectBox. It is the database's direct interface and also controls boxes. In reality, a Box instance provides access to items of a specific type, similar to a table in a standard database.
2. The second step is to open my database and keep it open while my app runs. To do this, I wrote a static create method that returns my AppLocalDatabase object, which will be injected, allowing my app to use just one instance.
3. Finally, I added two more functions: insert, which inserts a new object into the database, and getAll, which retrieves all of the stored weather data.

Local Data Source Implementation

Now we'll go on to the WeatherLocalDataSourceImpl class, which has the injected AppLocalDatabase object in its constructor. This class implements three functions: CacheWeatherInfo, that caches current weather data, getLastWeatherInfo, which returns the most recently saved WeatherInfoEntity object, and getAllLocalWeatherInfo, which retrieves all stored weather data:

@Injectable(as: WeatherLocalDataSource)
class WeatherRemoteDataSourceImpl implements WeatherLocalDataSource {
  WeatherRemoteDataSourceImpl(this.appLocalDatabase);

  final AppLocalDatabase appLocalDatabase;

  @override
  void cacheWeatherInfo(WeatherInfoResponseModel? weatherInfoResponseModel) {
    final WeatherInfoEntity? weatherData =
        weatherInfoResponseModel?.mapToEntity();
    final WeatherInfoLocalEntity _localEntity = WeatherInfoLocalEntity(
      timezone: weatherData?.timezone,
      name: weatherData?.name,
      dt: weatherData?.dt,
      visibility: weatherData?.visibility,
    );
    _localEntity.sys.target = SunsetSunriseLocalEntity(
      sunset: weatherData?.sys?.sunset,
      country: weatherData?.sys?.country,
      type: weatherData?.sys?.type,
      sunrise: weatherData?.sys?.sunrise,
    );
    _localEntity.weather.addAll(
      <WeatherDescriptionLocalEntity>[
        WeatherDescriptionLocalEntity(
          main: weatherData?.weather?[0]?.main,
          icon: weatherData?.weather?[0]?.icon,
          description: weatherData?.weather?[0]?.description,
        ),
      ],
    );
    _localEntity.main.target = MainWeatherInfoLocalEntity(
      tempMin: weatherData?.main?.tempMin,
      tempMax: weatherData?.main?.tempMax,
      pressure: weatherData?.main?.pressure,
      humidity: weatherData?.main?.humidity,
      feelsLike: weatherData?.main?.feelsLike,
      temp: weatherData?.main?.temp,
    );
    _localEntity.wind.target = WindInfoLocalEntity(
      speed: weatherData?.wind?.speed,
      deg: weatherData?.wind?.deg,
    );
    _localEntity.clouds.target = CloudsLocalEntity(
      all: weatherData?.clouds?.all,
    );
    _localEntity.weatherTheme.target = WeatherThemeLocalEntity(
      firstColorHex: weatherData?.weatherTheme?.firstColor?.value,
      secondColorHex: weatherData?.weatherTheme?.secondColor?.value,
    );
    appLocalDatabase.insert<WeatherInfoLocalEntity>(_localEntity);
  }

  @override
  Future<WeatherInfoEntity?> getLastWeatherInfo() async {
    final List<WeatherInfoLocalEntity>? weatherInfoLocalData =
        await appLocalDatabase.getAll<WeatherInfoLocalEntity>();
    if ((weatherInfoLocalData?.length ?? 0) > 0) {
      final WeatherInfoEntity? _lastInfoData =
          weatherInfoLocalData?.last.mapToEntity();
      return _lastInfoData;
    }
    return null;
  }

  @override
  Future<List<WeatherInfoEntity?>?> getAllLocalWeathers() async {
    final List<WeatherInfoLocalEntity>? weatherInfoLocalData =
        await appLocalDatabase.getAll<WeatherInfoLocalEntity>();
    if ((weatherInfoLocalData?.length ?? 0) > 0) {
      final List<WeatherInfoEntity?>? _localData = weatherInfoLocalData
          ?.map((WeatherInfoLocalEntity element) => element.mapToEntity())
          .toList();
      return _localData;
    }
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementing the Repository

All of the contracts required for the repository's dependencies have been created.These dependencies include local and remote data sources.

Image description

The WeatherRepositoryImpl class will implement the functions specified in its contract, such as obtaining weather data for a specific city via the getWeatherDataByCity method, retrieving weather information based on coordinates via the getWeatherDataByCoordinates method, and accessing all local weather information via the getAllLocalWeathers method.

Moreover, it's important to remember that the primary function of the Repository is:

Retrieve up-to-date data from the API when an internet connection is available and to access cached data when the user is offline.

Additionally, this class is tasked with storing the data retrieved from the server in the ObjectBox database. To achieve this, I have created two private functions: getLastLocalWeatherInfo which is invoked when a CustomConnectionException is thrown, and cacheLocalData which is utilized when the getWeatherDataByCity and getWeatherDataByCoordinates functions successfully retrieve data:

@Injectable(as: WeatherRepository)
class WeatherRepositoryImpl implements WeatherRepository {
  WeatherRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
  });

  final WeatherRemoteDataSource remoteDataSource;

  final WeatherLocalDataSource localDataSource;

  @override
  Future<ApiResultModel<WeatherInfoEntity?>> getWeatherDataByCity(
      {String? cityName}) async {
    try {
      final ApiResultModel<WeatherInfoResponseModel?> _result =
          await remoteDataSource.getWeatherDataByCity(cityName: cityName);
      return _result.when(
        success: (WeatherInfoResponseModel? weatherInfoResponseModel) async {
          if (weatherInfoResponseModel != null) {
            _cacheLocalData(weatherInfoResponseModel);
          }
          return ApiResultModel<WeatherInfoEntity?>.success(
            data: weatherInfoResponseModel?.mapToEntity(),
          );
        },
        failure: (ErrorResultModel errorResultModel) {
          return ApiResultModel<WeatherInfoEntity>.failure(
            errorResultEntity: errorResultModel,
          );
        },
      );
    } on CustomConnectionException catch (_) {
      final ApiResultModel<WeatherInfoEntity?> _result =
          await _getLastLocalWeatherInfo();
      return _result;
    }
  }

  Future<ApiResultModel<WeatherInfoEntity?>> _getLastLocalWeatherInfo() async {
    final WeatherInfoEntity? _localResult =
        await localDataSource.getLastWeatherInfo();
    return ApiResultModel<WeatherInfoEntity?>.success(
      data: _localResult,
    );
  }

  @override
  Future<ApiResultModel<WeatherInfoEntity?>> getWeatherDataByCoordinates(
      {WeatherByCoordinatesRequestModel?
          weatherByCoordinatesRequestModel}) async {
    try {
      final ApiResultModel<WeatherInfoResponseModel?> _result =
          await remoteDataSource.getWeatherDataByCoordinates(
              weatherByCoordinatesRequestModel:
                  weatherByCoordinatesRequestModel);
      return _result.when(
        success: (WeatherInfoResponseModel? weatherInfoResponseModel) async {
          if (weatherInfoResponseModel != null) {
            _cacheLocalData(weatherInfoResponseModel);
          }
          return ApiResultModel<WeatherInfoEntity?>.success(
            data: weatherInfoResponseModel?.mapToEntity(),
          );
        },
        failure: (ErrorResultModel errorResultModel) {
          return ApiResultModel<WeatherInfoEntity>.failure(
            errorResultEntity: errorResultModel,
          );
        },
      );
    } on CustomConnectionException catch (_) {
      final ApiResultModel<WeatherInfoEntity?> _result =
          await _getLastLocalWeatherInfo();
      return _result;
    }
  }

  void _cacheLocalData(WeatherInfoResponseModel? weatherData) {
    localDataSource.cacheWeatherInfo(weatherData);
  }

  @override
  Future<ApiResultModel<List<WeatherInfoEntity?>?>>
      getAllLocalWeathers() async {
    final List<WeatherInfoEntity?>? _result =
        await localDataSource.getAllLocalWeathers();
    return ApiResultModel<List<WeatherInfoEntity?>?>.success(
      data: _result,
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Next up

Our progress is remarkable, as we have successfully completed the entire data layer, and the domain layer is already implemented. Now, the final step is to focus on the presentation layer: Next

Top comments (1)

Collapse
 
fahmidul profile image
fahmidul

Hi @marwamejri
Thank you for providing such a valuable series of articles. I'm currently working on implementing the Clean Code Architecture and, amidst the learning process, I've encountered a couple of uncertainties, particularly concerning the model and entity classes.

  1. You suggest using a JSON serializer, but some API structures can be significantly more complex than the example provided. Manually converting such complex structures into a format manageable by a JSON serializer can be challenging. In contrast, using JSON-to-Dart converter tools (javiercbk.github.io/json_to_dart/) can be more efficient, especially when dealing with intricate nested object structures. These tools can generate Dart classes swiftly, reducing the likelihood of errors, especially when dealing with changes in nested object structures.

  2. Consider a scenario where the response structure of an API is highly complex, with multiple nested objects. After converting it to a Dart model, further work is required to convert it into an entity using a mapper. Instead of returning the entity class, why not consider returning the model class directly? While this approach may break some conventional boundaries, it can save a significant amount of time, especially when dealing with complex API structures that are unlikely to change frequently.