Adapting our Flutter mobile app to web and desktop

Although we’re using Flutter to build the Invoice Ninja web app it wasn’t our original plan. We were about six months into our big rewrite using Vue.js when we had a chance to try Flutter Web (at the time called Hummingbird) for the first time and were instantly convinced. Sure… performance isn’t quite up to native, but being able to share nearly all of our code across mobile, web and desktop is too big an advantage to pass up.

Update: In the end we decided to use Flutter for desktop and mobile and React for web. Sharing code is great but not if it comes at the expense of the final product, Flutter Web is truly amazing but not the right fit for our use case.

That being said in general you can’t just `flutter run -d chrome` and expect your mobile app to be a great web app. We found there were two areas we needed to focus on: UI layout and keyboard support.

UI Layout

I consider our app to be a relatively standard business app. It’s built with flutter_redux and uses a REST API to support CRUD actions (create, replace, update and delete). The primary views in the mobile app can be broken down to three parts: the sidebar navigation, the list view and the detail view.

A common approach to handle the mobile/desktop differences is to use a LayoutBuilder. While we do use it in some places in the app to adjust the layout slightly we don’t use it to adapt the entire UI. The navigation models between mobile and desktop are too different to make this an optimal experience on both platforms.

In the mobile app each screen is its own route. If you view the client list and then select a client there would be two routes on the stack. For the desktop version we instead use a ‘route’ string which the UI binds to. If the route is /invoice/view the UI will show the invoice list with the view panel on the side.

This dual navigation approach is relatively simple to implement in Flutter. In the main MaterialApp widget on mobile we set the list of routes whereas on desktop we provide an onGenerateRoute function instead.

Another example of how we adapted the mobile UI is the dashboard. On mobile it’s a single screen with multiple tabs whereas on desktop we use the two primary tabs as the center content and then show the remaining tabs on the side. The scrolling is linked to the selected tab so changing one automatically changes the other.

In the mobile app the client screen uses ListTiles to display the client’s invoices, quotes, etc. If you tap the invoices option you see the invoice list filtered by the current client.

The desktop app behaves the same way, when you tap on the invoices option the invoice list is filtered by the selected client. We show a filter menu at the top implemented using the awesome overflow_view package from Romain Rastel which enables easily changing between all of the client’s records.

There’s also an option to show the filter as a sidebar on the left side of the screen. This provides a broad view of the data, enabling you to easily navigate between records while having your context remained fixed.

Keyboard Support

The built in date and time pickers have keyboard options however if your users need to select multiple values (ie, in a table) they can be cumbersome to use.

To solve this we implemented date, time and duration pickers which make it easier to select values with the keyboard. All of the widgets use the same basic principle: we use a FocusNode to detect when the text field has focus, parse the string to get the new value to display as a preview and then update it when the widget loses focus.

Odds and Ends

One challenge we ran into once we deployed the app is since we’re using a PWA when we release a new version users may continue using the old version for some time. Even force refreshing the page will still use the cached version which (I believe) can hang around for up to 24 hours. Majid Hajian was kind enough to share this solution which will notify the user if a new version is available.

We’re currently using local storage to persist the web app state. While it’s possible to use the browser settings to clear the storage I’ve found it’s useful to support a ?clear_local=true property to clear it, when the backend sees the flag set it displays this JavaScript to clear the storage.

      window.onload = function() {

If you need more control over how tab focus traversal works you can wrap your widgets in a FocusTraversalGroup widget and set the policy, we use WidgetOrderTraversalPolicy in our app.

Final Thoughts

Although Flutter Web is an amazing technology we haven’t used it to rebuild our entire web app. We use Flutter for the admin portal which is used regularly by our users but the client portal which is used less often by more people is built using standard HTML/CSS. We think the combination of the two provides the best performance profile for each group of users.

I’d also like to thank Clemens Mol for his amazing contributions to the design, his input greatly improved the overall look and functionality of the design.

Thanks for reading! I’m on Twitter at @hillelcoren, I record a podcast about Flutter available here and you can sometimes catch me recording music on my side-project (also built with Flutter).