Offline-First Applications with Flutter

By Charles LAZIOSI
Published on

Implementing an offline-first application with Flutter as the front-end requires a thoughtful approach to data synchronization, caching, and state management. The goal is to enable the app to function effectively without an internet connection, using local storage, and then synchronize any changes with the server once the connection is restored.

Offline-First Architecture

Offline-first architecture is a design approach for mobile applications (and other software) that prioritizes the app's functionality and performance in offline scenarios. This means that the app is built to function without a constant internet connection, ensuring that users can access core features and data even when they're not connected to the web.

Key characteristics of offline-first architecture include:

  1. Data Caching: The app stores relevant data locally on the user's device so that it can be accessed without an internet connection. Data might include user preferences, application state, or content that has been previously downloaded.

  2. Synchronization: When a connection is available, the app synchronizes local changes with a remote server. This ensures consistency between the data on the device and any backend systems.

  3. Conflict Resolution: The architecture often includes mechanisms to handle conflicts that may arise when changes are made to the same data from different devices or when offline changes are synced back to the server after a period of being offline.

  4. User Experience: Offline-first apps are designed with a seamless user experience in mind, minimizing disruptions due to connectivity issues and providing users with immediate feedback even when actions cannot be instantly synced with a server.

  5. Background Operations: Many offline-first apps perform background tasks such as pre-fetching data or updating content whenever a connection is available so that the latest information is ready for use at any time.

By adopting an offline-first approach, developers aim to create robust mobile applications that deliver consistent performance and usability regardless of network conditions, which can be particularly beneficial in areas with poor connectivity or for users who are frequently on-the-move.

Set Up Your Flutter Project

  1. Create a new Flutter project using the command flutter create offline_first.
  2. Add the necessary dependencies in your pubspec.yaml file:
dependencies:
  flutter:
    sdk: flutter

  connectivity_plus: ^5.0.2
  hive: ^2.2.3
  hive_flutter: ^1.1.0
  http: ^1.2.0

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2

dev_dependencies:
  build_runner: ^2.4.8
  hive_generator: ^2.0.1
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  1. Run flutter packages get to install the dependencies.

Configure Hive for Local Storage

To Initialize Hive and register a custom adapter FormDataAdapter in your Flutter app, you can follow these steps. Let's assume you have a model named FormData that you want to store using Hive.

Create your model class FormData:

import 'package:hive/hive.dart';
part 'local_storage.g.dart';

(typeId: 0)
class FormData extends HiveObject {
  (0)
  late String field1;

  (1)
  late String field2;

  (2)
  late String field3;
}

After creating your model, run the build runner command to generate the adapter:

flutter pub run build_runner build

This will generate a file named form_data.g.dart with your FormDataAdapter.

Now, let's set up Hive and register the adapter in the main() function of your app:

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'local_storage.dart';
import 'package:http/http.dart' as http;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize Hive and register the adapter.
  await Hive.initFlutter();
  Hive.registerAdapter(FormDataAdapter());
  runApp(const MyApp());
}

This will initialize Hive with the necessary adapters before running your app and open a box ready for storing instances of FormData. Make sure that any interaction with the database occurs after initializing it in the main function.

Remember to call Hive.close() when your app is being terminated to properly dispose of any resources used by Hive.

Set Up Connectivity Monitoring

Using the connectivity_plus package, set up a stream listener in your main app state class _MyHomePageState to monitor connectivity changes, which will be used to update UI and trigger synchronization. Give me a sample code

To use the connectivity_plus package in your Flutter app, you first need to add it to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  connectivity_plus: ^5.0.3
  ...

After adding the dependency, run flutter pub get to install it.

Next, you can implement a stream listener for connectivity changes in your _MyHomePageState class. Below is an example code that demonstrates how to do this:

final Connectivity _connectivity = Connectivity();
late Stream<ConnectivityResult> _connectivityStream;
ConnectivityResult _connectionStatus = ConnectivityResult.none;

This sample code sets up a listener that listens for changes in the device's network connectivity and updates the _connectionStatus variable. The _updateConnectionStatus method is called whenever there is a change in connectivity, where you can also trigger any desired actions like synchronizing data when there's an active connection.

void _updateConnectionStatus(
      ConnectivityResult result, BuildContext context) async {
    setState(() => _connectionStatus = result);

    if (result != ConnectivityResult.none && formDataBox != null) {
      final unsyncedData = formDataBox!.values.toList();

      for (var formData in unsyncedData) {
        bool success = await sendDataToBackend(formData);
        if (success) {
          await formData
              .delete(); // Remove from local storage after successful sync.
        }
      }
    }
  }

Implement Offline Data Saving

When there's no internet connection, use Hive to save form data locally by adding it into a Box named 'formData'. The method saveDataLocally() is responsible for this operation.

  void saveDataLocally() {
    final formData = FormData()
      ..field1 = _field1Controller.text
      ..field2 = _field2Controller.text
      ..field3 = _field3Controller.text;

    formDataBox?.add(formData);

    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Data saved locally!')),
    );
    clearFields();
  }

Implement Synchronization Logic

Create a method called syncDataWithBackend() which iterates over all saved local entries and attempts to send them to your backend server through an HTTP POST request (sendDataToBackend()). Successful synchronization should remove data from local storage.

 // Sync data with backend
  Future<void> syncDataWithBackend() async {
    if (_isSyncing) return; // Prevent multiple sync attempts simultaneously

    if (_connectionStatus != ConnectivityResult.none) {
      setState(() {
        _isSyncing = true;
      });

      final unsyncedData = formDataBox?.values.toList() ?? [];

      for (var data in unsyncedData) {
        await sendDataToBackend(data);
        await data.delete(); // Remove from local storage after successful sync.
      }

      setState(() {
        _isSyncing = false;
      });

      if (unsyncedData.isNotEmpty) {
        clearFields(); // Clear fields only if there was something to sync
      }
    }
  }
// Send data to backend
  Future<bool> sendDataToBackend(FormData data) async {
    // Simulate a delay for testing purposes
    await Future.delayed(const Duration(seconds: 2));

    try {
      var response = await http.post(
        Uri.parse('https://[BACKEND_API_URL]/data/add'),
        headers: <String, String>{
          'Content-Type': 'application/json; charset=UTF-8',
        },
        body: jsonEncode(<String, String>{
          'field1': data.field1,
          'field2': data.field2,
          'field3': data.field3,
        }),
      );

      if (response.statusCode == 201) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Data sent to backend!')),
        );
        return true;
      } else {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('Failed to send data!')),
        );
        return false;
      }
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Failed to send data!')),
      );
      return false;
    }
  }

Handle Form Submission Based on Connectivity

In the button's onPressed callback, check network availability with _connectionStatus. If online, call handleSubmit(), which attempts immediate synchronization; if offline, invoke saveDataLocally().

Auto-Sync When Connection Restores

Update _updateConnectionStatus() method so that whenever connectivity is restored, it automatically calls syncDataWithBackend() to synchronize any unsynced local data with the backend server.

Test Your Application

First you can find the full source code in my GitHub Repository : https://github.com/claziosi/offline-first

It is time now to test the application... starting from an offline mode, we will save multiple entries locally... when the connectivity will come back, we will synchronize each entry one by one...