DEV Community

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

Posted on

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

This post is the final part of a tutorial series for Flutter App!.

You can find the completed project repository here

Presentation Layer

The presentation layer is in charge of managing the user interface and displaying information to the user. It serves as a bridge between the domain/business logic layer and the outside world. This layer is divided into three sections: the 'widgets' folder, the 'view' folder, and the 'presentation logic holder', which is implemented in our case using the Provider dependency.

Provider is a state management solution for Flutter applications that allows for simple and efficient management of application state. It is based on the InheritedWidget and ChangeNotifier APIs provided by the Flutter framework.
In fact, ChangeNotifier is a simple implementation of the Observer pattern, where the ChangeNotifier acts as the subject and notifies any registered observers (typically widgets) when there has been a change in state.
To read more about a provider, see its documentation.

Responsive UI

Image description

Since our application targets both iOS and Android platforms, and each platform has a multitude of screen sizes, it is essential to design responsive interfaces that can adapt to different screen sizes. By designing for responsiveness, we can ensure that our application will look and function properly on a wide range of devices.

Rather than designing separate interfaces for each screen size, we can use responsive design techniques to create flexible layouts that adjust to the available screen space. This can be achieved using widgets such as LayoutBuilder, AspectRatio, FittedBox, or FractionallySizedBox.

Image description

For our weather app, I have decided to use the MediaQuery.of() method to obtain the dimensions of the user's device. With this information, I can dynamically adjust the size of my app's widgets to ensure that they fit properly on the screen.

Applied to the Weather App

To implement the previous approach, I have created a new class called ResponsiveConfig. This class is located in the 'core/utils/helpers/responsive_ui_helper' directory:

@singleton
class ResponsiveUiConfig {
  late MediaQueryData _mediaQueryData;
  late double _screenWidth;
  late double _screenHeight;

  double get screenWidth => _screenWidth;

  double get screenHeight => _screenHeight;

  ///this method initialize all of our attributes
  void initialize(BuildContext context) {
    _mediaQueryData = MediaQuery.of(context);
    _screenWidth = _mediaQueryData.size.width;
    _screenHeight = _mediaQueryData.size.height;
  }

  double setWidth(num value) => _screenWidth * (value / 375);

  double setHeight(num value) => _screenHeight * (value / 812);
}
Enter fullscreen mode Exit fullscreen mode

The ResponsiveConfig class includes an initialize method that uses the MediaQuery.of() function to determine the width and height of the user's device. With these numbers, I constructed the setWidth() function, which calculates the proper size of widgets based on the screen width. The setWidth() function takes two parameters: the screen width and the desired widget size. The algorithm for calculating widget size is screenWidth * (value / 375), where the reference value of 375 is based on the minimum screen width of an iOS device. As a result, for a device with a width of 375, the widget width will be the supplied value. For devices with wider screens, the widget width will increase proportionally.
Following the same approach, I added the setHeight() function to calculate the appropriate height value of widgets based on the screen height.
And to make the setWidth() and setHeight() functions easier to use, I added a new file named size_extension to the 'core/utils/helpers/extension_functions' folder:

extension ExtensionsOnNum on num {
  static final ResponsiveUiConfig _responsiveUiConfig = locator<ResponsiveUiConfig>();

  double get w => _responsiveUiConfig.setWidth(this);

  double get h => _responsiveUiConfig.setHeight(this);
}
Enter fullscreen mode Exit fullscreen mode

There are two extension functions in this file: w() and h(). The w() function is a num data type extension function that refers to the setWidth() function. Similarly, the h() function is a num data type extension function that refers to the setHeight() function. We can simply set the width and height values of widgets within our app by using these extension functions. For example, we can adjust a widget's width to 13 by calling 13.w and its height to 20 by calling 20.h.

Additionally, I created a new base component called BaseResponsiveWidget:

class BaseResponsiveWidget extends StatelessWidget {
  const BaseResponsiveWidget({
    Key? key,
    required this.buildWidget,
    this.initializeConfig=false,
  }) : super(key: key);

  final Widget Function(
      BuildContext context, ResponsiveUiConfig responsiveUiConfig) buildWidget;
  final bool initializeConfig;

  @override
  Widget build(BuildContext context) {
    final ResponsiveUiConfig responsiveUiConfig = locator<ResponsiveUiConfig>();
    if (initializeConfig) {
      responsiveUiConfig.initialize(context);
    }
    return buildWidget(context, responsiveUiConfig);
  }
}
Enter fullscreen mode Exit fullscreen mode

This widget takes two parameters: a buildWidget function that returns a widget and an initializeConfig parameter of boolean type. The BaseResponsiveWidget is intended to initialize the shared screen width and height by calling the initialize() function of the ResponsiveUiConfig class. The buildWidget function is responsible for returning the widget tree with a reference to the ResponsiveUiConfig class in case you need to set a widget size by percent based on the screen width or height or if your widget takes up all the available width or height.

Creating a base view model

It is time to implement the view model class using the provider dependency. Since the view model acts as a bridge between the domain and presentation layers, its implementation will depend on a specific use case. Also, the view model class extends the ChangeNotifier class provided by the Provider dependency, allowing for efficient state management.

To further improve the solution, it is a good idea to establish a basic view model class that can be extended by each individual view model. This approach promotes code reusability and consistency across different view models. The base view model class can define common methods and properties, such as loading state management, error handling, and data retrieval methods:

class BaseViewModel extends ChangeNotifier {
  final StreamController<bool> _toggleLoading = StreamController<bool>.broadcast();

  StreamController<bool> get toggleLoading => _toggleLoading;

  Future<ApiResultState<Type>?> executeParamsUseCase<Type, Params>(
      {required BaseParamsUseCase<Type, Params> useCase, Params? query}) async {
    showLoadingIndicator(true);
    final ApiResultModel<Type> _apiResult = await useCase(query);
    return _apiResult.when(
      success: (Type data) {
        showLoadingIndicator(false);
        return ApiResultState<Type>.data(data: data);
      },
      failure: (ErrorResultModel errorResultEntity) {
        showLoadingIndicator(false);
        return ApiResultState<Type>.error(
          errorResultModel: ErrorResultModel(
            message: errorResultEntity.message,
            statusCode: errorResultEntity.statusCode,
          ),
        );
      },
    );
  }

  void showLoadingIndicator(bool show) {
    _toggleLoading.add(show);
  }

  void onDispose() {}

  @override
  void dispose() {
    _toggleLoading.close();
    onDispose();
    super.dispose();
  }

}
Enter fullscreen mode Exit fullscreen mode

The BaseViewModel class includes a StreamController attribute named toggleLoading, which is private and responsible for holding the loading state, as well as three other methods. To reduce code duplication, I created the executeParamsUseCase method. This method accepts a use case to execute as well as an optional query and uses the showLoadingIndicator function to switch the loading state depending on the success or failure of the API request.

StreamController: a StreamController allows you to create a Stream (a sequence of asynchronous events) and then add events to it over time. You can also listen to the stream to receive these events as they occur. This makes it a useful tool for managing state in a Flutter application, since you can use it to notify your widgets of changes as they happen.

The final method in the BaseViewModel class is onDispose(), which is responsible for closing any active StreamController streams. This is significant since ChangeNotifier is not lifecycle-aware, so we must manually close any active streams when the view model is disposed of.

Implementing the base view model

Image description

As an example, let's take the case of the weather details interface. In the presentation layer, a new folder named weather_details was created, which contains the view model class and two additional folders: one for the view and the other for the widgets used to build the view.

This interface is in charge of retrieving weather information based on coordinates. As a result, the corresponding view model class will be dependent on the previously developed GetWeatherDataByCoordinates use case:


@injectable
class WeatherDetailsViewModel extends BaseViewModel {
  WeatherDetailsViewModel(this.getWeatherDataByCoordinates);
  final GetWeatherDataByCoordinates getWeatherDataByCoordinates;

Enter fullscreen mode Exit fullscreen mode

The WeatherDetailsViewModel class constructor includes the GetWeatherDataByCoordinates use case. To execute the use case, the class extends the BaseViewModel and calls the executeParamsUseCase function with a query of type WeatherByCoordinatesRequestModel. In addition, the class includes a new attribute named weatherResult of type StreamController to hold the received API response. Finally, the onDispose() method is overridden to close the stream listener:

@injectable
class WeatherDetailsViewModel extends BaseViewModel {
  WeatherDetailsViewModel(this.getWeatherDataByCoordinates);

  final GetWeatherDataByCoordinates getWeatherDataByCoordinates;

  final StreamController<ApiResultState<WeatherInfoEntity?>?> _weatherResult =
      StreamController<ApiResultState<WeatherInfoEntity?>?>.broadcast();

  StreamController<ApiResultState<WeatherInfoEntity?>?> get weatherResult =>
      _weatherResult;

  Future<void> getWeatherByCoordinates(
      {WeatherByCoordinatesRequestModel?
          weatherByCoordinatesRequestModel}) async {
    final ApiResultState<WeatherInfoEntity?>? _result =
        await executeParamsUseCase(
      useCase: getWeatherDataByCoordinates,
      query: weatherByCoordinatesRequestModel,
    );
    _weatherResult.add(_result);
  }

  @override
  void onDispose() {
    super.onDispose();
    _weatherResult.close();
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating the view

As of now, all that is left to do is use the WeatherDetailsViewModel that has previously been established to develop an interface for our weather details. In order to enhance our interfaces, I have developed a generic class named BaseViewModelView that manages view model initialization, connectivity state, and the loader widget switching when an API is called:

class BaseViewModelView<T> extends StatefulWidget {
  const BaseViewModelView({
    Key? key,
    this.onInitState,
    required this.buildWidget,
  }) : super(key: key);
  final void Function(T provider)? onInitState;
  final Widget Function(T provider) buildWidget;

  @override
  State<BaseViewModelView<T>> createState() => _BaseViewModelViewState<T>();
}

class _BaseViewModelViewState<T> extends State<BaseViewModelView<T>> {
  bool _showLoader = false;

  @override
  void initState() {
    super.initState();
    final T _provider = Provider.of<T>(context, listen: false);
    checkInternetAvailability();
    toggleLoadingWidget(_provider);
    if (widget.onInitState != null) {
      widget.onInitState!(_provider);
    }
  }

  void checkInternetAvailability() {
    ConnectivityCheckerHelper.listenToConnectivityChanged().listen(
      (ConnectivityResult connectivityResult) {
        if (connectivityResult == ConnectivityResult.none) {
          if (!mounted) {
            return;
          }
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(
              content: Text(commonConnectionFailedMessage),
            ),
          );
        }
      },
    );
  }

  void toggleLoadingWidget(T provider) {
    (provider as BaseViewModel).toggleLoading.stream.listen((bool show) {
      if (!mounted) {
        return;
      }
      setState(() {
        _showLoader = show;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Consumer<T>(
      builder: (BuildContext context, T provider, Widget? child) {
        return Stack(
          alignment: Alignment.center,
          children: <Widget>[
            widget.buildWidget(provider),
            if (_showLoader)
              BaseResponsiveWidget(
                buildWidget: (BuildContext context,
                    ResponsiveUiConfig responsiveUiConfig) {
                  return AnimatedOpacity(
                    opacity: 1,
                    duration: const Duration(milliseconds: 200),
                    child: Container(
                      width: responsiveUiConfig.screenWidth,
                      height: responsiveUiConfig.screenHeight,
                      color: Colors.transparent,
                      child: Center(
                        child: Container(
                          decoration: BoxDecoration(
                            shape: BoxShape.circle,
                            color: primaryColor,
                          ),
                          padding: EdgeInsets.all(
                            15.w,
                          ),
                          width: 70.w,
                          height: 70.w,
                          child: CircularProgressIndicator(
                            color: lightColor,
                          ),
                        ),
                      ),
                    ),
                  );
                },
              ),
          ],
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The BaseViewModelView class is a generic class in which 'T' indicates the ViewModel type connected with the View. This class's arguments are two functions. One is named onInitState, and it accepts as an argument your view model instance. This function is called by the initState method, and it can be used to initialize the screen state or any other necessary initialization before producing the screen. The other function, buildWidget, is called in the build function and returns a widget.
Additionally, the initState() method invokes two additional methods: checkInternetAvailability() and toggleLoadingWidget():

The shared ConnectivityCheckerHelper class is used by the checkInternetAvailability() method to listen for changes in connectivity. If connectivity is lost, a snack bar with the text 'Please check your Internet connection' appears. This method avoids the need to manually check for connectivity on each screen.

The toggleLoadingWidget() function uses the BaseViewModel's toggleLoadingStream to determine whether the loader widget should be displayed or hidden.

Finally, in the build() function, I returned a Consumer widget, which listens to changes in the ViewModel object and rebuilds the widget tree accordingly. The Consumer widget returns a Stack widget, which contains the previously defined buildWidget() function to render the desired UI. On top of this, I added a loader widget, which is conditionally displayed based on changes in the ViewModel object's toggleLoading stream. By using this approach, the loader widget is displayed whenever an API call is performed, and it is hidden once the call is completed.

Consumer: a Consumer is a widget that rebuilds its child widget whenever the value passed into its builder function changes. It also allows obtaining a value from a provider when we don't have a BuildContext that is a descendant of provider, and therefore cannot use Provider.of.

And now the time to put all of our past work into effect has finally come!

Image description

The final step is to create the WeatherDetails interface, which will be a stateful widget due to the API call. To obtain weather information during initialization, we will use the onInitState method given by the BaseViewModelViewWeatherDetailsViewModel> widget. In addition, to ensure a responsive UI, we will use the BaseResponsiveWidget in the widget tree. The resultant interface will appear as follows:

class WeatherDetailsView extends StatefulWidget {
  const WeatherDetailsView({
    Key? key,
    this.weatherInfoEntity,
  }) : super(key: key);
  final WeatherInfoEntity? weatherInfoEntity;

  @override
  State<WeatherDetailsView> createState() => _WeatherDetailsViewState();
}

class _WeatherDetailsViewState extends State<WeatherDetailsView> {
  WeatherInfoEntity? _result;
  bool? _isSuccess;

  Future<void> _getWeatherData(WeatherDetailsViewModel provider) async {
    await provider.getWeatherByCoordinates(
      weatherByCoordinatesRequestModel: WeatherByCoordinatesRequestModel(
        lon: 10.634422,
        lat: 35.821430,
      ),
    );
  }

  Widget _getWidget(ResponsiveUiConfig responsiveUiConfig) {
    if (_isSuccess == false) {
      return ListView.builder(
        itemCount: 1,
        itemBuilder: (BuildContext context, int index) {
          return SizedBox(
            height: responsiveUiConfig.screenHeight,
            child: Center(
              child: Lottie.asset(
                'assets/lottie_animation.json',
              ),
            ),
          );
        },
      );
    } else {
      return Container();
    }
  }

  @override
  void initState() {
    super.initState();
    if (widget.weatherInfoEntity != null) {
      _result = widget.weatherInfoEntity;
    }
  }

  @override
  Widget build(BuildContext context) {
    final StackRouter appRouter = AutoRouter.of(context);
    return Scaffold(
      backgroundColor: lightColor,
      body: SafeArea(
        child: BaseViewModelView<WeatherDetailsViewModel>(
          onInitState: (WeatherDetailsViewModel provider) async {
            provider.weatherResult.stream.listen(
              (ApiResultState<WeatherInfoEntity?>? result) {
                result?.when(
                  data: (WeatherInfoEntity? data) {
                    if (!mounted) {
                      return;
                    }
                    setState(() {
                      _result = data;
                      _isSuccess = data != null;
                    });
                  },
                  error: (ErrorResultModel error) {
                    print(error);
                  },
                );
              },
            );
            if (widget.weatherInfoEntity == null) {
              await _getWeatherData(provider);
            }
          },
          buildWidget: (WeatherDetailsViewModel provider) {
            return BaseResponsiveWidget(
              initializeConfig: true,
              buildWidget: (BuildContext context,
                  ResponsiveUiConfig responsiveUiConfig) {
                return RefreshIndicator(
                  backgroundColor: lightColor,
                  color: primaryColor,
                  triggerMode: RefreshIndicatorTriggerMode.anywhere,
                  strokeWidth: 2.w,
                  onRefresh: () async {
                    _getWeatherData(provider);
                  },
                  child: Stack(
                    alignment: Alignment.bottomCenter,
                    children: <Widget>[
                      if (_result != null)
                        Container(
                          width: responsiveUiConfig.screenWidth,
                          decoration: BoxDecoration(
                            gradient: LinearGradient(
                              begin: Alignment.topCenter,
                              end: Alignment.bottomCenter,
                              colors: <Color>[
                                _result?.weatherTheme?.secondColor ?? lightBlue,
                                _result?.weatherTheme?.firstColor ?? blue,
                              ],
                            ),
                          ),
                          child: ListView.builder(
                            itemCount: 5,
                            primary: false,
                            physics: const BouncingScrollPhysics(),
                            itemBuilder: (BuildContext context, int index) {
                              switch (index) {
                                case 0:
                                  {
                                    return WeatherDetailsHeader(
                                      locationName: _result?.name,
                                      date: _result?.dt,
                                    );
                                  }
                                case 1:
                                  {
                                    return Align(
                                      alignment: Alignment.center,
                                      child: Container(
                                        padding: EdgeInsets.symmetric(
                                          vertical: 20.h,
                                        ),
                                        child: Image.asset(
                                          'assets/${_result?.weather?[0]?.icon}.png',
                                          height: 150.h,
                                          width: 150.w,
                                          alignment: Alignment.center,
                                        ),
                                      ),
                                    );
                                  }
                                case 2:
                                  {
                                    return WeatherDetailsDataWidget(
                                      weatherDescription:
                                          _result?.weather?[0]?.description,
                                      weatherVisibility: _result?.visibility,
                                      mainWeatherInfoEntity: _result?.main,
                                    );
                                  }
                                case 3:
                                  {
                                    return WeatherDetailsBoxList(
                                      sunsetSunriseEntity: _result?.sys,
                                      windInfoEntity: _result?.wind,
                                    );
                                  }
                                default:
                                  {
                                    return SizedBox(
                                      height: 90.h,
                                    );
                                  }
                              }
                            },
                          ),
                        )
                      else
                        _getWidget(responsiveUiConfig),
                      Positioned(
                        bottom: 0,
                        child: BottomNavigationBarWidget(
                          navigateToAddScreen: () {
                            appRouter.push(
                              const AddNewCityViewRoute(),
                            );
                          },
                        ),
                      ),
                    ],
                  ),
                );
              },
            );
          },
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Throughout this article, we've taken an idea and turned it into a functioning weather app. While the app's functionality may be on the simpler side, what's important are the principles behind it. I've demonstrated the importance of breaking your architecture down into layers and how they communicate with one another. Additionally, I've created generic components that make it easier for developers to create their interfaces.

In addition to what we've covered, I've also utilized other important dependencies such as injectable, auto_route, and build_runner for building my app. While I won't delve into them in this article, I plan on writing about them in the future.
Thank you all for reading, and I hope you've learned something valuable.
Happy coding until next time!

Image description

Top comments (1)

Collapse
 
almatarm profile image
Mufeed H AlMatar

Thank you, Marwa for this tutorial. It glues everything together.