Flutter tech insight into the Škola OnLine App
Feel like peeking behind the curtain of our latest mobile app? Come right in.
Recently, we celebrated the launch of the new Škola OnLine app, a school information system that enables principals, teachers, students, and parents to access daily curricular activities, by publishing a showcase of all its vital updates. Today, we’re delving a little deeper, shedding light on many technical decisions made while conceiving the app. Additionally, we’ve selected a few tools and libraries that helped us bring it into life.
Android and iOS platforms utilize a concept of flavors (build schemes in iOS), which allow for creation of different “build flavors” of the application. Think different icons for paid and free versions of games. Our case came with three specific requirements for the entire development process:
- First of all, every new feature or bug fix found during development should immediately be available for testers to test.
- When there is a new production release candidate, we want an easy approach to getting this candidate to testers.
- And finally, we need to have the development version and the production version of the app installed simultaneously.
These requirements conclude a few things, but most importantly, each flavor should have its own “application id” (
ApplicationId in Android,
Bundle Identifier in iOS). If the application id isn’t different, there is no way to have multiple application flavors installed simultaneously.
For development version testing and production candidates (staging), we are using the Firebase App Distribution service. It lets us quickly share new builds with testers and other clients without uploading packages into stores (Google Play, AppStore). But, even more importantly, each new build in the store has to increase a “code version” (BuildNumber), which is inconvenient because we could upload many “intermediate” versions of the application which were never meant to be promoted to production.
We also set different customizations for each flavor, such as custom icons, labels, and more. This way, we could easily fulfill our needs and achieve a butter-smooth development process.
At NetGlade, testing is treated with utmost care, so we perform different kinds of tests like unit or integration. We don’t do a lot of widget tests, but we try to cover as much code as possible with unit tests, which help us prevent bugs when changing services, APIs, or logic.
By conducting integration tests, we tried to explore and familiarize ourselves with different options. Tools such as Patrol by LeanCode, Fluttium by Wolfenrain, and Maestro by mobile.dev were among the most rigorously tested. Patrol and Fluttium, which are built around Flutter, caught our eye, but since they are still in active development, quite a few features are missing. Instead, we decided to use Maestro, which suits us just perfectly and can be written with a simple yaml syntax to black-box test the app.
Fig 1.: Integration testing using Maestro.
Managing Flutter versions
As is often the case in development, we cannot keep up with the frequency of Flutter updates for each project we work on. A new version brings a new set of features and deprecations, not to mention all developers must have the same version to experience the same conditions. And differences across different versions of Flutter are common. We also cannot install a new version every morning and keep every project up to date with it.
However, to partially solve this long-standing issue, there is a fantastical tool called fvm (Flutter Version Management). It’s a CLI that works as a proxy for Dart and Flutter and automatically selects the correct Flutter version for the current project. Each project can set its version, so developers can maintain multiple iterations at once. When a project version changes, the correct one is used or downloaded if missing.
We use a variety of tools to keep monitoring comprehensive yet simple. Tools like Firebase Crashlytics, Google Analytics, and Bugfender, which we selected based on targeted app flavor. Bugfender is for logging everything during the development and testing phases. If a production crash occurs, Crashlytics is there to keep record.
Considering Škola OnLine's massive user base, it’s crucial to keep track of which app modules are actually being used, identify any crashes, and measure the duration of user actions. Google Analytics for Firebase provides a solution for gathering and analyzing these critical statistics. We use Analytics to track the frequency of various user actions, such as using individual features and visits to different screens.
Firebase Crashlytics, another powerful tool in our arsenal, detects and tracks all unhandled code exceptions and crashes. We can categorize these occurrences by device, user, or operating system. Thus, it serves as a big helper in troubleshooting and resolving bugs.
Additionally, to measure our API response time, we use Firebase Performance, which monitors all API requests. That is how we detect potential server outages.
We also use Firebase Messaging for push notifications distribution and App Distribution for delivering the latest app development versions to testers.
For logging, we use the
loggy package that helps us customize logs. This way, we can forward logs to tools we want to use for a given app flavor.
Multiple user profiles
One exciting feature of the ŠkolaOnline app is the support of multiple user accounts, allowing users to switch between them. Usually, applications offer active sign-in against one profile, and if a user needs to hop onto a different account, they have to log off entirely and then switch. Implementing this feature was more challenging than we initially thought.
For instance, handling received notifications posed a significant challenge. The application must handle various scenarios, such as opening notifications for multiple accounts, leaving unfinished forms, and switching to the new one. Or, for example, what the application does when a profile receives a notification, where the user needs to fill in the password again because the session has expired.
Fig. 2: Management of multiple profiles, including children-profiles.
With Flutter making everything as declarative as possible, Navigation 2.0 comes into play. Essentially, it’s a redesign of imperative operations around the navigation stack into declarations of the stack and moving between “states.” Because Navigation 2.0 is very generic and hard to implement correctly, we decided to go with the officially supported package
go_router, which provides a convenient API around the router. You define a list of routes, each initialized with its identifying path and a screen builder. The cool thing about it is you can nest routes.
We use dependency injection using the service locator library
get_it to centralize dependencies and their initializing across the app. This allowed us to set up dependencies to be initialized as singletons, factories, or even lazy singletons. It can also help with testing because we can easily swap implementation for a mocked version with zero effort.
When using blocs or services in the app, we do not pass get_it’s location to a constructor call but rather to a constructor definition. Therefore we write it like this:
this.param = param ?? GetIt.I.get<MyDependency>(). That lets us manually provide the dependency, for example, when testing, while also not passing anything, and it still figures out.
Currently, state management is being discussed everywhere, and Flutter is no different. We opted to use the Bloc library, which provides predictable state management for our needs. With Bloc, you can go with two approaches: blocs or cubits.
In ŠkolaOnline, we tend to use cubits, which excel in their simplicity. You don’t have to define events separately; you use functions as events, which shaves off a bit of excess code. We use blocs in places where they’re beneficial, such as event transformations.
For small local UI states, we often don’t even use Bloc but rather
flutter_hooks, with which we can keep it simple while using stateless classes.
As we all know, apps need to feel natural and smooth, which means including a decent amount of transitions and animations. Naturally, we couldn’t let this aspect fall behind the rest of the app. Our ways to smoothen the experience were action-based, eye-catching features or arbitrary features.
The entire app is focused on scrolling. In some places, we even use slivers, which we described in a dedicated blog post about the basics of scrolling. Simply put: slivers are great low-level APIs for scrolling that you should use in case
ListView is insufficient. One of our pretty animated features is an app bar that moves stuff on the scroll, so you never miss them when scrolling down. We developed the
sliver_app_bar_builder for it. Check out why we build it.
Fig. 3: How sliver_app_bar_builder is used in the ŠOL app.
Tweens and lerps are there to smoothen transitions, making changes look more appealing in certain areas. While playing with entry animations, we found the
entry package to be extremely helpful and a good companion to screen smoothening.
In the Škola OnLine app, we store a small amount of data on the device. These are secure or non-secure data, with different storage types utilized based on that. For instance, we store non-secure data in
SharedPreferences on Android and
NSUserDefaults on iOS using the
shared_preferences package. We keep just a few things there, such as whether the user saw a help tooltip or completed the onboarding. Most of the time, they are simple booleans as the mentioned examples, or we store numbers related to an active profile. For user profile data, we use
Drift, which is type-safe, fast, and easy to use while also sporting a
sqlite database underneath.
Separation of concerns
We separate our project into different layers and features that separate concerns. Each part can have different models, and to separate their responsibilities, we map between them. But since manual mapping is a boring process and no other library was sufficient for us, we created a a dedicated library called
auto_mappr. It's already built with many features, and it helps us automate mapping. There’s even a whole blog post about it, so check it out to learn more.
Fig. 4: Selection of our custom tools.
Keeping the codebase strict
Keeping the code consistent and compliant with standards matters to us more than we’d like to admit, especially in big code bases, which is why we went through the process of selecting our list of most useful strict lints. Essentially, it’s a list of strict rules that helped us write better code with strict typing, inference, and unified code using our
netglade_analysis package that has predefined Dart lints and DCM lints.
Thus, no matter how many developers work on which project, it’s easy to move between them or help on another project.