Implementing Offline Capabilities in Flutter

In today’s world, users expect mobile apps to be available anytime, anywhere. However, network connectivity isn’t always reliable. This is why implementing offline capabilities in your Flutter apps is crucial. By allowing users to access and interact with content even without an active internet connection, you can significantly improve user experience and retention. This blog post will guide you through various strategies and tools to add offline capabilities to your Flutter applications.

Why Offline Capabilities Matter in Flutter

Implementing offline capabilities offers several significant advantages:

  • Improved User Experience: Users can access content and use essential features even when they’re offline.
  • Enhanced Reliability: Your app becomes more reliable in areas with poor or intermittent internet connectivity.
  • Increased Engagement: Offline access ensures users can continue to engage with your app, leading to better retention rates.
  • Competitive Advantage: Providing offline functionality can set your app apart from competitors that lack this feature.

Strategies for Implementing Offline Capabilities in Flutter

There are several strategies to consider when adding offline capabilities to your Flutter app. The best approach depends on your app’s complexity, data requirements, and desired user experience.

1. Caching Data with shared_preferences

The shared_preferences plugin is the simplest way to persist small amounts of key-value data locally. While it’s not suitable for large datasets or complex objects, it can be useful for caching simple configurations or user preferences.

Step 1: Add the shared_preferences Dependency

Include shared_preferences in your pubspec.yaml file:


dependencies:
  shared_preferences: ^2.2.2
Step 2: Cache and Retrieve Data

Here’s how to use shared_preferences to cache a simple string value:


import 'package:shared_preferences/shared_preferences.dart';

class OfflineService {
  static Future saveString(String key, String value) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(key, value);
  }

  static Future getString(String key) async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getString(key);
  }
}

Usage:


// Saving data
await OfflineService.saveString('userName', 'JohnDoe');

// Retrieving data
String? userName = await OfflineService.getString('userName');
print('User Name: $userName'); // Output: User Name: JohnDoe

2. Using SQLite with sqflite

SQLite is a lightweight, disk-based database that’s perfect for storing structured data locally. The sqflite plugin provides a Flutter interface for interacting with SQLite databases.

Step 1: Add the sqflite Dependency

Add sqflite to your pubspec.yaml:


dependencies:
  sqflite: ^2.3.0
  path_provider: ^2.2.0

You’ll also need the path_provider package to find the correct location to store the database on the device.

Step 2: Create a Database Helper Class

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';

class DatabaseHelper {
  static const _databaseName = "MyDatabase.db";
  static const _databaseVersion = 1;

  static const table = 'my_table';

  static const columnId = '_id';
  static const columnName = 'name';
  static const columnAge = 'age';

  // Make this a singleton class
  DatabaseHelper._privateConstructor();
  static final DatabaseHelper instance = DatabaseHelper._privateConstructor();

  static Database? _database;
  Future get database async {
    if (_database != null) return _database!;
    // Initialize the DB first time it is accessed
    _database = await _initDatabase();
    return _database!;
  }

  // Open the database and create it if it doesn't exist
  _initDatabase() async {
    Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, _databaseName);
    return await openDatabase(path,
        version: _databaseVersion,
        onCreate: _onCreate);
  }

  // SQL code to create the database table
  Future _onCreate(Database db, int version) async {
    await db.execute('''
      CREATE TABLE $table (
        $columnId INTEGER PRIMARY KEY,
        $columnName TEXT NOT NULL,
        $columnAge INTEGER NOT NULL
      )
      ''');
  }

  // Helper methods

  // Inserts a row in the database where each key in the Map is a column name
  // and the value is the column value. The return value is the id of the
  // inserted row.
  Future insert(Map row) async {
    Database db = await instance.database;
    return await db.insert(table, row);
  }

  // All of the rows are returned as a list of maps, where each map is
  // a key-value list of columns.
  Future>> queryAllRows() async {
    Database db = await instance.database;
    return await db.query(table);
  }

  // All of the methods (insert, query, update, delete) can also be done using
  // raw SQL commands. Raw methods are provided here only to show a demo of how
  // they can be done.
}
Step 3: Use the Database Helper in Your App

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Insert data
  Map row = {
    DatabaseHelper.columnName : 'John Doe',
    DatabaseHelper.columnAge  : 30
  };
  final id = await DatabaseHelper.instance.insert(row);
  print('Inserted row id: $id');

  // Query all rows
  final allRows = await DatabaseHelper.instance.queryAllRows();
  print('All rows:');
  allRows.forEach(print);
}

3. Hive: A Lightweight NoSQL Database

Hive is a lightweight NoSQL database written in pure Dart, making it an excellent choice for Flutter. It’s fast, simple to use, and doesn’t require native dependencies.

Step 1: Add the Hive Dependencies

Include the following dependencies in your pubspec.yaml file:


dependencies:
  hive: ^2.2.3
  hive_flutter: ^1.1.2

dev_dependencies:
  build_runner: ^2.4.6
  hive_generator: ^2.0.1

Run flutter pub get to install the dependencies.

Step 2: Define a Hive Object

To store objects in Hive, you need to create a class and annotate it with @HiveType. You’ll also need to create a TypeAdapter using hive_generator.


import 'package:hive/hive.dart';

part 'person.g.dart';

@HiveType(typeId: 0)
class Person {
  @HiveField(0)
  String name;

  @HiveField(1)
  int age;

  Person({required this.name, required this.age});
}

Run the following command in your terminal to generate the adapter:


flutter pub run build_runner build
Step 3: Initialize Hive and Open a Box

In your main.dart file, initialize Hive and open a box to store data.


import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'person.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(PersonAdapter());
  await Hive.openBox('peopleBox');
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State {
  @override
  void dispose() {
    Hive.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Hive Example'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
            var box = Hive.box('peopleBox');
            box.add(Person(name: 'John Doe', age: 30));
            print('Data saved to Hive');
          },
          child: Text('Save Data'),
        ),
      ),
    );
  }
}

4. Moor: Reactive Persistence Library

Moor (formerly known as sembast) is a powerful and reactive persistence library for Flutter. It’s built on top of SQLite but provides a higher-level API and features like type-safe queries and stream-based data updates.

Step 1: Add the Moor Dependencies

Include the required dependencies in your pubspec.yaml file:


dependencies:
  moor: ^4.0.0

dev_dependencies:
  moor_generator: ^4.0.0
  build_runner: ^2.4.6

Run flutter pub get to install the dependencies.

Step 2: Define a Database Class

Create a class that extends Table and defines your database schema.


import 'package:moor/moor.dart';

class Tasks extends Table {
  IntColumn get id => autoincrement()();
  TextColumn get description => text().named('desc')();
  BoolColumn get completed => boolean().withDefault(const Constant(false))();
}
Step 3: Generate the Database Code

Create an abstract class that extends MoorDatabase and includes your tables.


import 'package:moor/moor.dart';
import 'package:moor_example/daos/task_dao.dart'; // Assuming you have a DAO
import 'tables.dart';
import 'dart:io';
import 'package:moor/ffi.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

part 'database.g.dart';

@UseMoor(tables: [Tasks], daos: [TaskDao])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;
}

LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'db.sqlite'));
    return VmDatabase(file);
  });
}

Run the following command in your terminal to generate the necessary code:


flutter pub run build_runner build
Step 4: Use the Database in Your App

Instantiate the database and use it to perform CRUD operations.


import 'package:flutter/material.dart';
import 'package:moor_example/database.dart';

void main() {
  final database = AppDatabase();
  runApp(MyApp(database: database));
}

class MyApp extends StatelessWidget {
  final AppDatabase database;

  MyApp({required this.database});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Moor Example'),
        ),
        body: Center(
          child: ElevatedButton(
            onPressed: () async {
              // Example of inserting a task
              final newTask = TasksCompanion.insert(
                description: 'Sample Task',
              );
              await database.into(database.tasks).insert(newTask);
              print('Task inserted');
            },
            child: Text('Insert Task'),
          ),
        ),
      ),
    );
  }
}

5. Dio Package with CacheInterceptor

Dio is a powerful HTTP client for Dart, which can be combined with CacheInterceptor to seamlessly cache API responses for offline use.

Step 1: Add Dio and CacheInterceptor Dependencies

Add the following dependencies to your pubspec.yaml:


dependencies:
  dio: ^5.4.0
  dio_cache_interceptor: ^3.4.3
  dio_cache_interceptor_hive_store: ^3.2.1
  path_provider: ^2.2.0

Run flutter pub get to install dependencies.

Step 2: Configure Dio with CacheInterceptor

import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:path_provider/path_provider.dart';

Future createDioClient() async {
  final dio = Dio();
  final dir = await getTemporaryDirectory();

  final cacheOptions = CacheOptions(
    store: HiveCacheStore(dir.path),
    policy: CachePolicy.forceCache,
    hitCacheOnErrorExcept: [],
    maxStale: const Duration(days: 7),
    priority: CachePriority.normal,
    cipher: null,
    keyBuilder: CacheKeyBuilder.fromUri(),
    allowPostMethod: false,
  );

  dio.interceptors.add(CacheInterceptor(cacheOptions: cacheOptions));

  return dio;
}

Future main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dio = await createDioClient();

  try {
    final response = await dio.get('https://rickandmortyapi.com/api/character');
    print('Data: ${response.data}');
  } catch (e) {
    print('Error fetching data: $e');
  }
}

Handling Connectivity Changes

To provide a seamless offline experience, you need to monitor the app’s connectivity status and react accordingly. The connectivity_plus plugin helps you detect changes in network connectivity.

Step 1: Add the connectivity_plus Dependency

Include connectivity_plus in your pubspec.yaml file:


dependencies:
  connectivity_plus: ^5.2.0
Step 2: Monitor Connectivity Changes

import 'package:connectivity_plus/connectivity_plus.dart';

class ConnectivityService {
  static Future isConnected() async {
    var connectivityResult = await (Connectivity().checkConnectivity());
    if (connectivityResult == ConnectivityResult.mobile ||
        connectivityResult == ConnectivityResult.wifi) {
      return true;
    } else {
      return false;
    }
  }

  static Stream connectivityStream = Connectivity().onConnectivityChanged;
}

Best Practices for Implementing Offline Capabilities

  • Graceful Degradation: Ensure that your app handles offline scenarios gracefully by displaying appropriate messages or providing limited functionality.
  • Background Sync: Implement background synchronization to update local data when the app regains connectivity.
  • Data Conflict Resolution: Handle potential data conflicts that may arise when users modify data offline.
  • Storage Management: Manage local storage efficiently to prevent your app from consuming excessive disk space.
  • Testing: Thoroughly test your app’s offline functionality to ensure it works as expected.

Conclusion

Implementing offline capabilities in Flutter significantly improves user experience, engagement, and app reliability. By leveraging plugins like shared_preferences, sqflite, hive, and moor, combined with strategies for handling connectivity changes, you can create a robust offline-first Flutter app. Always consider the specific requirements of your app and the trade-offs between different offline storage solutions to choose the best approach. Ensuring your app works seamlessly offline can give you a competitive edge and delight your users.