One of the more technically challenging tasks that I took on at insuretech Flip Insurance was to improve the overall state management architecture of their customer-facing Flutter mobile-app. The Flip mobile-app had been used by customers for over two years by the time I join the team. It allows our customer-users to sign up for an account, make purchases, lodge insurance claims, register purchases via event code or scanning QR-codes.
But as the app grew and changed with feature changes and product experimentations, we started noticing the same speedbumps when debugging state management and trawling through Datadog error logs.
From time to time, Datadog reported 401 error responses from the backend platform-side that we traced back to the mobile-app. We’ve also seen some state management jitters immediately after successful authentication that we suspect may be a race condition from multiple platform API calls. It had been flaky and repeatedly evaded my best attempts to reproduce using local simulators and on Test Flight/App Tester.
Many of our customer-users are adventurous folks and often use the mobile-app on location such as in remote mountainous regions. We’ve noticed that the mobile app was not able to recover gracefully whenever internet connectivity was interrupted leading to cascading error logs. Errors were also not being surfaced correctly as being connectivity-related or platform service availability at a more granular level.
Because the Flutter app is constantly in an iterative state, separation of concern had not been applied consistently across the codebase in the past. In particular, the state machine governing authentication status was awkwardly co-located within the view model for the splash screen. This is not ideal for the Flip app because users require both an authenticated Okta session and a valid profile registered on the Flip platform in order to access any of the app. The splash screen view model took on more responsibilities than it should have — handling authentication, tracking authentication state and navigation behaviours.
We relied on the outdated and unmaintained uni_links package to handle our deeplinking events by writing our own NavigationService. As deeplinking became an increasingly vital point of entry for various different user journeys and product experiments, this NavigationService was up for a review. Coinciding with the 16kb page size upgrade requirement for Android deployments first flagged by Google back in December 2024 (https://android-developers.googleblog.com/2024/12/get-your-apps-ready-for-16-kb-page-size-devices.html), we decided to replace uni_links with a more native, resilient deeplinking navigation strategy.
In short, code quality, elusive error logs and increasingly outdated package dependencies meant we had to bite the bullet and take the plunge.
The uplift did not call for a complete overhaul. Instead we made specific strategic changes towards some services, removed uni_links entirely for navigation, and applied a disciplined pattern to how state and business logic can be managed.

When the app spins up at main(), it lazily registers multiple service locators which accesses various Flip platform domain endpoints such as customer profile, product rules, claims, as well as external services such as Firebase remote config and connectivity_plus. The service locator pattern works well for the flutter app because we can access service data without having to pass props down the widget tree (dependency injection). We relied on Firebase to help the Flutter app manage A/B multivariate tests and feature flagging for maintenance mode and staged feature rollouts.
A global AppService background instance facilitates these services to constantly listen for changes to their respective state. From here, we were able to surface more granular error states here to handle specifically different Flip platform api errors and specific third party service errors (i.e Box file upload errors).
At the highest MaterialApp level, we’ve done two key things:
surfaced potential connectivity errors with a snackbar across the entire app. We relied on listening in to connectivity changes via the connectivity_plus package.
replaced a uni_links driven NavigationService to use auto_route package to listen for deeplink url triggers, match a sanitised url with a whitelisted route and pass along any query params. The query params will be in turn handed-off from the relevant route’s widget in the constructor through to the view model through
didUpdateWidget lifecycle method.
The authentication state machine was re-written and moved out of the Splash screen view models into its own dedicated AuthenticationService. We leveraged auto_route to set up route guards that can check the authentication status upon route change.
Because the mobile-app initialisation triggers multiple API calls to amass multiple streams of data and content, a separate loading route is configured to handle this gap when hydration has not been fully succeeded. The route guards will redirect gracefully to splash screen should the authentication status ever changes.
// pseudocode of the route guard
class AuthGuard extends AutoRouteGuard {
void onNavigation(NavigationResolver resolver, StackRouter router) {
AuthenticationService authService = serviceLocator<AuthenticationService>();
bool authenticationStatus = authService.stream.first.status;
if(authenticationStatus == AuthStatus.authenticated) {
resolver.next(true);
} else {
resolver.redirectUntil(
SplashScreen(onResult: (success) {
// if success == true the navigation will be resumed
// else it will be aborted
resolver.next(success);
},
);
);
}
}
}The structure of each user flows remained largely unchanged. Each route/flow uses the MVVM pattern built on provider that has been well battle-tested. Each has its own view models that acts as controllers to marshal different UI with business logic rules by tapping in on the global service locators, and persist data in presentation models. Parent PageView widgets then subscribe to these view models via the ChangeNotifierProvider and Consumers.
Additional request interceptors were added to dio api call handlers so that the Okta session can be re-authenticated should an access token expire. This provided an additional guard against making unintentional unauthenticated calls to the platform.
This refactoring was fun to reason through but was also quite challenging to me. I had to (re-)learn engineering concepts such as dependency injection, behavioural programming within the context of an existing codebase.
Please note: This is my best recollection of the solution. Much time has passed on since. And I fully acknowledge there can be other options to solve our issues.
I was able to complete all these changes successfully in mid 2025 and since then, it has brought about more clarity to error observability and much improved performance stability.
We were able to debug production issues more effectively being able to differentiate between errors in authentication, connectivity, platform, or external service api errors.
Clearer and consistent separation of concerns across all services
After two attempts at digging into every dependency, we finally succeeded in clearing the 16kb page size update for Google. Wrangling teetering lists of dependencies is always been an interesting housekeeping exercise and glad that Google helped us out this time with the check_elf_alignment.sh script (https://developer.android.com/guide/practices/page-sizes).
Having said that, there still remains some flaky race condition issues here that we haven’t fully grokked through. One in particular is with logging out of an authenticated session where the authentication status is not correctly aligned with Okta. Perhaps an adventure in another universe in another timeline 🤷♀️
Although HCF Insurance has discontinued Flip Insurance on 1 August 2026, I’m still genuinely excited by where our work could have gone on from here.
With this uplift, it should be straight forward now to whitelist certain user flows from the authentication check in the auto_route route guards. For example, we can allow unauthenticated users to explore buying an insurance product right up until Stripe checkout — all without having to first sign up for an account. The business often conduct sales outreach with new customers directly on the field at major competitive events, well patronised ski resorts, and local sports club events.
By opening up more of the app experience to newcomers, I think it can boost sign-up conversion by allow visitors/customers to explore the product and app literally before they buy.
Having a clearer and consistent separation of concerns across all routes means that there is a clearer pattern for grouping business logic, creating new routes and tapping into the global state without unintentionally re-triggering state updates. For example, adding an onboarding slide deck to the Splash Screen route could simply be adding a child PageView widget to the existing Splash Screen View.
As an aside to the state management uplift, with flutter’s latest release 3.47 (https://flutter.dev/blog/whats-new-in-flutter-3-47) decoupling the Material and Cupertino design system from the SDK, it is exciting to have a native pathway to maintain the similar MaterialUI and Cupertino variants of Flip's design component system. In fact we could take it one step further to entirely move our design component system into its own separate repository and inject it as a standalone pubspec package dependency into the core mobile-app. This could speed up the release cycles, improve code hygiene, and streamline reconciling breaking changes when upgrading dependencies.
With the app being more stabilised now, we could also refocus on expanding our design component system with more UI gestures like swipes, generated animations and accessibility features to add more visual flair for brand personality.
Our testing infrastructure is still maturing at Flip, and I’ve not yet had a chance to explore how the Kent C Dodd’s testing trophy (https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) could be implemented at speed. All of our front-end and back-end test cases are manually configured by the QA engineer using AIO and integrated with Jira tickets (https://www.aiotests.com/). With this technical refactoring, there wasn’t a single aspect of the mobile-app that was not impacted in some way — from checking for internet connectivity through to the first paint. The resulting regression testing was a laborious, manual process running through every known case scenario.
So, for a testing workload of this size, I think this would be a great opportunity to explore automated testing using AI-assisted tooling like Patrol, ProofShot and testRigor. I’m excited by how test case scenarios can now be written as natural language prompts to let LLMs run through case scenarios like a human-user. This could open up the technical QA process to involve non-technical team members like our Product Manager who can bring in an insurance or regulatory lens to testing. It would certainly solve our knowledge-sharing challenges and raise the confidence in our engineering deliverables without compounding mental overhead.
I think the portfolio of projects that we did at Flip Insurance would have been a perfect, fertile sandbox to explore what is possible with AI-augmented testing methodology for both engineering and product development.