owned this note
owned this note
Published
Linked with GitHub
# Building Offline-First Flutter Apps with Strapi
![CLICK HERE TO COPY TEMPLATE (1)](https://hackmd.io/_uploads/H1i996vMC.jpg)
In today's mobile-centric world, it is imperative to make sure that users will not have a bad experience when using your apps in such regions, where there is poor or no mobile network or internet connection. Here is one of the hurdles that an offline-first architecture sets to address by ensuring that the user can use your application even in areas with a poor connection.
In this tutorial, you'll learn how to build an offline-first Flutter application with Strapi.
## Overview of Offline-First Mobile App Development
Offline-first mobile application is a development paradigm that gives more attention to the building and designing of mobile apps that can function even without the Internet. Contrary to the remote data source-only approach, offline-first applications use local storage in an in-memory datasource to perform operations against this local datasource. As a result, the app will synchronize the data of the local datasource and the remote datasource as soon as the connection to the internet is restored. This is to make sure that there is data consistency all through the app, which means user experience will be smooth from offline to online.
## Benefits of Offline-First Apps
Offline-first apps offer several benefits to both users and developers:
- Improved user experience: Offline-first applications can show data even when there is no or poor network connection, thus guaranteeing a continuous experience.
- Increased reliability: The offline-first apps are more resistant to network outages or server downtime, which gives a more reliable user experience through local data storage.
- Faster load times: These apps can display content faster and lower latency which is achieved by caching data locally.
- Reduced data usage: Offline-first applications minimize the need to always transfer between the application and the host server reducing data costs for users, especially in places where mobile data is limited or quite costly.
## Prerequisites
To follow along with this tutorial, ensure you have met the following requirements.
- [Node.js v18](https://nodejs.org/) or later.
- A code editor on your computer.
- [Node.js package manage](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm/)r v6 and above.
- [Python](https://www.python.org/downloads/)
- Set up your [Flutter environment](https://flutter.dev/docs/get-started/install) if you haven't already.
To get the code for this tutorial, visit my GitHub [repository](https://github.com/Claradev32/offline_first_app) and clone it.
## Setting up a New Flutter Project
Once you have the above requirements, create a new Flutter project by running the command below:
```
flutter create my_offline_first_app
```
The above command will generate a new Flutter project in a folder named `my_offline_first_app`.
## Installing and Configuring Strapi
Strapi is headless and allows you to easily create the backend of your applications with less overhead. To install run the command below:
```
npx create-strapi-app@latest offline_first_app --quickstart
```
The above command will scaffold a new Strapi application in a folder named `offline_first_app`. Once installed, the application will run on port `1337` and the admin panel will be opened on your browser.
Next, create your first admin user by filling out the forms. After that, you'll be redirected to your Strapi admin page.
### Creating Collections Types
Now from your `Content-Type Builder`, click on `+ create new collection type` and create a `todos` collection.
![Screenshot 2024-05-07 at 13.05.38](https://hackmd.io/_uploads/HyvSK9wfC.png)
Then add the following fields in your `todos` collection:
- `title`: Short Text field
- `description`: Short Text field
- `isCompleted`: Boolean field
Next, go to the Content Builder page and add some todos data. Do not forget to publish them.
![Screenshot 2024-05-07 at 13.11.16](https://hackmd.io/_uploads/HynY55PzC.png)
Finally, navigate to **Settings->Roles -> Public** and grant public access to the todos endpoints, and click **Save**.
![Screenshot 2024-05-07 at 13.13.51](https://hackmd.io/_uploads/HyiVj9DGR.png)
## Offline Data Synchronization
The major challenge encountered by developers in the development of offline-first applications is to maintain data consistency between the local database and remote server.
## Implementing a Local Database
To store data locally, you'll use a local database solution like SQLite or a NoSQL database like Hive. For this tutorial, we'll use the **SQLite** package for SQLite support and the **http** package to make API requests to Strapi. To do that, add the **sqflite** dependency to your `pubspec.yaml` file:
```js
dependencies:
flutter:
sdk: flutter
sqflite: ^2.0.3+1
http: ^1.2.1
```
Next, create a new folder named `features/database.dart` in the `lib` folder and define a class to manage the local database:
```
import 'package:path/path.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
class DatabaseHelper {
static const _databaseName = 'my_offline_first_app1.db';
static const _databaseVersion = 1;
static Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
final databasePath = await getDatabasesPath();
final path = join(databasePath, _databaseName);
return await openDatabase(
path,
version: _databaseVersion,
onCreate: _createTables,
);
}
Future<void> _createTables(Database db, int version) async {
await db.execute('''
CREATE TABLE todos (
id INTEGER PRIMARY KEY,
title TEXT,
description TEXT,
isCompleted INTEGER,
createdAt TEXT,
updatedAt TEXT,
publishedAt TEXT,
isNew INTEGER,
isUpdated INTEGER
);
''');
}
}
```
The `DatabaseHelper` class creates an instance of the SQLite database and creates three methods `_initDatabase()` to initialize the database, `_createTables` for creating the `todos` table, and the `get` to access the database instance across the application. Your local database schema has to be the same as your Strapi collection fields to avoid data inconsistencies and conflict.
## Fetching Data from Strapi and Caching it Locally
Create a `service/strapi_service.dart` in the **lib** folder. Then create a `StrapiService` service class that communicates with the Strapi API with the code snippet below:
```
class StrapiService {
static const String baseUrl = 'http://localhost:1337';
final DatabaseHelper databaseHelper = DatabaseHelper();
Future<void> fetchAndCacheTodos() async {
final response = await http.get(Uri.parse('$baseUrl/api/todos'));
if (response.statusCode == 200) {
final responseData = json.decode(response.body);
final todos = responseData['data'];
final db = await databaseHelper.database;
await db.transaction((txn) async {
await txn.delete('todos');
for (var todo in todos) {
await txn.insert(
'todos',
{
"id": todo['id'],
"title": todo['attributes']['title'],
"description": todo['attributes']['description'],
"isCompleted": todo['attributes']['isCompleted'] ? 1 : 0,
"createdAt": todo['attributes']['createdAt'],
"updatedAt": todo['attributes']['updatedAt'],
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
});
} else {
throw Exception('Failed to load todos');
}
}
}
```
In the above code snippet, create a `fetchAndCacheUsers` method to fetch the todos data from the Strapi backend. It then opens a database transaction, clears the existing todos data in the local database, and inserts the newly fetched todos into the local database.
> Notice in the `insert` function, conditional rendering is used in the isCompleted field, this is because SQLite database does not accept boolean values, rather it represents them as **1** for **true** and **0** for **false**.
## Handling CRUD operations (create, read, update, delete) in offline mode
Let's create CRUD operations in the local database, this way even if there is no internet, users can still create, update, and delete todos. To do that, update the `StrapiService` class in the `service/strapi_service.dart` file to add the methods below:
```
Future<List<Map<String, dynamic>>> getLocalTodos() async {
try {
final db = await databaseHelper.database;
final todos = await db.query('todos');
return todos;
} catch (e) {
throw Exception(e);
}
}
Future<void> createLocalTodo(Map<String, dynamic> todo) async {
final db = await databaseHelper.database;
final id = await db.insert(
'todos',
todo,
conflictAlgorithm: ConflictAlgorithm.replace,
);
todo['id'] = id;
await uploadTodoToBackend(todo);
}
Future<void> updateLocalTodo(Map<String, dynamic> todo) async {
final db = await databaseHelper.database;
await db.update(
'todos',
todo,
where: 'id = ?',
whereArgs: [todo['id']],
);
}
Future<void> deleteLocalTodo(int id) async {
final db = await databaseHelper.database;
await db.delete(
'todos',
where: 'id = ?',
whereArgs: [id],
);
}
```
The `createLocalTodo` methods will try to update the Strapi backend when a new todo is created so you have updated data across the local and remote database. If there is no network it won't upload to the remote database successfully but when the user's internet is restored it will use the methods we'll be implementing later in this tutorial.
Next, update the `StrapiService` to add the `uploadTodoToBackend` method:
```
//...
Future<void> uploadTodoToBackend(Map<String, dynamic> todo) async {
try {
var connectivityService = ConnectivityService();
if (await connectivityService.checkConnectivity()) {
await http.post(
Uri.parse('$baseUrl/api/todos'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
"data": {
"id": todo['id'],
"title": todo['title'],
"description": todo['description'],
"isCompleted": todo['isCompleted'] == 1 ? true : false,
}
}),
);
} else {
return;
}
} catch (e) {
throw Exception(e);
}
}
```
The above code starts by initializing the `ConnectivityService()`, use it to check if there is internet connectivity before trying to update the remote database with the new todo. If there is no internet, it returns.
Now create an `utils.dart` file in the features folder and add the code:
```
import 'package:connectivity/connectivity.dart';
class ConnectivityService {
static final ConnectivityService _instance = ConnectivityService._internal();
factory ConnectivityService() => _instance;
ConnectivityService._internal();
void initConnectivity(Function syncCallback) {
Connectivity().onConnectivityChanged.listen((ConnectivityResult result) {
if (result == ConnectivityResult.mobile ||
result == ConnectivityResult.wifi) {
syncCallback();
}
});
}
Future<bool> checkConnectivity() async {
var connectivityResult = await Connectivity().checkConnectivity();
return connectivityResult == ConnectivityResult.mobile ||
connectivityResult == ConnectivityResult.wifi;
}
}
```
The above code creates two methods `initConnectivity` to initialize the connectivity monitoring and `checkConnectivity()` to check if there is an internet connection. For this to work you need to add the `connectivity` page to your `pubspec.yaml` file:
```
dependencies:
flutter:
sdk: flutter
sqflite: ^2.0.3+1
http: ^1.2.1
connectivity: ^3.0.6
```
## Synchronizing Local Data with the Strapi Backend When Online
When the app is back online, the local data needs to be synchronized with the Strapi backend server for data consistency. These steps consist of updating the Strapi server with the data in the local database. Add a new method to the `StrapiService` class to handle this:
```dart
Future<void> syncLocalTodosWithBackend() async {
final localtodos = await getLocalTodos();
final remotetodos = await fetchRemoteTodos();
final mergedtodos = _mergeLocalAndRemoteTodos(localtodos, remotetodos);
for (var todo in mergedtodos) {
if ((todo['isNew'] == true || todo['isNew'] == 1) ||
(todo['isUpdated'] == true || todo['isUpdated'] == 1)) {
await uploadTodoToBackend(todo);
}
}
}
```
The above code will synchronize the todos in the local database with the one from the Strapi server and check for the new or updated todos created while the application was offline and update the Strapi server with those data using the `_mergeLocalAndRemoteTodos` which you'll create shortly.
## Handling conflicts and data merging during synchronization
Now create the `_mergeLocalAndRemoteTodos` method to properly merge the local and remote data to avoid conflicts during the synchronization process. Also, you need to create a `fetchRemoteTodos` method to fetch all the todos in the Strapi server.
```
List<Map<String, dynamic>> _mergeLocalAndRemoteTodos(
List<Map<String, dynamic>> localtodos,
List<Map<String, dynamic>> remotetodos,
) {
final mergedtodos = [...localtodos];
for (var remotetodo in remotetodos) {
final localtodoIndex =
mergedtodos.indexWhere((todo) => todo['id'] == remotetodo['id']);
if (localtodoIndex == -1) {
mergedtodos.add({
...remotetodo,
'isNew': false,
'isUpdated': false,
});
} else {
final localtodo = mergedtodos[localtodoIndex];
final remoteUpdatedAt = DateTime.parse(remotetodo['updatedAt']);
final localUpdatedAt = DateTime.parse(localtodo['updatedAt']);
if (remoteUpdatedAt.isAfter(localUpdatedAt)) {
mergedtodos[localtodoIndex] = {
...remotetodo,
'isNew': false,
'isUpdated': true,
};
} else if (remoteUpdatedAt.isBefore(localUpdatedAt)) {
mergedtodos[localtodoIndex] = {
...localtodo,
'isNew': false,
'isUpdated': true,
};
}
}
}
return mergedtodos;
}
Future<List<Map<String, dynamic>>> fetchRemoteTodos() async {
final response = await http.get(Uri.parse('$baseUrl/api/todos'));
if (response.statusCode == 200) {
final Map<String, dynamic> responseData = json.decode(response.body);
final List<dynamic> todosData = responseData['data'];
final List<Map<String, dynamic>> todos = todosData
.map((todo) => todo['attributes'] as Map<String, dynamic>)
.toList();
return todos;
} else {
throw Exception('Failed to load todos');
}
}
}
```
With the above code, when the apps regains internet connectivity the local and remote todos will sync together to keep the data in the app consistent without conflicts.
## Building the Flutter UI
Now that you have implemented all the logic for your offline-first app, build the UI for your application and use them. Create a **screens** folder in the lib folder and inside the **screens** folder, create a new `home_screen.dart` file and add the code below:
```
import 'package:flutter/material.dart';
import 'package:offline_first_app/features/services/strapi_service.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final StrapiService strapiService = StrapiService();
@override
void initState() {
super.initState();
strapiService.fetchAndCacheTodos();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Todo List'),
),
body: FutureBuilder<List<Map<String, dynamic>>>(
future: strapiService.getLocalTodos(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
} else {
final todos = snapshot.data ?? [];
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
final todo = todos[index];
return ListTile(
title: Text(todo['title']),
subtitle: Text(todo['description']),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: todo['isCompleted'] == 1,
onChanged: (value) {
if (value != null) {
strapiService.updateLocalTodo({
...todo,
'isCompleted': value ? 1 : 0,
});
}
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete Todo?'),
content: const Text(
'Are you sure you want to delete this todo?'),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
strapiService
.deleteLocalTodo(todo['id']);
Navigator.pop(context);
},
child: const Text('Delete'),
),
],
);
},
);
},
),
],
),
onLongPress: () {});
},
);
}
},
),
floatingActionButton: FloatingActionButton(
onPressed: () {},
child: const Icon(Icons.add),
),
);
}
}
```
The above creates a StatefulWidget **HomePage** screen. It initializes the `StrapiService` and creates an instance of it. When the widget initializes, it calls the `fetchAndCacheTodos` method to first fetch the todos you created in your Strapi admin and cache them so users can access them when offline. It then loops through the todos and displays them on the screen, adding a checkbox and delete icon to each to update the status and delete a todo. Then it creates a `FloatingActionButton` to allow users to create new todos. For the update and delete it calls the `updateLocalTodo` and `deleteLocalTodo` methods.
![Simulator Screenshot - iPhone 15 Pro - 2024-05-07 at 14.56.48](https://hackmd.io/_uploads/SyBlv3vfR.png)
Next, update the `onPressed` parameter in the `FloatingActionButton` class to create a modal to add new todos.
```
//...
floatingActionButton: FloatingActionButton(
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'New Todo',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
TextField(
controller: _descriptionController,
decoration:
const InputDecoration(labelText: 'Description'),
),
ElevatedButton(
onPressed: () {
final newTodo = {
'title': _titleController.text,
'description': _descriptionController.text,
'isCompleted': 0,
'createdAt': DateTime.now().toIso8601String(),
};
strapiService.createLocalTodo(newTodo);
_titleController.clear();
_descriptionController.clear();
Navigator.pop(context);
},
child: const Text('Create Todo'),
),
],
),
);
},
);
},
child: const Icon(Icons.add),
),
```
![Simulator Screenshot - iPhone 15 Pro - 2024-05-07 at 15.13.43](https://hackmd.io/_uploads/HkL4v2wM0.png)
Then inside the `_HomePageState` class, after the line where you initialized the `StrapiService` class, add the code below to handle the form state for the title and description fields.
```dart
//...
final TextEditingController _titleController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
```
## Implementing background synchronization strategies
Now implement a background synchronization to synchronize the local and remote data in the HomePage screen. Add the code below after the `initState` function:
```
void initConnectivity() {
ConnectivityService()
.initConnectivity(strapiService.syncLocalTodosWithBackend);
}
void initBackgroundFetch() {
BackgroundFetch.configure(
BackgroundFetchConfig(
minimumFetchInterval: 15,
stopOnTerminate: false,
enableHeadless: true,
startOnBoot: true,
forceAlarmManager: false,
),
(String taskId) async {
strapiService.syncLocalTodosWithBackend();
BackgroundFetch.finish(taskId);
},
);
}
```
The above code creates two methods `initConnectivity` to initialize the connectivity monitoring using the `ConnectivityService` and listen to network changes to call the `syncLocalTodosWithBackend` when there is network connectivity. Then the `initBackgroundFetch` initializes background fetch functionality allowing the app to synchronize local todos with the Strapi Server even when the app is not currently used.
For this to work, you need to add the `background_fetch` package to your `pubspec.yaml` file:
```
dependencies:
flutter:
sdk: flutter
sqflite: ^2.0.3+1
http: ^1.2.1
connectivity: ^3.0.6
background_fetch: ^1.3.4
```
## Authentication and Authorization
To implement authentication and authorization in the application, follow this [guide](https://strapi.io/blog/mastering-flutter-authentication-with-strapi-cms-a-step-by-step-guide).
## Conclusion
Throughout this tutorial, you've learned how to build an Offline-First Flutter Apps with Strapi. You have set up a Strapi project, installed the Strapi, created a collection Type, and created data using the Strapi Content Manager. You created a Flutter application to make API requests to the Strapi Server, cache the data for offline access, and create data synchronization backgrounds and strategies to sync the local database with the Strapi Server.