Learn the basics
The main idea of ShipFlutter is get you started fast! We use a simple, yet effective approach, for structuring the code, manage state and inject dependencies to make it easy to understand and statisfy most of the needs to launch a project with Flutter.
This guide covers the basic structure and patterns of the project that is common to all modules.
Project Structure
The project follows a simple structure, with a root folder for each the app
, cloud functions
, the landing
page if selected and root-level configuration files. Inside the Flutter project the the structure is divided in the following way:
Directory.github/workflows/ Actions to deploy the project
- …
Directoryapp Flutter project root folder
- …
Directoryfunctions Cloud functions module
- …
Directorylanding SEO optimized landing page
- …
- .firebaserc Configuration file for the Firebase CLI
- firebase.json Configuration file for Firebase project
- … other configuration files
Flutter structure
The app is divided between the core
or backbone of the app and the features. Each feature is encapsulated inside it’s own folder, while core
contains common classes used throughout the app.
Directoryassets/ All images and resources files
- …
Directoryandroid/ Android specific files
- …
Directoryios/ iOS specific files
- …
Directoryweb/ Web specific files
- …
Directorylib Flutter app source code
Directoryenv/ The generated env class with envied
- …
Directoryi18n
- en.i18n.json Json that defines the app localization
Directorycore The backbone of the app
Directoryaccount/
- …
Directoryfeedback/
- …
Directorynotifications/
- …
- …
Directoryfirebase/
Directoryauth/
- …
Directorydatabase/
- …
Directorymonitoring/
- …
- …
- firebase_service.dart Connector to Firebase services
Directorytheme Theme configuration logic
- app_theme.dart Main theme configuration file
DirectoryfeatureX/ UI features and screens
- …
DirectoryfeatureY/
- …
- app.dart Root widget with routing and theming setup
- main.dart Main entry point and startup logic
- navigation.dart Navigation component configuration
- firebase_options.dart Auto-generated Firebase configuration
- .env Environment variables file
- pubspec.yaml Dependencies and Flutter project config
- setup.sh Simple script to run the setup commands
- slang.yaml Localization configuration
- …
Feature structure
Each UI-feature is strucutred following this pattern:
Directoryhome
- home_view.dart
- home_route.dart
- home_controller.dart
- home_service.dart
Directorywidgets/ Any widget needed by the home_view.dart
- …
- View: The feature main widget/view that wires the controller and displays the state.
- Route: Defines how to create a route for this view (used by the navigation). Either a NavigationRoute for routes that are part of the bottom/rail/drawer navigation or an AppRoute for all the other routes
- Controller: The business logic of the view. Mainly responsible to map the service/model with the view state.
- Service: Responsible to fetch the model date and update it.
Theme, icons and splash screen
Theme
ShipFlutter uses MaterialTheme with M3. By default we use the colors that you defined in the builders.
You can modify them and add on to the theme, by modifying the app_theme.dart
file.
To use the theme in the app you can access it via the context
extension:
// This will tint the color with the theme primary colorIcons( Icons.abc, color: context.colors.primary);
To control light/dark mode we use the settingsController
singleton. It has a signal themeMode
that
holds the current theme mode. If changed, it will store it in the user preferences and automatically
change the mode in the MainApp
widget in app.dart
.
App icon and splash screen
ShipFlutter uses the flutter_launcher_icons and flutter_native_splash packages to generate the app icon and the splash screen for Android, iOS and Web targets.
Generate app icon
To generate a new icon follow these steps:
-
Create a base icon with at least 512x512 pixels.
-
Replace the icon in the
assets/images/icon-512.png
. -
Then run the following command:
Terminal window dart run flutter_launcher_icons
Generate splash screen
To generate a new splash screen for Android and iOS follow these steps:
-
Modify
flutter_native_splash
configuration inpubspecs.yaml
as you wish (e.g. change the main image). -
Then run the following command:
Terminal window dart run flutter_native_splash:create
For web, the splash screen is done manually with a smooth pulse animation using css and js.
If you want to modify it, you will need to change the index.html
and the flutter_bootstrap.js
files.
State Management
The ShipFlutter project uses Signals together with context_watch for state management.
Signals are quite powerful but at the same time simple to use. The project only uses the basic
functionality to make it easier to understand. Here is an example of the details_controller.dart
class DetailsController extends Disposable { DetailsController({required this.id});
final String id;
late final state = homeService.instance.loadSingle(id).toFutureSignal();
@override void dispose() { state.dispose(); }}
The state
variable holds the signal with the Future returned by the service. When the signal is observed
it will the fetch the data and update the state. It will initially return an AsyncLoading
state, once
the value is available it will return an AsyncData
state. In case of error it will return an AsyncError
state.
The details_view
then uses the state
to react to state changes and display the view.
class DetailsView extends StatelessWidget { final String id;
const DetailsView({super.key, required this.id});
@override Widget build(BuildContext context) { final controller = detailsController.of(context, id); final state = controller.state.watch(context); return Scaffold( // ... ) }}
Similar pattern with some variations is applied across the codebase. For more information on Signals check the officials docs.
Dependency Injection
Instead of using complex libraries, we use Lite Ref, a lightweight dependency injection library for Dart and Flutter with a scope approach that makes it easy to use but still testable and flexible.
The basics are simple. There are scoped
and singleton
instances.
Scoped instances
Used for controllers and context
bound instances, that should be disposed when the widget/context
is destroyed/removed. The usage is simple. First, declare it as a top-level variable:
final homeController = Ref.scoped( (context) => HomeController(...));
Then, get the insitance from withing any widget or context
:
final controller = homeController.of(context);
lite_ref also provides a disposable mechanism, that would call dispose
to any class that implements Disposable
.
class HomeController extends Disposable { // ...
@override void dispose() { // it will be called automatically when the widget leaves the tree. _state.dispose(); }}
Singleton instances
Used for services and classes that needs to be used across the different app screens. The usage is similar to scoped instances. First, declare it as a top-level variable:
final authService = Ref.singleton( () => AuthService(...));
Then access it from everywhere of your app with:
final service = authService.instance;
You can also create asyncSingleton
instances to ensure the class has been initialized before
using it. Quite useful for libraries that requires some sort of async initialization.
// SharedPreferences library returns a Future<SharedPreferences>final preferences = Ref.asyncSingleton( () => SharedPreferences.getInstance(),);
// It's safe to call `instance` multiple times. It will be initialized only once.final preferences = await preferences.instance;// You can also get it syncronously with `assertInstance`// but we would discourage it unless it's really neededfinal preferences = preferences.assertInstance;
Localization
An important bit of any app is localization. ShipFlutter setup allows you to easily localize the app in any language by using Slang and Slang-GPT.
The concept is simple, you can define your project localization using JSON and use code generation to do the wiring.
Directoryapp
Directorylib
Directoryi18n
- en.i18n.json Default localization definition
- es.i18n.json Language specific localization
- …
- translations.g.dart Generated localization file
Adding or updating values
- Add or update a key/value pair to
en.i18n.json
{// ...other keys"my_key": "value"} - Run the following command:
Terminal window dart run slang - Use the new key
Text(context.t.my_key)
Adding a new locale
To add a new locale, you can simply duplicate the default en.i18n.json
and rename it to LOCALE.i18n.json
.
Then, manually translate the values or use the Slang-GPT to generate it for you.
- Configure Slang-GPT in
app/slang.yaml
. - Provide a description/context for your app
- Run the following command:
Terminal window dart run slang_gpt --target=fr --api-key=<api-key>
Read further configuration options at Slang-GPT
Code Generation
A powerful tool that allows you to generate code and speed up the development process is build_runner
.
ShipFlutter uses it to generate environment variables (envied)
and data classes (freezed).
To run the code generator, execute the following command:
dart run build_runner
This will automatically generate .freezed.dart
and g.dart
files for you. Those files contains
all the boilerplate code for getters/setters, equals, hashcode, copyWith, JSON serialization, etc…
Everytime you modify a file that contains a part
statement, make sure to run the builder.
Alternatively you can run it in watch mode (but sometimes is problematic)
dart run build_runner watch
Navigation
A crucial component of any app is navigation. ShipFlutter uses go_router to manage the navigation between screens.
The initial setup is slighlty complex, but ShipFlutter has already done it and provides a simple interface to customize it.
Create a route
A route should be associated with a view/screen. ShipFlutter abstracts the concept with the AppRoute
class.
-
Create a new class that implements
AppRoute
class YourRoute implements AppRoute {static const String path = "/your_path";@overrideGoRoute createRoute() {return GoRoute(path: path,builder: (context, state) => const YourView(),);}} -
Include the route when creating the router in
app.dart
_config = createRouter(// ...other configuration,otherRoutes: [// ...other routesOnboardingRoute(),],); -
Navigate to a route with the
go_router
interface. For example:context.go(YourRoute.path);
You can also, define a route within a route, by manually invoking the createRoute
method:
class YourRoute implements AppRoute { static const String path = "/your_path";
@override GoRoute createRoute() { return GoRoute( path: path, builder: (context, state) => const YourView(), routes: [ AnotherRoute().createRoute(), ] ); }}
In such case, the route path is a combination of the parent route and the child route.
context.go(YourRoute.path + AnotherRoute.path);
Create a Navigation route
A navigation route is an abstraction done with the NavigationRoute
class used for routes that are
part of a navigation bar (either bottom or rail). The main difference with the AppRoute
class is
that it adds a new createDestination
method that defines the icon and label used in the navigation UI.
@overrideNavigationDestination createDestination(BuildContext context) { return NavigationDestination( icon: const Icon(Icons.home_rounded), label: context.t.navigation.home, );}
ShipFlutter handles the logic to show a bottom, rail or top navigation bar for you. In addition, you can
also define main/details screens that split the screen in two when there is enough space by using the AppScaffoldContent
class.
Check the home_route.dart
and details_route.dart
files for an example.