Guide Diary is a Flutter application that enables documentation of fishing trips. This app records images, trip descriptions, environmental conditions, and any income and expenses associated with the trip, which can be edited afterward. The trip information can be shared on socials or downloaded. Trips can be viewed, sorted, and searched from the main listing page.
As stated, the application is written with Flutter, which allows it to run across multiple platforms from a single codebase with a minimal amount of platform-specific configuration.
Flutter Overview
Flutter is a free and open-source software development toolkit created and maintained by Google for the purpose of building cross-platform applications. It is backed by the Dart programming language. Flutter applications are natively compiled for each platform they run on rather than being interpreted. This allows them to utilize platform-specific functionality while still having a single code base.
Flutter is a declarative framework. Declarative approaches define what the desired appearance or outcome should be when provided with specific input. It relies on the framework or abstracted implementation details to determine how to accomplish that outcome. This contrasts with imperative approaches, which specify the specific steps to take to create an appearance or outcome.
Widgets are the fundamental building blocks of Flutter. A widget
defines the appearance and behavior of a view based on any given application state. Nearly everything in Flutter, from the structural layout of the page to input fields and text display, is defined by widgets. The Flutter framework includes a large collection of commonly used widgets to facilitate quick development for common tasks.
These widgets are organized into a tree structure. When a state change occurs in the application, the impacted portions of the widget tree are rebuilt to reflect the state transition. The BuildContext
allows access to the location of the widget in the tree.
In addition to the widgets, Flutter provides tooling to help developers write robust and portable applications that adhere to best practices. These include but are not limited to:
- Testing and debugging tools
- Accessibility and internationalization
- Performance profiling
- Platform-specific configuration support
The Flutter documentation provides introductions and tutorials for building a basic application as well as “cookbooks” to demonstrate common tasks.
Architectural Overview
Guide Diary was built using the recommendations from the core Flutter documentation with minimal reliance on other libraries. This allowed us, the development team, to explore the core functionalities for a simple application. While this approach introduced some limitations on the architecture of the application, we still had a lot of flexibility in core architecture.
Engineers from a variety of backgrounds were able to bring development patterns from other frameworks and languages. The Flutter documentation includes multiple introductions that are tailored to developers coming from other ecosystems. These were particularly helpful in onboarding new developers to the Flutter mindset and determining which patterns fit into the ecosystem.
Before getting into the application, we had to determine if we would be using the Cupertino or Material widget library. Because it is designed for multiple platforms, Guide Diary uses the Material style and widgets.
We also determined that there were several core architectural decisions that would impact the file structure and widget tree. In order to create a cohesive application and minimize refactoring, the navigation strategy, the application level state management, and the abstraction of the data layer were determined early in development. I’ll discuss each decision in detail below.
Navigation
Every application needs a navigation strategy that makes sense for the platform and users. Choosing a navigation strategy introduces challenges when building an application that uses a single code base but runs on multiple platforms. This is because mobile platforms and web apps may have different navigation requirements.
Flutter Navigator
In Flutter, the Navigator
serves as the base of navigation. As navigation actions occur, the pages that are displayed are pushed or popped from the stack. The stack is maintained in the BuildContext
and these pages are referred to as Routes
.
This setup is referred to as imperative navigation because it relies on method calls to inform the Navigator
how to manipulate the stack rather than describing the desired state. For small applications that do not utilize deep linking and do not have complex navigation requirements, this is generally sufficient.
Note: The Navigator
also supports navigation with named routes, but they are no longer recommended for use. See the documentation, here.
As applications grow in complexity, more robust routing may be needed. Additionally, it can be difficult to switch between the imperative mindset for routing and the declarative mindset for general Flutter development.
To meet these more complex needs, the Router
was developed. This introduces a declarative style of routing that supports deep linking and other advanced use cases. It is compatible with Navigator
, so a single application may use both strategies. Directly consuming the Router
is possible, but it requires boilerplate code and a deep understanding of navigation. Because of this, the Flutter team currently recommends using a routing package, such as go_router.
Guide Diary Navigation Strategy
Guide Diary utilizes the basic imperative routing strategy.
Understanding that the Navigator
uses a stack is important to keep in mind when planning a route through the application. The Navigator
can push any route to the stack, but it can only navigate back to routes that are already on the stack.
This can sometimes impact the structure of the widget tree in the application. An example of this occurred on the Guide Diary project for the workflow of adding the first trip.
For the standard workflow, Guide Diary shows the list of trips as the base screen in the application. This listing route is pushed onto the stack when the application is opened. The user adds a new trip using the bottom navigation bar, pushing the Add Trip
route onto the stack. When the trip is saved, the stack is popped to navigate back to the listing route.
When no trips have been recorded, Guide Diary shows a splash screen instead of the trip listing. The user clicks a button to navigate to add a trip. When the trip is saved, the application can’t pop to navigate back to the listing route because it is not present in the stack.
This can be handled by adding conditional logic to the Add Trip
widget to check if this is the first trip, then deciding to either pop the stack or push to the listing widget. While this works, it is not ideal. The Add Trip
widget should not contain conditional logic about its ancestry, and when it does, the back button may not behave as expected.
Rather than introducing conditional navigation to the child, we created a common parent widget for both the trip listing and the splash screen. This widget checks if a trip exists, then determines which child should be displayed. The Add Trip
navigation can reliably pop back to this parent widget because it will always be present in the stack.
State Management and Updates
Flutter allows widgets to be defined as either stateless or stateful. A state that can be encapsulated within a single widget doesn’t require larger state management.
A parent can pass state information to child widgets during construction, and then the child can trigger updates to the parent utilizing callbacks. This will trigger a rebuild for all impacted widgets. This strategy allows the state to be shared across widgets by lifting it up to a common parent.
For small applications, this method of managing the state may be sufficient. However, it becomes cumbersome at scale, especially in nested components or when the components need to use the same state but are not close relatives in the widget tree.
Provider
To help reduce the amount of state that needs to be passed through constructors, Flutter provides the concept of InheritedWidgets
and InheritedModels
. An InheritedWidget
allows descendent widgets to inherit the state and receive notifications of state changes from an ancestor in the widget tree.
InheritedModels
are a type of InheritedWidget
that allow dependents more granular control over the specific state of the ancestor that they consume. These two concepts can be utilized directly for state management, but the Provider
package provides a more usable wrapper.
There are many other state management options available, and we evaluated several others. However, the Flutter documentation recommends starting with Provider
for simple applications.
The Provider
has several key elements:
ChangeNotifier
– an observable that can be extended. The extending classes encapsulate the application state and provide change notifications when that state is updated.
ChangeNotifierProvider
– provides an instance of the ChangeNotifier
and is placed in the common ancestor of the widgets that require access to the ChangeNotifier
state.
Provider.of
– When a descendent widget needs to access the type extending the change notifier, Provider.of(context, listen)
looks back in the provided BuildContext
widget tree for when the type was instantiated via the ChangeNotifierProvider
.
The listen argument is a flag indicating if the descendent widget should subscribe to state changes; when it is off, the widget can call methods on the ChangeNotifier
without being notified of state changes.
If the widget does need to be notified of state changes, then the flag can be passed as true, but it is generally recommended to use a Consumer
instead.
Consumer
– The Consumer
widget uses Provider.of(context, listen:true)
but provides some extra functionality. Directly listening to the provider state changes can result in unnecessary rebuilds if only some of the descendants rely on the state.
The Consumer
allows more granular control over which components will rebuild when the state is updated. Consumers
should be placed as low as possible in the widget tree to minimize rebuilding.
Guide Diary State Management
In Guide Diary, the majority of the state can be encapsulated within individual widgets. However, the actions of adding, editing, or deleting a trip need to make calls to the data persistence layer and the listing page should reflect those changes.
To facilitate this, a single class, EntriesService
, extends the ChangeNotifier
. The EntriesService
contains the list of trip entries along with the methods to retrieve, create, modify, and remove trips from the list. The EntriesService
maintains the state of the list by interacting with the data persistence layer and notifying when updates occur successfully.
The EntriesService
is registered to the widget tree using a ChangeNotifierProvider
. Because the service is required by the splash screen, the listing page, and the add trip button on the bottom navigation bar, it is registered at their common ancestor in the Widget Tree.
In this application, none of the widgets needed to directly subscribe to the state of the entry model. They all use Provider.of(context, listen:false)
, which enables them to retrieve the state of the list and trigger actions. All of the updates to the EntriesService
state trigger navigation actions, so the widgets do not need to listen for the model changes. Although, if this changes in the future, the internal state management code can be separated from the logic interacting with the external services to provide a clearer separation of responsibilities.
Data Persistence Strategy
Guide Diary implements the Repository Design Pattern to add a layer of abstraction between the services and the persistence layer. An abstract EntryRepository
class defines the methods for interacting with the persistence layer. This provides a contract to code against with the flexibility of switching out the specific persistence layer implementation later on.
The current application state utilizes the local device for persistence. The Flutter cookbook outlines three foundational data persistence options for local device storage:
- For simple key-value pairs, use the shared_preferences plugin
- For reading and writing to files, use the path_provider plugin and the io library from Dart to read and write from files.
- For larger data sets or more complex cases, use a local database such as the sqflite plugin.
We ended up using all three methods of persistence at various points in the application development; the repository abstraction simplified switching between them without having to update the rest of the stack.
During testing, we observed that there were differences in performances across platforms. While the Flutter code is the same, the actual implementation details vary enough that it is important to do performance benchmarking and testing against different devices and platforms early and often in the development process. This was particularly true with SQLite on Android.
Application Structure
The Scaffold
serves as the outer layout of the application. It allows AppBars, body content, and BottomNavigationBars
to display consistently throughout the application lifecycle.
The body is primarily composed of the listing page and the form to add or edit trips. Let’s get into it.
The Listing Page
Like many mobile apps, the listing screen for trips is the central component of the application. Because the concept is so common, Flutter provides two core widgets that handle a wide variety of use cases but can also be extended easily.
For simple list implementations the ListView
provides a layout to display children one after the other in a single scroll direction. The ListView
has a variety of constructors that support lazy loading of the list or allows all children to be provided when the view builds.
The ListView
can display any type of widget, but the ListTile
provides a fixed height row with up to three lines of text with optional leading and following icons. This is the standard layout for many list-based applications.
The Guide Diary initially utilized the ListTile
, but the layout could not be adjusted for the padding that was required on the leading images. So, the main list uses a custom widget that uses the Card
and Row
components for the layout. This was wrapped by a Slideable
to support gestures for the action icons.
The listing page only shows one item type. However, it is common for a listing page to show different elements within a single list, such as grouping common elements under a header widget. The Flutter cookbook provides a straightforward pattern for mixed lists.
The search and filter functionalities use a TextField
and Popup Menu
to collect input, which is then passed to the EntriesService
. The EntriesService
makes all required state updates and then notifies the listing page with the updated results.
Add/Edit Trip
For the app to be functional, we also need the ability to add and edit trips. Flutter provides the basic building blocks for forms with Form
and provides examples of input and field validation in the form cookbook.
The Form
groups different fields together with a GlobalKey
that serves as a unique identifier for the group. It also creates a FieldState
to allow the form to be saved, reset, and validated.
The individual entries on the form extend FormField
, and text input fields are generally implemented with the TextFormField
. The TextFormField
calls the onChanged()
callback each time the content of the field changes.
For more granular control over the content of the text within the field, the TextEditingController
can be used to access the text. This is helpful for setting the initial value of the form and allowing it to persist if the form is reloaded. When the TextEditingController
is used, it also needs to be explicitly disposed of to discard all resources.
While images are displayed on the same page as the rest of the fields, they are not part of the form. Instead, they are uploaded using the image picker
package and displayed using Image
.
In Guide Diary, the same form backs both the edit and add screens.
Deployment Process
Each platform has a unique process for publishing the application. Because Flutter code is compiled rather than interpreted, separate builds need to be run for each supported platform.
The Flutter docs walk through the steps to prepare a release and how to publish the app after it has been built. The releases of the Guide Diary application for Android and iOS were both done manually.
Flutter releases and deployments can also be integrated with CI/CD tooling. CodeMagic, Bitrise, and AppCircle all have built-in Flutter support. Flutter is also compatible with the open-source release and deployment tool, Fastlane.
Conclusion
Overall, Flutter is a powerful option for writing cross-platform applications. There is a large learning curve up front to become familiar with the layout structures, the available widgets, and darts. However, once an application has established the core architectural patterns, it is simple to add new common functionalities and build custom widgets that can be reused throughout the application.