Most of my Flutter projects have been looking the same recently...
I've settled on my favourite packages, file-structure, build settings, build-flavour setup, etc.
I'm hoping this is a signal of my maturity with the platform and less that my ability is stagnating. There are so many unexplored paths, like state-management with Bloc, like routing with go-router, like using cupertino-flavoured widgets for once; it may be a sunk-cost situation, but I feel very comfortable in this workflow.
This post will serve as a reference point for starting all new Flutter projects and some perhaps niche little tid-bits that make getting started easier.
Packages
Flutter doesn't have good native state-management solutions, good routing solutions, or good native mapping for JSON.
These packages each address a shortcoming, leveraging build_runner as a code-generator.
1# Riverpod for state management 2flutter pub add \ 3 flutter_riverpod \ 4 riverpod_annotation \ 5 dev:riverpod_generator \ 6 dev:build_runner; 7 8# Freezed for strongly typed, immutable classes. 9flutter pub add \ 10 freezed_annotation \ 11 dev:freezed; 12 13# JsonSerializable for json mapping and converters. 14flutter pub add \ 15 json_annotation \ 16 dev:json_serializable; 17 18# AutoRoute for routing, deep-links, etc. 19flutter pub add \ 20 auto_route \ 21 dev:auto_route_generator;
Paste the above command into the terminal to sort out your required package setup.
1dart run build_runner watch -d
Then run this one to watch for changes that require code-generation and create them automatically.
File Structure
I've come to like a 'lite' version of the Clean Architecture pattern. Separating concerns helps me keep things organised, understand where errors are created, and where the responsible bugs live.
It ends up looking like so:
1project/ 2├── assets/ 3│ ├── fonts/ 4│ ├── images/ 5│ └── icons/ 6└── lib/ 7 ├── data/ 8 │ └── api/ 9 │ ├── models/ 10 │ │ ├── get_data_request.dart 11 │ │ └── get_data_response.dart 12 │ ├── api_service_impl.dart 13 │ └── api_routes.dart 14 ├── domain/ 15 │ ├── api/ 16 │ │ ├── api_service.dart 17 │ │ └── api_use_cases.dart 18 │ └── models/ 19 ├── ui/ 20 │ ├── components/ 21 │ │ ├── primary_button.dart 22 │ │ ├── secondary_button.dart 23 │ │ └── visual_center.dart 24 │ ├── router/ 25 │ │ ├── router.dart 26 │ │ ├── route_guard.dart 27 │ │ └── tab_router.dart 28 │ ├── screens/ 29 │ │ └── home/ 30 │ │ ├── state/ 31 │ │ │ ├── home_state_model.dart 32 │ │ │ └── home_state_provider.dart 33 │ │ ├── widgets/ 34 │ │ │ ├── welcome_widget.dart 35 │ │ │ └── onboarding_modal.dart 36 │ │ └── home_screen.dart 37 │ └── theme/ 38 │ ├── assets.dart 39 │ ├── colors.dart 40 │ ├── fonts.dart 41 │ ├── sizes.dart 42 │ └── theme.dart 43 ├── utils/ 44 │ ├── nullable_extensions.dart 45 │ └── string_extensions.dart 46 └── main.dart
Does that make sense?
UI
The skinny of the UI layer is that each screen has its own folder with subfolders for screen-specific widgets and its view model.
Classic MVVM situation, except the ViewModel is called a providerNotifier in Riverpod and lives in the *_state_provider.dart;
an MVP situation, if you will.
The reason we split these out into their own state/ folder is that there is a decent amount of generated files that pollute the folder.
One for the model, and one for the provider.
Keeping these tied up in their own directory dungeon is much better than having to search through 20 files.
Widgets that are required on multiple screens can live in the components/ directory,
and everything related to routing is handled in the router/ folder, shocking.
This gets more interesting an opinionated later.
Theme
Inside the UI folder, we find the app's theme. This whole folder is made up of part files which funnel into theme.dart.
1// THEME.dart // 2part 'assets.dart'; 3part 'colors.dart'; 4part 'fonts.dart'; 5part 'sizes.dart';
This is done so that all uses of the different theme components can be accessed via a single import statement in a widget.
Having to mess around with imports interrupts the flow, so reducing this friction really improves my experience programming in Flutter.
In sizes.dart, we take inspiration from TailwindCSS once again and provide named sizes and rounding.
1// sizes.dart // 2part of 'theme.dart'; 3 4abstract class Spacing { 5 static const xxs = 4.0; 6 static const xs = 8.0; 7 static const sm = 12.0; 8 // etc ... 9} 10 11abstract class BoxSizes { 12 static const xxs = 32.0; 13 static const xs = 48.0; 14 static const sm = 64.0; 15 // etc... 16} 17 18abstract class Rounding { 19 static const xxs = BorderRadius.all(Radius.circular(Spacing.xxs)); 20 static const xs = BorderRadius.all(Radius.circular(Spacing.xs)); 21 // etc... 22 static const full = BorderRadius.all(Radius.circular(double.infinity)); 23}
This method was dreamt up because of the introduction of the spacing property on Row and Column widgets.
The syntax of doing something like...
1Column( 2 spacing: Spacing.sm, 3 children: [ 4 SomeWidget(), 5 SizedBox( 6 height: BoxSizes.sm, 7 child: DecoratedBox( 8 decoration: BoxDecoration( 9 borderRadius: Rounding.full, 10 color. Colors.green, 11 ), 12 ), 13 ), 14 ] 15);
... is VERY aligned with Flutter conventions.
The introduction of dot-shorthands has nipped this in the bud a little,
but this will make do until having namespaced/scoped properties is introduced.
I'm assuming that the assets, fonts, and colors files are pretty self-explanatory.
It's important to me to keep these UI-centric files away from other more business-centric constants.
Domain
The domain directory is basically a mapping layer between the external resources and the internal models required for the screens. This layer also contains the infamous and ominous "Business Logic". Which, in essence, is just "rules made up by higher powers that must be enforced." These rules can be known as "side effects",
Its relation to the data layer is that it provides a contract (in this case, an interface) in which the data layer must fulfil.
The domain layer is expecting some kind of data, and it doesn't care how the data layer gets it.
The data layer could just be a mock that returns dummy data when requested by the domain layer,
great for testing, doesn't matter to the domain layer.
1 2IApiService apiService(Ref ref) => ApiServiceImpl( 3 authService: ref.watch(authServiceProvider) 4); 5 6abstract interface class IApiService { 7 Future<SomeData> getData(); 8 Future<void> postData(SomeData data); 9}
This snippet details an interface contract, IApiService,
and how we instantiate the implementation from the data layer and inject the instance of the authService.
We don't know who or what is fulfilling the authService contract behind the scenes, but we also don't care.
Data
The data layer is for interfacing with external resources. This could be an API, the device storage, an external authentication service, etc. The contracts (functions) are defined by the domain layer, and are fulfilled in whichever implementation is required.
The data layer should also be in charge of coercing errors into a readable and understandable format for the application. If the API returns a structured error that states a field was invalid, that structured error should find it's way to reflect on the field itself.
That's really it for Flutter stuff, I might add a repo to this...
If I do, it will be found below.