Flutter Modules: Enabling feature parity across our estate

JK

April 16, 2025

Published by Jake King-Lee

Bright HR SVG

Introduction

At BrightHR we utilise Flutter to build our BrightSafe On The Go (BSOTG), PoP, and Advice apps. Flutter enables us to build once and deploy to multiple platforms, simplifying and speeding up development.

In 2023 we began development on two new features; Praise (for employee recognition), and Learning Management (for E-Learning). We required these features to be available within BSOTG and our native BrightHR apps. We needed to be able to build quickly, achieve feature parity, and to release simultaneously.

Flutter Add-to-App Modules

We decided to build the Praise and Learning Management features as Flutter add-to-app modules. Add-to-app is a way of building a Flutter app as a module and integrating it into an existing native iOS or Android app. This approach enabled us to build Praise and Learning Management once and add it to multiple apps, saving on development time.

However this approach also gave us a few technical challenges to deal with:

  • We wanted to integrate Learning Management into our existing Flutter app (BSOTG) as well as our native apps (BrightHR). This added complexity into how we built the Learning Management module as we had to ensure it could run in all three environments (Flutter, iOS, Android).

  • We quickly learned that it is not possible to add multiple Flutter module repositories to a native app. We had to combine Praise and Learning Management into a single repository to solve this. We named this repository flutter_umbrella.

  • Displaying a Flutter module as a partial view (a Flutter view embedded in a native view) is much more difficult than expected.

Adding Learning Management to BSOTG

Adding flutter_umbrella to BSTOG was trivial. We simply added it as a dependency in our pubspec.yaml.

Note: We use onepub.dev to manage our internal Flutter/Dart packages.

BrightSafe/lib/pubspec.yaml

dependencies: 
	flutter_umbrella: 
		hosted: https://onepub.dev/api/xyz/ 
		version: ^1.0.0

Adding Learning Management to BrightHR

Note: All native code shown is from our iOS app written in Swift. The same principles stand for the Android app.

In order to add the flutter_umbrella repository to our native iOS and Android apps, we added it as a git submodule.

In order to display the Flutter modules in our native apps we have to explicitly tell each Flutter engine to start and render the UI. We did this by creating a unique entry point for each of our modules inside of the flutter_umbrella repo:

flutter_umbrella/main.dart

@pragma('vm:entry-point')
Future<void> praiseMain() async => praise_main.main();

@pragma('vm:entry-point')
Future<void> learningManagementScreenMain() async => learning_management_screen_main.main();

@pragma('vm:entry-point')
Future<void> learningManagementDashboardCardMain() async => learning_management_dashboard_card_main.main();

We then initialise a Flutter engine inside of a native app like so:

brightHR/flutterService.swift

var flutterEngines = FlutterEngineGroup(name: "multiple-flutters", project: nil)
learningManagementScreenEngine = flutterEngines.makeEngine(withEntrypoint: "learningManagementScreenMain", libraryURI: nil)
learningManagementScreenEngine.run()

Git submodule vs prebuilt

As explained above in order to embed our Flutter modules into our native apps we had to app flutter_umbrella as a git submodule to our native app projects.

The downside of doing this is that it means that each time the native app is built, it will first have to compile the Flutter modules. This meant that all of our native developers had to have Flutter installed. This caused issues amongst our developers as any changes to the Flutter module had to be communicated across teams.

A solution to this is to instead pre-build the Flutter modules rather than using git submodules. We successfully did this with the Android app by pre-building the Flutter modules as an aar file. This meant that our Android developers no longer needed to have Flutter installed and build times were much faster.

Communicating between Flutter Modules and the native apps

Sending data to and from native apps to Flutter modules is done via method channels. This could be data such as; auth tokens, API endpoints, push notification data, etc.

Example of sending data via method channels

In order to fetch user data from a module we need access to the user’s OAuth token from the BrightHR app.

Here we are creating a method channel inside of the Flutter module and calling the getAuthToken method.

flutter_umbrella/lib/learning_management_native_services.dart

// Create method channel
MethodChannel _methodChannel = MethodChannel('com.brighthr.learningManagement/auth');

// Call the "getAuthToken" method on this method channel to get user's token from the native app
Future<String> getAuthToken() async {
  final String? token = await _methodChannel?.invokeMethod('getAuthToken');
  return token;
}

In our native app we set up a method channel with the same name and return the user’s auth token when the getAuthToken method is called.

brightHR/flutterService.swift

// Create method channel
let channel = FlutterMethodChannel(name: "com.brighthr.learningManagement/auth",
                                           binaryMessenger: learningManagementScreenEngine.binaryMessenger)

// Set up a method channel to return a user's auth token
channel.setMethodCallHandler { (methodCall, result) in
            switch methodCall.method {

            case "getAuthToken":
                self.tokenService.getAuthToken({ _, _ in
                    let token = self.tokenService.accessToken()
                    result(token)
                })
            
            default:
                result(methodCall.method)
            }
        }

Issues and workarounds

During the course of development we came across unforeseen issues which resulted in a lot of “creative” workarounds.

Full screen vs partial view

An issue that we did not expect was the differences between displaying a full screen view and a partial view. Displaying a full view was easy because just had to tell the native app when to display the full view. i.e the user taps a button and we navigate to the Learning Management Flutter module.

Add-to-app

A requirement for the Learning Management feature was that we displayed a card on the dashboard that shows the user’s assigned courses. Displaying this partial view was tricky because the size of the view will change depending on the content within.

Sizing the partial view

When displaying a partial view within a native app we are required to pass in a height parameter. This would be fine if we had a pre-defined size for the widget, however in our case the size of the widget can change depending on the user’s state and number of assigned courses. This resulted in our partial view either being cut off or not displaying at all.

As a workaround, we had to calculate the size of the widget upon render, then use a method channel to pass this height value to the native app and set the height of the partial view.

Pseudo code for getting height of widget from Flutter module:

flutter_umbrella/learning_management_dashboard_card.dart

// Create a Key that will be passed to the widget we render
final GlobalKey _key = GlobalKey();

Future<double> _getHeight(GlobalKey key) async {
  final RenderObject? obj = _key.currentContext?.findRenderObject();
  if (obj != null) {
    final RenderBox box = obj as RenderBox;
    height = box.size.height;
    return height;
  }
  // If we fail to get height of this widget, return a default value
  return 100;
}

void initState() {
  super.initState();
  final double height = await _getHeight(_key);
  
  // Call a method channel in the native app to update height of partial view
  methodChannel?.invokeMethod('updateHeight', <String, dynamic>{ 'height': height});
}

The method channel that receives a height and updates the partial view in the native app:

brightHR/flutterService.swift

@Published var learningManagementCardHeight: Int = 0

let channel = FlutterMethodChannel(name: "com.brighthr.learningManagementCard", binaryMessenger: learningManagementDashboardCardEngine.binaryMessenger)

channel.setMethodCallHandler { (methodCall, result) in
    switch methodCall.method {
    /// Updates the height of the FlutterView view.
    case "updateHeight":
        if let height = (methodCall.arguments as AnyObject)["height"]! as? Int {
            self.learningManagementCardHeight = height
            result(height)
        }
}

The partial view SwiftUI component that renders our dashboard card and takes height as a parameter:

brightHR/learningManagementDashboardCardView.swift

struct LearningManagementDashboardCardView: View {
    @Binding var height: Int
    
    var body: some View {
        FlutterView().frame(minWidth: 200, maxWidth: .infinity, minHeight: CGFloat(height), maxHeight: 700)
    }
}

Horizontal scrolling

Another unforeseen issue was around adding a horizontal scrollview inside of our partial view.

Since the LearningManagementDashboardCard was inside of a vertical scroll view in the native app, whenever the native app detected a scrolling gesture it would scroll vertically. This made scrolling horizontally on the LearningManagementDashboardCard very difficult, if not impossible for users.

To get around this issue we wrote some code inside of the Flutter module that would detect when a user started dragging. Once we detected that they were scrolling horizontally we called a method channel that would disable vertical scrolling inside of the native scroll view.

This was a very hacky approach but it did fix our scrolling issues.

Keeping multiple modules in sync

Due to us running two Learning Management modules independently, it took some work to ensure that they remain in sync.

For example, when a user completes a course in the LearningManagementScreen, we need the LearningManagementDashboardCard to display the correct number of assigned courses. In order to do this, we have to call a method channel from LearningManagemntScreen to the native app, and then call another method channel in LearningManagementDashboardCard to force it to refresh its data.

Debugging and development

When developing an add-to-app module, you cannot launch and debug the app as you normally would. This means, no logging, no breakpoints, and no hot reloading. You can however run the native app and then use the terminal command flutter attach to attach to a Dart VM. You can then open Dart Dev Tools to get insights into what your module is doing.

Dart Dev Tools

This worked when we were only running on Flutter engine. However, once you have multiple Flutter engines running at once, there is no way to specify which Flutter engine you want to attach to. This meant that during development we were severely limited in what kind of debugging we could do. This resulted in us depending a lot on print statement debugging and constantly re-running of the app. This was very painful but we were close to the finish line so did not have time to think of an alternative.

Once the Flutter modules were in the app and released we were able to think about debugging more clearly. We decided to develop a simple Sandbox app that would run all 3 of our add-to-app modules. Thankfully, because each module was built to run independently of it’s environment, this was a relatively simple task. This means that we can now develop from inside the Sandbox app for quick development and debugging. Then once we are happy we can update the flutter_umbrella repo/package inside of our other apps.

What we learned

  • Flutter add-to-app modules make it easy to share UI and logic between multiple apps. This reduces the time spent re-building features in multiple languages or frameworks. However integrating a Flutter module can be quite complex.

  • All mobile developers are required to have Flutter installed and up to date in order to run the native apps.

  • If you want to add multiple add-to-app modules you need to combine them into one repository.

  • Flutter add-to-app modules are not easy to develop or debug. You cannot easily attach to a Flutter engine when there are multiple running. Building our sandbox app from the start would have saved us a lot of time and effort.

  • Displaying partial views with dynamic sizing is much more difficult than full screen views.

  • Carefully consider if this is the correct approach for your app. There are a lot of unforeseen issues with this approach so it should be considered carefully before building.

  • Building the Flutter add-to-app modules as aar files (Android) will simplify your development and build processes.

References:

https://docs.flutter.dev/add-to-app

https://onepub.dev/

https://github.com/flutter/flutter/issues/39707#issuecomment-569120877

Registered Office: Bright HR Limited, The Peninsula, Victoria Place, Manchester, M4 4FB. Registered in England and Wales No: 9283467. Tel: 0844 892 3928. I Copyright © 2024 BrightHR