Mastering Clean Architecture & Repository Pattern in Flutter

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
In this video, I will guide you  through the process of building a   fully functional Flutter application. The  app will consume a REST API to retrieve a   list of upcoming movies. By following this  tutorial, you will learn the following: - How to use SOLID patterns  to consume a remote API.  - How to implement the Repository Pattern  to create a local cache of data retrieved   from the API in a local database. - How to develop a mechanism to   detect new data from the API, and flush the  local cache to download the updated data.  - How to create an infinite scroll list to  display the retrieved data as the user scrolls. To implement the functionalities  I have described, we will be use   some powerful libraries that will enable us  to reduce development time significantly: - To create our domain layer models,  we will be using the "freezed" library,   which works seamlessly with Dart classes. - We will simplify the process of defining our   data layer models using "json_serializable". - For making requests to the remote API,   we will be using the "dio" library, which  streamlines the process of consuming a REST API.  - We will be using "sqflite" for the database and  learn how to implement the Repository Pattern to   adhere to Clean Architecture standards. - Finally, for the infinite scroll   functionality, we will use the  "infinite_scroll_pagination" package. This tutorial is designed for individuals who  are new to Dart and Flutter, but already have a   basic understanding of these technologies.  I will not be covering the fundamentals,   such as variables, functions, classes  or Flutter widgets. Therefore,   you should have a solid foundation in these  basic concepts before beginning this tutorial. I will be explaining the basics of Clean  Architecture applied to a Flutter application.   While I will not be strictly adhering to the  principles, I will be sharing the adaptation   that I have been using for many years in my  own apps and those of my clients. I encourage   you to observe closely and listen carefully  in order to improve your learning process.   The complete source code for this  app is available along with helpful   links to the tools that I will be  using in the video description. Let's get started with the tutorial! Let's begin by creating a Flutter application  for both Android and iOS. Once created, you can   open the project in your preferred IDE, either  Android Studio or VSCode. For me, I'll be using   Android Studio since it's the IDE that I'm most  familiar with and have been using for many years. The first step is to open the pubspec.yaml  file and remove all the default comment blocks.   I'll also remove the cupertino_icons  package since it's unnecessary. Next, I'll remove flutter_lints and instead  install the lint package. This step is optional,   and you can still follow this tutorial with  flutter_lints if you prefer. However, if you   choose to use lint like I did, you'll need to  modify the analysis_options.yaml file accordingly. To ensure that the configuration is correct, you  can run "flutter analyze". I'll also remove some   content from the test file to prevent it from  interfering with the output of "flutter analyze". Moving on, the first thing I'm going to do is to  create a model that will serve as a representation   of a movie. However, before doing that, let's take  a moment to examine the API that we'll be using.   You can find the link to this  page in the video description.   We'll be using an endpoint that  returns a list of upcoming movies.   If we take a look at the response JSON,  we can see the data that's returned.   For this tutorial, we'll only be using the movie's  identifier, title, image, and release date. One of the fundamental principles of a  SOLID architecture is that each layer   should have a distinct responsibility  and should be decoupled from the others.   This means that any changes made to one layer  should not affect the rest of the layers,   reducing the potential for failure  and improving maintainability. To achieve this, we'll create a movie model  that meets the requirements of our application   and aligns with the business rules that govern  it. This should be done independently of the   API response. Next, we'll create an entity  that accurately represents the data returned   by the API. Finally, we'll create a class that  converts the API entity into our domain model. One option to create the movie model is to use a  Dart class, but there are some utilities that are   not available by default in Dart, such as methods  to duplicate objects and comparison methods.   These utilities should be present  in any language. To address this,   I will use the freezed package, which  generates these utilities automatically. I will follow the installation commands provided  in the freezed documentation and create a domain   directory, with a models directory inside where  the domain models of my application will reside. Next, I'll create a file called movie.dart  and define the class with its properties.   Note that this class includes the @freezed  annotation and extends another class, among   other differences from a standard Dart class. This  is all explained in the freezed documentation. My Movie class will contain the movie identifier,  title, an optional image, and release date. Finally, we need to auto-generate the freezed  files to provide us with the additional functions.   I will document the autogenerate  command in the README file.   Running this command will result  in a new file being autogenerated   with all the extra functions I mentioned earlier. My next task is to create the network layer.  To achieve this, I will make a directory named   "data" and then create another directory inside it  named "network". Within the "network" directory,   I will create another directory named  "entity", which will host the network   models. These models are classes that precisely  represent the content of the remote API responses. To create the network model, I will  create a file named "movie_entity.dart".   I will utilize a web tool called "quicktype"   which auto-generates corresponding Dart  classes after pasting the expected JSON.   To proceed, I will visit the API page and copy  the response of the upcoming movies endpoint.   Click on "Test Endpoint" to get a response  and then copy and paste it into quicktype.   I will name the main class  "UpcomingMovies" and copy all   the generated code into the newly  created file "movie_entity.dart".   After that, I will remove the  properties that are not required   and all the "typename" properties and  their corresponding enums that I can see. I am going to create a new class responsible for  mapping the network models to my domain models.   To do this, I'll navigate to the network folder  using the terminal as it's more convenient for   me than using the interface sometimes. Then,  I'll create a file named network_mapper.dart. As explained before, the purpose of this class is  to achieve a clear separation of responsibilities   between the different layers of our application.  Our domain models will cater to the specific needs   of our application, while the network models will  accurately represent the data returned by the API. To make the code more intuitive, I'll  change the name of Result to MovieEntity. Before I begin mapping, I'll define an  exception directory in the domain where   I'll create a custom exception for mapping  errors. This exception will take two generics:   From and To, representing the classes we  want to convert from and to, respectively. Now, to perform the mapping, I'll  use a try-catch block and throw the   newly created MapperException  if an error occurs. This way,   we'll get precise information about the type of  mapping being performed when the error occurred. I'll also create another method  to convert lists of objects.   Inside the for loop, I'll define a try-catch  block to avoid suspending the mapping of the   entire list if one object fails. If this occurs,  I'll log the error using the logger package,   you can install it with this command. I'm going to create a class responsible for  handling API requests. To do this, I'll create   a directory called "client" inside the "network"  folder and a file called "api_client.dart". I'll   use the dio package to communicate with the API,  so I'll install it using the following command. First, I'll define the Dio variable and initialize  it the constructor. To communicate with the API,   we need its URL, as well as the apiKey and apiHost  parameters, which are specific authentication   parameters for the API being used. You can find  this information on the API page. I'll set up the   Dio object so that it includes these two pieces  of information in the header of each request.   Additionally, I'll add an interceptor to  log the requests made to the console so   we can better understand their content and timing. Next, I'll define a method called  "getUpcomingMovies()" to retrieve   a list of the upcoming movies. This method  will take two parameters: "page" and "limit",   which will limit the number of results  and indicate which page we want to load.   I'll implement this request  using the API documentation. I'll create a custom exception  to handle communication errors.   This exception will contain the status code  of the error and optionally an error message.   If the response from the request has an error code  (400 or higher), I'll throw a NetworkException.   If it has a status code but it's less than 400,   it means the request was successful, so  I'll return the UpcomingMovies class.   If it doesn't even have a status  code, something bad has happened,   so I'll throw a traditional exception. Finally, I'll make some changes  to the "movies_entity.dart" file,   installing json_serializable and following its  documentation to auto-generate the mapping code.   After running the build_runner command, the  necessary methods will be auto-generated.   After these changes, the error in the  "api_client.dart" file should be resolved. Our next step is to create a  crucial component: the repository. Repositories are responsible for providing  data to other layers while abstracting them   from technical details. This means that the  repository will give you a list of movies,   regardless of whether it obtained them from the  internet or a local database. This is an essential   concept in implementing Clean Architecture,  where the data layer takes responsibility for   dealing with data, and the domain and presentation  layers need not be concerned about its origin. To proceed, create a new directory called  "repository" in the "data" folder, and then create   a new file called "movies_repository.dart". In  this file, we'll define the "getUpcomingMovies()"   method, which will obtain data from the remote API  and map it to domain objects before returning it.   To do this, we'll need instances  of "ApiClient" and "NetworkMapper". In a typical Clean Architecture system, we would  create a layer called "UseCases" or "Interactors".   However, based on my experience,  these classes are usually empty,   as the business logic for different systems  usually resides on backend servers. Hence,   I opt to utilize repositories directly from  the presentation layer to work with data. To verify that everything is working correctly,  I want to view a sample of the data. First,   create a directory called "presentation"  to handle the visual interface layer.   Inside this directory, create  a sub-directory called "list"   and create a new file within it  named "movies_list_screen.dart". In this file, I'll define the screen where  the list of movies will be displayed.   To manage the state of the application  and obtain a reference to the repository,   I'll use the provider package. If you're  already familiar with a different dependency   injection or state management library,  feel free to use it instead. I prefer   provider because I've been using it  for many years, but for this example,   I won't make extensive use of it. You can  use the mechanisms you're comfortable with. Initially, I'll define a simple  user interface to load the first   ten results. Later, we'll improve this interface,   but for now, I just want to confirm if the  request and mapping are working correctly. Next, create the root widget of the application by   defining the "app.dart" file  within the "app" directory.   I'll set the screen I just created  as the home screen of the app. The only part missing is the creation of  all the classes and the initialization of   the dependency injection, which is nothing more  than creating the class tree and establishing the   way to satisfy each of the dependencies for each  class. But before, we are going to create a small   functionality in which our app will be able  to load data from a local configuration file.   This will help us to create a json file where  we will write our apiKey and our apiHost in   a file that will not be tracked by git, in  this way we can save our credentials safely. To do this, create an assets directory, and  inside another config directory. We then set   this configuration in pubspec.yaml so that Flutter  can access the files inside this config folder. Now create within the config folder  a file called config.json.temp,   where we will place the schema of our  json as an example. The objective of   this class is to have a file that acts as a  template in order to create the actual file.   This file will be tracked by git, but it will  not contain the actual credentials. Then create   another identical file, called config.json,  and put your actual API credentials in it.   Now open the .gitignore file and write the  path to the config.json file. From now on you   can commit and push your project without fear of  your credentials being in the remote repository. Now I'm going to create a model class to  represent this configuration file. Create   the config.dart file in the model folder in the  domain layer. We will use freezed again for this.   It will be necessary to run  the model autogeneration again. Now open main.dart, remove  everything except the main() method.   Create a function that will return  the Config model, this function will   contain the Logger parameter, which  we will use to log information. The job of parsing this json will  be done inside a try catch block.   We can get the raw content of the file through  this variable called rootBundle that is inside   the Flutter services package. We then decode it  with json.decode() as a map of String and dynamic. Then we simply have to return  Config, and in each parameter,   we mention the name that identifies each field.   The method to load the  rootBundle file is loadString().   If an error occurs, re-throw  the exception. From now on,   this configuration file must be present  and valid for the application to run. Now we need to create the dependency list. Create  a class called InitialData, which contains a list   of providers. We will use this class to store all  the providers and send them to the App widget. Now create a method that returns a Future  with InitialData. Within this method,   I am going to proceed to instantiate all the  classes that we have been creating so far.   At the end of it all I have to do is return  InitialData with this list of instances. In the main method, I get the InitialData  instance and pass it to the App widget.   To do this I have to modify the App  widget to accept this input parameter.   I'm going to wrap MaterialApp  inside a MultiProvider,   and I'm going to provide it with the  list of providers I just created.   This way, everything below the MultiProvider  can access all the providers I have defined. As I explained to you before, this is the way  to do it with the provider package. If you   are using another dependency injection  methodology the way will be different. Now we are going to run the app to see if  everything works well. I get an error, we need   to add WidgetsFluterBinding.ensureInitialized() to  the beginning of the main() method of main.dart.   We run again and see that the list of  upcoming movies has loaded correctly.   Ok, now we can successfully get the data from the  remote API. Next, we will proceed to improve the   user interface a bit, we will make it possible  to scroll infinitely in this list of movies,   and we will define our database layer  to create a local cache of data. Before continuing I would like to ask you a  favor. If you don't mind, could you please   take a moment to give me a like and write a  comment with your feedback on this tutorial?   I'm always looking to improve, so any comments  or suggestions would be greatly appreciated.   Also, if you're able to, you can also  support me by clicking on the "Super   Thanks" button below this video or visiting  my website's donation page. The link is on   the video description. Thank you so much for your  support, and now let's get back to the tutorial! Next, we'll make some improvements to the  interface. To do this, create a file called   "movie_preview.dart" in the "list" directory  within the "presentation" folder. Here,   we will create a widget that represents  each movie in the list of entries. First, we will define a variable called "size",  which will determine the size of the movie image.   The widget will take the movie to  be displayed as an input parameter. For each entry, we'll use a Card widget  that contains the movie's image, title,   and release date. If the movie doesn't have an  image, we'll show a placeholder image instead. If the movie does have an image, we'll  use the "cached_network_image" package   to download and cache the image. This way,  if the image needs to be rendered again,   it will be loaded from the cache,  saving battery and bandwidth. If we need to display a placeholder image,  we'll define a specific widget for it.   For now, we'll use one of the  icons already included in Flutter. Next, we will add the title and release date.  We will put everything inside a Flexible widget,   because the title may be too long. By  putting everything inside Flexible,   the Text widget that contains the title will  be able to correctly apply the ellipsis. To display the release date correctly, we'll  need to format the DateTime. We can do this   using the DateFormat class, but we'll  need to install the "intl" package first,   which contains utilities related to app  internationalization and language-related tools.   We can then use this  DateFormat to format the date. Now, let's go to the "MoviesListScreen" and   replace what's there with  the widget we just created.   You may encounter an error at this  point, which can be solved by opening   the "Podfile" file in the iOS folder  and adding the following block of code. Finally, we can execute the code  and see the new list interface. Now I want to create a screen to get the detail  of each movie. We will access this screen by   tapping on each item on the list. Create a  detail directory in the presentation folder,   and inside the movie_detail_screen.dart file. This widget receives a movie to display its  data. What I'm going to do is put it all inside   a CustomScrollView, because I want to display  the movie image in the top bar, but I want it   to get smaller if the user scrolls down. You  will see the final result in a moment. At   the moment we define the list of slivers as  follows, first a SliverAppBar with the image   of the movie, but we check that if the movie has  no image then we will not give it the same amount   of height. Then a SliverList with a single  element: the synopsis of the movie. What's   happening here is that the API we're using  doesn't give us the description of the movie,   so for the purposes of this tutorial I'm going to  generate a lorem ipsum text and paste it as is. We then go back to MoviePreview and wrap the  Card inside a GestureDetector to detect the   tap action. In case the user clicks, we push  the MovieDetailScreen that we just created.   I'm going to run the app, you'll see that  when I click on each entry the detail opens.   For movies without images, the top bar  has the normal height of the action bars. With the API data and a basic  visual interface in place,   my next step is to implement infinite scroll  functionality. To do this, I will install   the infinite_scroll_pagination  package by running this command. I will then create a model class  called "movies_list_model.dart"   to manage the state of the MoviesListScreen.   This class will require the use of Logger to log  data and MoviesRepository to retrieve the movies.   I will create a method that takes in the current  page as a parameter and returns a list of movies.   If an error occurs, it will be logged and then  re-thrown. The method will return the list of   movies for the current page, with a limit of 10  movies as that is the maximum allowed by the API. Next, in the MoviesListScreen, I will define  a variable of type PagingController to handle   pagination. This controller will contain an  index and each element will be of type "Movie".   I will initialize the model I just  created in the "initState()" method.   Then, I will add a listener  to the PagingController that   uses the method we just created to get the data.   In the appendPage() method, we pass it the list of   movies and indicate that the next index  is the current page plus one. Lastly,   the "dispose()" method of the PagingController  needs to be called to release its resources. To display the movies, I will define the  body of the Scaffold in a new way using   classes from the infinite_scroll_pagination  package. The PagedListView receives the   PagingController we defined earlier, and within  the PagedChildBuilderDelegate, we can create the   widget that we want for each movie. We will use  the MoviePreview class that we created earlier.   Finally, we can delete the repository from here.   Once we run the app, we will be able to scroll  through the movies, and we will see that as we   scroll, more requests are automatically made  to the remote API to get more data to display. We will now create the database layer, which  will store the data obtained from the remote   API to form a local cache. This will enable the  user to quickly access the last list of movies   that was loaded, regardless of whether  they have an internet connection or not.   Once this local cache is created, we  will develop a system to enable users   to download new data from the  remote API, if it is available. As we create the database layer,  you will begin to see the benefits   of the Repository Pattern that  we applied in the data layer. To begin, we will install "sqflite,"  the Flutter library that we will use   to work with the database. Additionally,  we will need "path" and "path_provider." The first step is to create the database entity.  We'll start by creating the "database" directory   inside the "data" folder, and then create  a new folder called "entity" within it.   Inside the "entity" folder, we'll create  a new file called "movie_db_entity.dart."   The goal now is to define the entity that  will represent each entry in the database.   To do this, we'll use "json_serializable," just  like we did with the network layer entities. The first thing we need to do is define the names  of the fields. You'll soon see why this is useful.   Note that we're creating two fields for  identifiers: the first will be a unique   integer managed by the app, and the second  is the movie ID that comes from the remote   API. We're doing this because we need to apply  an order to the entries in the database. So,   we're going to create an auto-incremental  integer as a primary key that will act as   a unique identifier and help us to order the data. Next, we'll create a variable for each  field. Using the "@JsonKey()" annotation,   we can give it whatever name we want. For  the release date, we'll use an int, which is   simply the timestamp of the release date, as we  cannot store dates as such in SQLite databases. We'll then run the model build  command so that the "fromJson()"   and "toJson()" methods are autogenerated.  In this case, they are called this way,   even though they won't actually  convert to JSON, but to a Dart map. We need to create the database mapping class,  similar to what we did with the network layer.   To do this, navigate to the database directory  and create a file named "database_mapper.dart".   The process we'll use is identical to the  NetworkMapper, but in this case, the mapping   class will be responsible for converting the  database entities to domain models and vice versa. Next, we need to create a data  source that connects to the database.   These objects are commonly referred  to as DAOs, or Data Access Objects.   Create a directory named "dao" inside the database  directory. We'll start by creating a base DAO,   from which all other DAOs will inherit. The base  DAO will handle obtaining access to the database,   as well as managing its creation and updating. Define "BaseDao" as an abstract class to prevent  it from being instantiated. Define a variable that   contains the database version (currently  set to 1) and the name of the database.   I always name a database using the app  identifier and its name. Define the name   of the movies table. Then, create a protected  method that returns the database instance.   If the instance does not exist, it will create it.   Create a private method that will  handle creating the database instance.   You can see how to obtain this  instance in the SQFlite documentation.   For now, leave the onCreate() method  empty. The operator you see on the   screen is used to return the object if it  is not null, or call the method if it is. We now need to create a method  that will create the movies table.   This method takes an object of type Batch,  which we will use to execute queries more   efficiently in a sequence. We define the  table using the variables we have created   for the table name and field names, instead of  hardcoded Strings to reduce the risk of typos. We can now fill in the onCreate()  method by getting a batch from the   database and running the create query. This method   will be called the first time an attempt  is made to interact with the database. Next, we will create the DAO for movies, which  extends from the BaseDao we just created. We will   first create a method to get all movies, taking  in a limit and an offset to narrow the search.   We obtain a reference to the database using  the method we created in BaseDao and perform   the query, sorting the results using  the auto-incrementing ID field we added.   We then return a list generated from each input. We will also need a method to  insert data obtained from the   remote API. Create an insertAll()  method with the following content.   To insert all the data, we use a transaction,   which we can implement as follows. This way of  working with the database is more efficient. The last step is to apply the  magic to the MoviesRepository.   Add to it the DAO and the  mapping class we just created.   Well, at this point we are going  to apply the following logic,   first we try to get data from the database,  if there are elements then we return them.   In this way, as I said before, we save having to  make an API call every time we want to get data. In case the database does not contain data for  the search criteria, then we make the API call as   we already had before, and right after we save  the results in the database for future calls.   Note that the insertion in the database  is done without the await keyword.   The reason is because I don't need  to wait for the insert to be done,   I execute it and continue with the next  step, which is to return the movies. Now open main.dart and add this in the main()  method. This will be used to spit out in the   log the operations that are done in the database,  but only when the app is running in debug mode. Also add the classes we have  created to the dependency tree.   To show you how this cache works, I'm  going to place two debug breakpoints,   one when returning data from the database and  another when returning data from the remote API.   I run the app, as you can see,  the API data is returned first.   Right after another request is made  and also returned from the API.   I scroll down and as I scroll down it keeps  making requests to the API and returning them.   If everything has gone well, all these  requests have been saved in the database,   when the app is reloaded these already downloaded  data must be returned from the database. I close the app and launch it again. And as  you see, the data comes from the database.   As I scroll, it continues to load from  the database. If I keep scrolling down,   there will come a point where I no longer have  data in the database, because I haven't scrolled   down that much before, and then the data continues  to be loaded, but this time from the remote API. As you can see, by using the Repository Pattern we  have been able to articulate an intelligent data   layer that pulls data from two different data  sources, the remote API and the local database;   but making this process completely transparent  to the domain layer and the presentation layer.   In this way, changes in this data logic will  not affect other layers of the application,   thus reducing its maintenance cost  and reducing the possibility of bugs. The final task for this tutorial is to  create a system that synchronizes the   database. Imagine a situation where you have  saved all the data in your local database,   but new data is added to the remote API,  which you cannot see because your app is   loading data from the database. Therefore,  we need to create a way to detect this and   notify the user to refresh the app to  get the new data from the remote API. There is no specific method to do this, as  it depends on the API's functionality and   your specific use case. For this example, we  will take a simple approach. We will compare   the first record from the database with the  first record from the remote API, and if they   are not the same, we will display a message  to the user indicating new data is available. To accomplish this task, open the  movies_dao.dart file and create a   new method to remove all data from the database.   Then create a delete method in the   MoviesRepository that calls  the newly created method.   Next, open the MoviesListModel  and create the delete method,   which should be executed inside a  try-catch block in case of any errors.   Now open the MoviesListScreen and create a  method that will refresh the data. First,   remove all content from the database,  and then call the refresh() method of the   PagingController to refresh the list. Finally,  call this method in a RefreshIndicator widget,   which will wrap the PagedListView. I will  set breakpoints to demonstrate the result. When we open the app, the data is retrieved  from the database where it was saved.   When we pull down the list, the RefreshIndicator  effect is triggered, and the movies are reloaded   from the remote API. This happens because we have  deleted all the cached content in the database. Next,   we go back to the MoviesRepository and create a  new method to detect changes in the remote API.   We start by getting the first record from  the database. If the database is empty,   we will get the last fresh data, and  there will be no need to notify the user. We then get the data from the remote API and  compare it with the first database record.   If the data is the same, it means there  are no new updates in the remote API. If   the data is different, it indicates that the  database is out of sync with the remote API,   and we need to notify the user. Now, we will execute this method  in MoviesListScreen by creating   a private method. We need to include  everything within the callback provided   by WidgetsBinding. This is because we want to  execute this as soon as we open the screen,   but the widget may not have all the  resources available yet. Using this   callback, we can delay this operation until the  complete initialization of MoviesListScreen. To display a message to the user, we first need  to get a reference to ScaffoldMessenger. Go to   MoviesListModel and create a new method that  calls the repository method we added. When we   call this method, if it returns that there are  new data available, we need to inform the user.   We can use a SnackBar for this purpose and add an   action to make it easier for  the user to refresh the data. To execute this action when the screen is opened,   we define a Future variable at the beginning  of the class and assign it the value of   the method we just created. Then we wrap  RefreshIndicator inside a FutureBuilder.   At this point I've made a mistake,  I'm calling the method of the model   instead of the private method  of the class we just created. When we run the app, nothing happens if  there are no new updates in the remote API.   To test this functionality, we can temporarily  change the page parameter in MoviesRepository,   so that the first data obtained from the  API is different from the first database   record. This will cause the SnackBar to  appear, informing us of the new data in   the remote API. We can click on "Refresh" to  load the new data correctly. After testing,   we should change the page parameter back to  1 to ensure that everything works correctly. To complete this tutorial, I'll perform  two actions that I always do after   finishing a task. First, I'll execute "flutter  analyze" to check for any errors in the code.   Once you run it, you'll notice several  errors in the autogenerated classes.   Since these classes are autogenerated, I'll add a  rule to ignore them in the analysis_options.yaml   file. To do this, you can specify the path  to ignore using a wildcard (*) to indicate   that only the autogenerated files, which  have the ".g" naming, will be ignored.   Then we have two more errors that  we can solve in the following way. Finally run "dart format ." to make sure  that the format of all Dart files is correct. That concludes this tutorial on how to  create a complete Flutter application   using Clean Architecture. Please note that  the source code for the app we created and   links to various libraries and tools used in this  tutorial can be found in the video description. If you haven't already, please leave a like  and leave me a comment with your impressions,   doubts, suggestions or anything you would  like to tell me about this tutorial. I hope this video has been helpful,   and that you've learned some useful  things. I'll see you in the next video. Goodbye.
Info
Channel: David Serrano
Views: 16,080
Rating: undefined out of 5
Keywords: flutter, flutterdev, flutterdeveloper, dart, app, apps, mobile, flutterapp, software, android, ios, flutter tutorial, clean architecture, repository pattern, repository patern, solid principles, flutter clean
Id: eEt6JrMuPZw
Channel Id: undefined
Length: 52min 1sec (3121 seconds)
Published: Fri Apr 14 2023
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.