Skip to content

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 top-level module (e.g. app, functions, landing) 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
    • Directorylib
      • Directoryenv/ The generated env class with envied
      • Directoryi18n
        • strings.i18n.json Json that defines the app localization
      • Directorymodules/ All included modules
        • Directorybase/
        • Directoryonboarding/
        • Directorymonitoring/
      • Directorytheme/ Theme configuration logic
      • app.dart Root widget with routing and theming setup
      • main.dart Main entry point and startup logic
      • navigation.dart Navigation component configuration
    • Directoryassets/ All images and resources files
    • Directoryandroid/ios/web/ Platform specific folders
    • .env Environment variables file
    • pubspec.yaml Dependencies and Flutter project config
    • setup.sh Small script to run the setup commands
    • slang.yaml Localization configuration
  • Directoryfunctions Only present for functions module
  • Directorylanding Only present for landing module
  • firebase.json
  • Other configuration files
  • README.md

Feature structure

Each UI-feature is strucutred following this pattern:

  • Directoryhome
    • home_view.dart
    • home_route.dart
    • home_controller.dart // The business logic of this view
    • 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 color
Icons(
Icons.abc,
color: context.theme.colorSchema.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:

  1. Create a base icon with at least 512x512 pixels.

  2. Replace the icon in the assets/images/icon-512.png.

  3. 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:

  1. Modify flutter_native_splash configuration in pubspecs.yaml as you wish (e.g. change the main image).

  2. 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 asynSingleton 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 needed
final 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
        • strings.i18n.json Default localization definition
        • strings_es.i18n.json Language specific localization
        • translations.g.dart Generated localization file

Adding or updating values

  1. Add or update a key/value pair to strings.i18n.json
    {
    // ...other keys
    "my_key": "value"
    }
  2. Run the following command:
    Terminal window
    dart run slang
  3. Use the new key
    Text(context.t.my_key)

Adding a new locale

To add a new locale, you can simply duplicate the default strings.i18n.json and rename it to strings_LOCALE.i18n.json. Then, manually translate the values or use the Slang-GPT to generate it for you.

  1. Configure Slang-GPT in app/slang.yaml.
  2. Provide a description/context for your app
  3. 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:

Terminal window
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)

Terminal window
dart run build_runner watch

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.

  1. Create a new class that implements AppRoute

    class YourRoute implements AppRoute {
    static const String path = "/your_path";
    @override
    GoRoute createRoute() {
    return GoRoute(
    path: path,
    builder: (context, state) => const YourView(),
    );
    }
    }
  2. Include the route when creating the router in app.dart

    _config = createRouter(
    // ...other configuration,
    otherRoutes: [
    // ...other routes
    OnboardingRoute(),
    ],
    );
  3. 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.

@override
NavigationDestination 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.