- Clone the repository:
git clone https://github.com/monterail/stock-app.git
. - Open created directory and run
flutter pub get && make generate-code
. - Obtain a free Polygon.io API key, and paste it into
environment/.prod-variables -> APP_POLYGON_API_KEY_PROD
andenvironment/.dev-variables -> APP_POLYGON_API_KEY_DEV
. - Prepare an Android or iOS target and run
make run-prod
. The app will be built and ran on the target.
Note: Our template is meant for privately hosted repositories hence secrets handling is a bit clunky.
Flutter app template.
Supports:
- Internationalization
- Splash screen
- Linting and formatting
- State management with bloc
- Flavors
- Routing
- Testing
- CI/CD with AppCenter
- Setup for VS Code
-
Sentry integration(Removed for project simplicity) - ADR
- Changelog
- Caching, saving local data
For help getting started with Flutter, view our online documentation, which offers tutorials, samples, guidance on mobile development, and a full API reference.
The assets
directory houses images, fonts, and any other files you want to
include with your application.
The assets/images
directory contains resolution-aware
images.
Splash screen configuration with available options is available in flutter_native_splash.yaml
.
Any changes to this file have to be followed by running flutter pub run flutter_native_splash:create
command.
This is a solution for masking the initial load time of Flutter engine. If the app does something time consuming before displaying meaningful content to the user consider adding a splash screen widget to mask such wait time.
We're using flutter_localizations
package which generates code automatically based on lib/src/localization/*.arb
language files.
To add another language to the app:
- add a
app_xx.arb
where xx is a two letter language code (eg. pl for Polish, es for Spanish). It must contain translated strings for all keys fromapp_en.arb
(except ones with a@
prefix, those are for added context for the translator), - add new supported Locale to
supportedLocales
list inlib/app.dart
(eg.Locale('pl', '')
for Polish,Locale('es', '')
for Spanish).
After code generation all of the defined strings will be available for widgets from AppLocalizations.of(context)
.
Eg. to read an appTitle
field in the Text widget: Text(AppLocalizations.of(context)!.appTitle)
.
If a widget to test uses AppLocalizations
, you will have to wrap it with MaterialApp
and provide localizationsDelegates: AppLocalizations.delegate
like so:
const myWidget = MaterialApp(
localizationsDelegates: [
AppLocalizations.delegate,
],
home: TestWidget(),
);
- Flutter docs: link
It's good to keep consistent code style, at least project-wide, and Dart/Flutter does come with linting support.
We're using flutter_lints
package which contains recommended rules for Flutter apps.
Run linting by running flutter analyze
command in the root of the project or integrate linter with your IDE.
To format the code use flutter format lib/
or flutter format test/
command in the root of a project.
Linting can be easily integrated via Flutter extension.
With this extension, you can find analysis issues in the Problems tab:
To see issues next to affected line use Error Lens extension.
Enable automatic code formatting on each file save by settings Manage (Bottom left cog icon) ➡ Settings
, then search for Editor: Format On Save and enable the checkbox:
We're using bloc (mostly) as out state management. It provides us easy separation of our apps into three layers:
- Presentation (your UI has to be located here)
- Business logic (here is place for code that do some stuff)
- Data (work with network or local data will be located here)
We use both Cubit and classic BLoC.
BLoC is your choice if you are building a feature that has inputs, a lot of fetches, or any other kind of complicated states
If you are working on some simpler stuff, take a Cubit. You can easily rewrite it later.
- Install this VSCode extension to save your time while you're creating your blocs
- Separate your models, API fetches, UI screens, and blocs/cubits by features
- Write tests for each of your bloc/cubit
- Your states and events have to extend Equatable, the reason is described here (if you're lazy) and here (if you're not so lazy)
- Put only one BlocProvider in the tree, then just use BlocBuilder to have access to your bloc or cubit
- If your bloc contains some work with streams, don't forget to close it in close() method of your bloc
- Do use MultiBlocProvider in case you need to provide more than one bloc to your module
This template supports flavoring via environment variables passed to flutter build/run
commands.
Variables are available to other modules in lib/src/environment/variables.dart
and any new ones should be added there.
Each variable should have an APP_
prefix to avoid accidental overriding of other tool variables.
To add a new variable:
- Add a new
--dart-define=APP_VARIABLE=value
parameter toflutter build/run
command or.vscode/launch.json
, like so:
flutter run --dart-define=APP_VARIABLE=value
- Handle the variable in the code. In
lib/src/environment/variables.dart
add a new field that will read value from environment, like so:
class EnvironmentVariables {
// ...
static const String appVariable = String.fromEnvironment('APP_VARIABLE', defaultValue: 'default');
// ...
}
- (Optional) Handle the value in Android build process. Head to
android/app/build.gradle
and add your variable todartEnvironmentVariables
, like so:
def dartEnvironmentVariables = [
// ...
APP_VARIABLE: 'default'
// ...
];
- (Optional) Handle the value in iOS build process. New variables will be automatically available for use in Xcode (as long as those are prefixed with
APP_
).
To create new environment:
- Create new file with variables in
environment/
directory. - Include new variables file in
Makefile
. - Create build scripts for the new flavor.
Also, for VS Code:
- Open
.vscode/launch.json
. - Create debug and profile launch modes with new environment variables.
By default auto_route is used as route management. It provides us opportunity to easily send params to our routes.
To create a route with a parameter:
- Add a
RouteHelper
class inlib/src/config/routes/
directory with defined parameter:
import 'package:stocks/src/config/routes/routes.dart';
import 'package:stocks/src/modules/bloc_screen/view/bloc_view.dart';
export 'package:stocks/src/modules/bloc_screen/view/bloc_view.dart';
class BlocRouteHelper extends RouteHelper<String> {
static const path = '/bloc/:title';
static const widget = BlocView;
const BlocRouteHelper() : super(path: path);
@override
String generatePath(String title) =>
absolutePath.replaceFirst(':title', title);
}
Be sure to export the widget file.
- Annotate parameter in the target widget's constructor
class ParamView extends StatelessWidget {
final String? title;
const BlocView({@PathParam('title') this.title, Key? key}) : super(key: key);
...
- Add the route helper to
Routes
(lib/src/config/routes.dart
) class
class Routes {
// ...
static const bloc = BlocRouteHelper();
// ...
}
- Let
auto_route
know about the new route
@AdaptiveAutoRouter(routes: [
// ...
AutoRoute(page: BlocRouteHelper.widget, path: BlocRouteHelper.path),
// ...
])
class AppRouter extends _$AppRouter {}
- Run
make generate-code
to make the new route available in the app.
To create a route without any parameters:
- Add a
ParameterlessRouteHelper
class inlib/src/config/routes/
directory:
import 'package:stocks/src/config/routes/routes.dart';
import 'package:stocks/src/modules/main_screen/view/main_screen_view.dart';
export 'package:stocks/src/modules/main_screen/view/main_screen_view.dart';
class MainRouteHelper extends ParameterlessRouteHelper {
static const path = '/';
static const widget = MainScreenWidget;
const MainRouteHelper() : super(path: path);
}
Be sure to export the widget file.
- Add the route helper to
Routes
(lib/src/config/routes.dart
) class
class Routes {
// ...
static const main = MainRouteHelper();
// ...
}
- Let
auto_route
know about the new route
@AdaptiveAutoRouter(routes: [
// ...
AutoRoute(page: MainRouteHelper.widget, path: MainRouteHelper.path),
// ...
])
class AppRouter extends _$AppRouter {}
- Run
make generate-code
to make the new route available in the app.
Each app version should have brief notes for introduced changes in CHANGELOG.md
.
We use Hive database to store data locally. Hive is a lightweight, powerful database which runs fast on the device.
Unless you absolutely need to model your data with many relationships, choosing this pure-Dart package with no native dependencies can be the best option.
Hive is centered around the idea of boxes
. Box
has to be opened before use. In addition to the plain-flavored Boxes,
there are also options which support lazy-loading of values and encryption.
Hive needs to be initialized to, among other things, know in which directory it stores the data. A service for hive was created.
The setupHive
method initializes hive for flutter and registers adapters and is called in main
.
IHiveRepository<E>
is an mixin that manages Hive box opening, where E
is a specific type depending on the type of data being stored.
Hive service
Future<void> setupHive() async {
WidgetsFlutterBinding.ensureInitialized();
await Hive.initFlutter();
_registerAdapters();
}
void _registerAdapters() {
Hive.registerAdapter<User>(UserAdapter());
}
abstract class IHiveRepository<E> {
Box<E>? _box;
String get boxKey;
Future<Box<E>> get box async {
_box ??= await Hive.openBox<E>(boxKey);
return _box!;
}
}
Data can be stored and read only from an opened Box
. Opening a Box
loads all of its data from the local storage into memory for immediate access.
- Open box
Hive.openBox('userBox');
- Get an already opened instance
Hive.box('name');
There are two basic options of adding data - either call put(key, value)
and specify the key yourself,
or call add
and utilize Hive's auto-incrementing keys. Unless you absolutely need to define the keys manually,
calling add is the better and simpler option.
userBox.add(User('Test User', 28));
Hive works with binary data. While it's entirely possible to write a custom adapter which fumbles with a BinaryWriter and a BinaryReader,
it's much easier to let the hive_generator
package do the hard job for you. Making an adapter for specific class is then as simple as adding a few annotations.
Creating a TypeAdapter
import 'package:hive/hive.dart';
part 'user.g.dart';
@HiveType()
class User {
@HiveField(0)
final String name;
@HiveField(1)
final int age;
User(this.name, this.age);
}
To generate TypeAdapter you should run flutter packages pub run build_runner build
. Thanks to the Makefile
scripts, we can do this with make generate-code
make watch-and-generate-code
until stopped will watch for file changes and automatically build code if necessary.
It's useful when dealing with a lot of code generation since it'll do a whole project build only at start and then do smaller builds only for affected files.
The created adapter must be registered.
IHiveRepository
should be used with every repository that is using Hive.
Example
class UserRepository with IHiveRepository<User> implements IUserRepository {
@override
String get boxKey => 'userInfoBoxKey';
@override
Future<User?> getUser(String userKey) async {
return (await box).get(userKey);
}
@override
Future<void> saveUser(String userKey, User user) async {
await (await box).put(userKey, user);
}
@override
Future<void> deleteUser(String userKey) async {
await (await box).delete(userKey);
}
Dependency injection is an object-oriented technique that sends the dependencies of another object to an object. Using dependency injection, we can also move the creation and restriction of dependent objects outside the classes. This concept brings a more significant level of adaptability, decoupling, and simpler testing. Famous packages for DI:
There is a Makefile
with build scripts for dev and prod environment (those are standard flutter build *
commands but with environment variables).
Eg. to build dev .apk
run make build-dev-apk
. For iOS there're *-ipa
, and for web there're *-web
scripts.
We're using custom scripts to make AppCenter support our app building process.
There's one for Android (android/app/appcenter-post-clone.sh
) and one for iOS (ios/appcenter-post-clone.sh
). Those download latest stable Flutter and build prod flavored app with signing.
To build the dev flavored app, set a RELEASE_TARGET environment variable to
development
in branch build configuration.
Firstly, create a keystore for signing.
You need to have Java installed and available in the shell:
- on mac, using brew:
brew install openjdk
, - on windows, just download a
.msi
file from here, - on linux or wsl, there's probably
openjdk
available in your package manager.
In the root project folder run: make create-android-signing
.
You will be asked some questions, but the passwords are the most important. Remember those and put
in android/app/build.gradle
in section signingConfigs
:
signingConfigs {
release {
storeFile rootProject.file("upload-keystore.jks") # leave as-is
storePassword "password" # put your store password here
keyAlias "upload" # leave as-is
keyPassword "password" # put your key password here
}
}
To check if signing works, you can run make build-prod-apk
. If the build process goes fine
and the app is working it's done 🍾
To use signing in Android builds, set the AppCenter build like so:
To distribute the app automatically to the store, follow this guide.
iOS builds will require .mobileprovision
and .p12
files. Here's how to obtain them. Keep them somewhere safe and upload copies to AppCenter build config:
To use signing in iOS builds, set the AppCenter build like so:
To enable automated code quality tests, head to .github/workflows/lint-and-test-pr.yml
and uncomment lines:
on:
pull_request:
branches:
- development
It's off by default to not slow down development, but if your project have 3+ developers working on it, turning it on may be beneficial.
Testing of Flutter goes into three categories:
- unit tests for single function, class or method,
- widget tests for a single widget,
- integration tests for whole app or big part of the app.
Example tests can be found in test/
directory.
Strive to test every BLoC thoroughly as it is the source of data for UI and a recipient of events from the system. If it works, then UI will likely work too as it listens closely for updates, and requested actions will take place.
Example test:
blocTest<CounterBloc, CounterState>(
'decrease actions',
build: () => CounterBloc(),
act: (bloc) => [for (int i = 0; i < 4; i++) bloc.add(CounterDecreased())],
expect: () => const <CounterState>[
CounterState(value: -1),
CounterState(value: -2),
CounterState(value: -3),
CounterState(value: -4)
],
);
Use blocTest
to reduce boilerplate compared to classic tests, where managing
instances is required.
When creating reusable widget, DO test if it's params do affect the UI as expected.
Testing complex widgets, like whole pages should be done via integration tests.
Consider implementing integration tests for crucial workflows, eg. logging in. Avoid implementing integration tests for every workflow as updating an app fortified with a lot of integration tests will be difficult. Assume that reusable widgets are working correctly (as those are tested) and focus integration tests on workflow interactions.
Look at Flutter integration testing docs.
We're keeping integration tests in integration_test
directory.
To run integration tests, use make run-integration-test
.
Perform this checklist: Template using checklist.