In today's mobile-centric world, ensuring users will have a good experience using your apps in regions with a mobile network or internet connection is crucial. Offline-first architecture plays a pivotal role in this case, addressing the critical need to enable users to use your application even in areas with a poor connection.
Strapi is an open-source headless CMS that enables developers to manage content easily and integrate it with various front-end frameworks, such as Flutter.
In this tutorial, you'll learn how to build an offline-first Flutter application with Strapi.
Offline-first mobile app development is a development paradigm that gives more attention to the building and designing 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 data source to perform operations against this local data source. As a result, the app will synchronize the data of the local data source and the remote data source as soon as the connection to the internet is restored. This method ensures that data consistency is maintained throughout the app, which means user experience will be smooth from offline to online.
To follow along with this tutorial, ensure you have met the following requirements.
To get the code for this tutorial, visit my GitHub repository and clone it.
Offline-first apps offer several benefits to both users and developers:
For example, a field service app is used by technicians in remote areas. A technician can access repair manuals and customer information stored locally and record repair details without an internet connection when they visit rural areas with poor connectivity.
Once you have met the prerequisites above needed for this tutorial, 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
.
Strapi is headless CMS and allows you to easily create the backend of your applications with less overhead. To install Strapi, 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.
Now from your Content-Type Builder
, click on + create new collection type
and create a todo
collection.
Then add the following fields in your todos
collection:
title
: Short Text fielddescription
: Short Text fieldisCompleted
: Boolean fieldtodos
CollectionNext, go to the Content Builder page and add some todos data or entries. Do not forget to publish them.
Finally, navigate to Settings > Roles > Public and grant public access to the todos
endpoints, and click the "Save" button at the top-right corner.
The major challenge encountered by developers in developing offline-first applications is maintaining data consistency between the local database and remote server. Offline data synchronization is important for offline-first apps because it ensures that all devices and servers have the most up-to-date information, maintaining data integrity across the entire system.
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:
1dependencies:
2 flutter:
3 sdk: flutter
4 sqflite: ^2.0.3+1
5 http: ^1.2.1
Next, create a new folder named features
in the lib
folder. Create a file inside the features
folder and call it database.dart
. Inside it define a class to manage the local database:
1import 'package:path/path.dart';
2import 'package:sqflite_common_ffi/sqflite_ffi.dart';
3
4class DatabaseHelper {
5 static const _databaseName = 'my_offline_first_app1.db';
6 static const _databaseVersion = 1;
7
8 static Database? _database;
9
10 Future<Database> get database async {
11 if (_database != null) return _database!;
12
13 _database = await _initDatabase();
14 return _database!;
15 }
16
17 Future<Database> _initDatabase() async {
18 final databasePath = await getDatabasesPath();
19 final path = join(databasePath, _databaseName);
20
21 return await openDatabase(
22 path,
23 version: _databaseVersion,
24 onCreate: _createTables,
25 );
26 }
27
28 Future<void> _createTables(Database db, int version) async {
29 await db.execute('''
30 CREATE TABLE todos (
31 id INTEGER PRIMARY KEY,
32 title TEXT,
33 description TEXT,
34 isCompleted INTEGER,
35 createdAt TEXT,
36 updatedAt TEXT,
37 publishedAt TEXT,
38 isNew INTEGER,
39 isUpdated INTEGER
40 );
41 ''');
42 }
43}
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.
Create a new folder called service
in the lib
folder. Inside it, create a new file called strapi_service.dart
. The new file should have a StrapiService
service class that communicates with the Strapi API, as shown in the code snippet below:
1import 'dart:async';
2import 'dart:convert';
3import 'package:http/http.dart' as http;
4import 'package:offline_first_app/features/core/database.dart';
5import 'package:sqflite/sqflite.dart';
6
7class StrapiService {
8 static const String baseUrl = 'http://localhost:1337';
9 final DatabaseHelper databaseHelper = DatabaseHelper();
10
11 Future<void> fetchAndCacheTodos() async {
12 try {
13 final response = await http
14 .get(Uri.parse('$baseUrl/api/todos'))
15 .timeout(const Duration(seconds: 5));
16 if (response.statusCode == 200) {
17 final responseData = json.decode(response.body);
18 final todos = responseData['data'];
19
20 final db = await databaseHelper.database;
21 await db.transaction((txn) async {
22 for (var todo in todos) {
23 await txn.insert(
24 'todos',
25 {
26 "id": todo['id'],
27 "title": todo['attributes']['title'],
28 "description": todo['attributes']['description'],
29 "isCompleted": todo['attributes']['isCompleted'] ? 1 : 0,
30 "createdAt": todo['attributes']['createdAt'],
31 "updatedAt": todo['attributes']['updatedAt'],
32 "isNew": 0,
33 "isUpdated": 0,
34 },
35 conflictAlgorithm: ConflictAlgorithm.replace,
36 );
37 }
38 });
39 }
40 } catch (e) {
41 print('Error fetching todos: $e');
42 } finally {
43 await _updateTodosStream();
44 }
45 }
46}
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.
Let's create CRUD (create, read, update, delete) 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:
1class StrapiService {
2 //... other class varriables
3
4 final _todosStreamController =
5 StreamController<List<Map<String, dynamic>>>.broadcast();
6 Stream<List<Map<String, dynamic>>> get todosStream =>
7 _todosStreamController.stream;
8
9 Future<List<Map<String, dynamic>>> getLocalTodos() async {
10 try {
11 final db = await databaseHelper.database;
12 final todos = await db.query('todos');
13 return todos;
14 } catch (e) {
15 throw Exception(e);
16 }
17 }
18
19 Future<void> createLocalTodo(Map<String, dynamic> todo) async {
20 final db = await databaseHelper.database;
21 final id = await db.insert(
22 'todos',
23 todo,
24 conflictAlgorithm: ConflictAlgorithm.replace,
25 );
26 todo['id'] = id;
27 await uploadTodoToBackend(todo);
28 await _updateTodosStream();
29 }
30
31 Future<void> updateLocalTodo(Map<String, dynamic> todo) async {
32 final db = await databaseHelper.database;
33 final updateData = {
34 'title': todo['title'],
35 'description': todo['description'],
36 'isCompleted':
37 todo['isCompleted'] == 1 || todo['isCompleted'] == true ? 1 : 0,
38 'updatedAt': DateTime.now().toIso8601String(),
39 };
40 await db.update(
41 'todos',
42 updateData,
43 where: 'id = ?',
44 whereArgs: [todo['id']],
45 );
46 await updateTodoOnBackend({...todo, ...updateData});
47 await _updateTodosStream();
48 }
49
50 Future<void> deleteLocalTodo(int id) async {
51 final db = await databaseHelper.database;
52 await db.delete(
53 'todos',
54 where: 'id = ?',
55 whereArgs: [id],
56 );
57 await _updateTodosStream();
58 }
59
60 Future<void> _updateTodosStream() async {
61 final todos = await getLocalTodos();
62 _todosStreamController.add(todos);
63 }
64 }
The createLocalTodo
method attempts to update the Strapi backend when a new todo is created, ensuring synchronization between the local and remote databases. If there's no network connection, it won't successfully upload to the remote database. However, when the user's internet connection is restored, it will use the methods we'll implement later in this tutorial to sync the data.
The updateLocalTodo
and deleteLocalTodo
methods modify records in the local database and update the remote database with these changes. After each operation, the _updateTodosStream()
method is named. This method emits the updated list of entries of the todos
through a stream, which the UI listens to. As a result, the UI is automatically updated whenever an entry is created, updated, or deleted.
Next, update the StrapiService
to add the uploadTodoToBackend
method:
1 //...
2 Future<void> uploadTodoToBackend(Map<String, dynamic> todo,
3 {bool isSync = false}) async {
4 try {
5 final response = await http
6 .post(
7 Uri.parse('$baseUrl/api/todos'),
8 headers: {'Content-Type': 'application/json'},
9 body: json.encode({
10 "data": {
11 "title": todo['title'],
12 "description": todo['description'],
13 "isCompleted": todo['isCompleted'] == 1,
14 }
15 }),
16 )
17 .timeout(const Duration(seconds: 5));
18
19 if (response.statusCode == 200 || response.statusCode == 201) {
20 final responseData = json.decode(response.body);
21 final backendId = responseData['data']['id'];
22
23 final db = await databaseHelper.database;
24 await db.update(
25 'todos',
26 {
27 'id': backendId,
28 'isNew': 0,
29 'updatedAt': DateTime.now().toIso8601String(),
30 },
31 where: 'id = ?',
32 whereArgs: [todo['id']],
33 );
34 todo['id'] = backendId;
35 } else {
36 throw Exception('Failed to upload todo');
37 }
38 } catch (e) {
39 print('Error uploading todo: $e');
40 throw e;
41 }
42 }
43
44 Future<void> updateTodoOnBackend(Map<String, dynamic> todo) async {
45 try {
46 final response = await http
47 .put(
48 Uri.parse('$baseUrl/api/todos/${todo['id']}'),
49 headers: {'Content-Type': 'application/json'},
50 body: json.encode({
51 "data": {
52 "title": todo['title'],
53 "description": todo['description'],
54 "isCompleted": todo['isCompleted'] == 1,
55 }
56 }),
57 )
58 .timeout(const Duration(seconds: 5));
59
60 if (response.statusCode == 200) {
61 final db = await databaseHelper.database;
62 await db.update('todos', {'isUpdated': 0},
63 where: 'id = ?', whereArgs: [todo['id']]);
64 } else {
65 throw Exception('Failed to update todo on backend');
66 }
67 } catch (e) {
68 print('Error updating todo on backend: $e');
69 throw e;
70 }
71 }
The above code defines two functions for interacting with the backend database: uploadTodoToBackend
and updateTodoOnBackend
. Both functions start by initializing the ConnectivityService()
and use it to check for internet connectivity before attempting to update the remote database.
uploadTodoToBackend
plays a crucial role in the system, as it is specifically designed to create new todos on the backend. It is invoked when a new todo is created locally and needs to be synced with the remote database, ensuring a seamless data flow.
updateTodoOnBackend
is specifically for updating existing todos. It is named when a todo is modified locally, and the changes must be reflected on the backend.
For data consistency, the local data must be synchronized with the Strapi backend server when the app is back online. These steps consist of updating the Strapi server with the data in the local database. The flowchart below further explains how the data synchronization works.
Update your main.dart
file to configure the background fetch with the code snippet below:
1import 'package:background_fetch/background_fetch.dart';
2import 'package:flutter/material.dart';
3import 'package:offline_first_app/features/core/database.dart';
4import 'package:offline_first_app/features/screens/home_screen.dart';
5import 'package:offline_first_app/features/services/strapi_service.dart';
6
7@pragma('vm:entry-point')
8void backgroundFetchHeadlessTask(HeadlessTask task) async {
9 String taskId = task.taskId;
10 bool isTimeout = task.timeout;
11 if (isTimeout) {
12 BackgroundFetch.finish(taskId);
13 return;
14 }
15 final strapiService = StrapiService();
16 await strapiService.syncLocalTodosWithBackend();
17 BackgroundFetch.finish(taskId);
18}
19
20void main() async {
21 WidgetsFlutterBinding.ensureInitialized();
22
23 final DatabaseHelper databasehleper = DatabaseHelper();
24 await databasehleper.database;
25 runApp(const MyApp());
26 BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask);
27}
28
29class MyApp extends StatelessWidget {
30 const MyApp({super.key});
31
32 @override
33 Widget build(BuildContext context) {
34 return MaterialApp(
35 title: 'Offline First App',
36 theme: ThemeData(
37 primarySwatch: Colors.blue,
38 ),
39 home: const HomePage(),
40 );
41 }
42}
Add a new method to the StrapiService
class to handle the background fetch:
1 Future<void> syncLocalTodosWithBackend() async {
2 final localTodos = await getLocalTodos();
3 // print(localTodos);
4 for (var todo in localTodos) {
5 if (todo['isNew'] == 1) {
6 await uploadTodoToBackend(todo, isSync: true);
7 } else if (todo['isUpdated'] == 1) {
8 await updateTodoOnBackend(todo);
9 }
10 }
11
12 await fetchAndCacheTodos();
13 }
The above code will synchronize the todos
in the local database with the one from the Strapi server, check for 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.
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.
1List<Map<String, dynamic>> _mergeLocalAndRemoteTodos(
2 List<Map<String, dynamic>> localtodos,
3 List<Map<String, dynamic>> remotetodos,
4 ) {
5 final mergedtodos = [...localtodos];
6
7 for (var remotetodo in remotetodos) {
8 final localtodoIndex =
9 mergedtodos.indexWhere((todo) => todo['id'] == remotetodo['id']);
10
11 if (localtodoIndex == -1) {
12 mergedtodos.add({
13 ...remotetodo,
14 'isNew': false,
15 'isUpdated': false,
16 });
17 } else {
18 final localtodo = mergedtodos[localtodoIndex];
19 final remoteUpdatedAt = DateTime.parse(remotetodo['updatedAt']);
20 final localUpdatedAt = DateTime.parse(localtodo['updatedAt']);
21
22 if (remoteUpdatedAt.isAfter(localUpdatedAt)) {
23 mergedtodos[localtodoIndex] = {
24 ...remotetodo,
25 'isNew': false,
26 'isUpdated': true,
27 };
28 } else if (remoteUpdatedAt.isBefore(localUpdatedAt)) {
29 mergedtodos[localtodoIndex] = {
30 ...localtodo,
31 'isNew': false,
32 'isUpdated': true,
33 };
34 }
35 }
36 }
37
38 return mergedtodos;
39 }
40
41 Future<List<Map<String, dynamic>>> fetchRemoteTodos() async {
42 final response = await http.get(Uri.parse('$baseUrl/api/todos'));
43 if (response.statusCode == 200) {
44 final Map<String, dynamic> responseData = json.decode(response.body);
45 final List<dynamic> todosData = responseData['data'];
46 final List<Map<String, dynamic>> todos = todosData
47 .map((todo) => todo['attributes'] as Map<String, dynamic>)
48 .toList();
49 return todos;
50 } else {
51 throw Exception('Failed to load todos');
52 }
53 }
54}
With the above code, when the apps regain internet connectivity, the local and remote todos
will sync to keep the app's data consistent without conflicts.
Now that you have implemented all the logic for your offline-first app build the UI for your application and use them. Create a new folder, screens
, in the lib folder, and inside the screens
folder, create a new home_screen.dart
file and add the code below:
1import 'package:flutter/material.dart';
2import 'package:offline_first_app/features/services/strapi_service.dart';
3
4class HomePage extends StatefulWidget {
5 const HomePage({super.key});
6
7 @override
8 State<HomePage> createState() => _HomePageState();
9}
10
11class _HomePageState extends State<HomePage> {
12 final StrapiService strapiService = StrapiService();
13
14 @override
15 void initState() {
16 super.initState();
17 strapiService.fetchAndCacheTodos();
18 }
19
20 @override
21 Widget build(BuildContext context) {
22 return Scaffold(
23 appBar: AppBar(
24 title: const Text('Todo List'),
25 ),
26 body: StreamBuilder<List<Map<String, dynamic>>>(
27 stream: strapiService.todosStream,
28 builder: (context, snapshot) {
29 if (snapshot.connectionState == ConnectionState.waiting) {
30 return const CircularProgressIndicator();
31 } else if (snapshot.hasError) {
32 return Text('Error: ${snapshot.error}');
33 } else {
34 final todos = snapshot.data ?? [];
35 return ListView.builder(
36 itemCount: todos.length,
37 itemBuilder: (context, index) {
38 final todo = todos[index];
39 return ListTile(
40 title: Text(todo['title']),
41 subtitle: Text(todo['description']),
42 trailing: Row(
43 mainAxisSize: MainAxisSize.min,
44 children: [
45 Checkbox(
46 value: todo['isCompleted'] == 1,
47 onChanged: (value) {
48 if (value != null) {
49 strapiService.updateLocalTodo({
50 ...todo,
51 'isCompleted': value ? 1 : 0,
52 });
53 }
54 },
55 ),
56 IconButton(
57 icon: const Icon(Icons.delete),
58 onPressed: () {
59 showDialog(
60 context: context,
61 builder: (context) {
62 return AlertDialog(
63 title: const Text('Delete Todo?'),
64 content: const Text(
65 'Are you sure you want to delete this todo?'),
66 actions: [
67 TextButton(
68 onPressed: () {
69 Navigator.pop(context);
70 },
71 child: const Text('Cancel'),
72 ),
73 TextButton(
74 onPressed: () {
75 strapiService.deleteLocalTodo(todo['id']);
76 Navigator.pop(context);
77 },
78 child: const Text('Delete'),
79 ),
80 ],
81 );
82 },
83 );
84 },
85 ),
86 ],
87 ),
88 onLongPress: () {
89 showDialog(
90 context: context,
91 builder: (context) {
92 return AlertDialog(
93 title: const Text('Delete Todo?'),
94 content: const Text(
95 'Are you sure you want to delete this todo?'),
96 actions: [
97 TextButton(
98 onPressed: () {
99 Navigator.pop(context);
100 },
101 child: const Text('Cancel'),
102 ),
103 TextButton(
104 onPressed: () {
105 strapiService.deleteLocalTodo(todo['id']);
106 Navigator.pop(context);
107 },
108 child: const Text('Delete'),
109 ),
110 ],
111 );
112 },
113 );
114 },
115 );
116 },
117 );
118 }
119 },
120 ),
121 floatingActionButton: FloatingActionButton(
122 onPressed: () {},
123 child: const Icon(Icons.add),
124 ),
125 );
126 }
127}
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 fetch the todos
you created in your Strapi admin and cache them so users can access them offline.
With the StreamBuilder
, you listen to changes in the todo list. The StreamBuilder
loops through the todos
and displays them on the screen, adding a checkbox and delete icon to each to update the status and delete an entry of the todos
. This approach allows the UI to update in real-time whenever the todo list changes.
You also have the FloatingActionButton
to allow users to create new todos, see the image below. For the update and delete actions, it calls the updateLocalTodo
and deleteLocalTodo
methods, respectively. These methods modify the local database and attempt to sync changes with the remote database.
Next, update the onPressed
parameter in the FloatingActionButton
class to create a modal to add new todos.
1//...
2 floatingActionButton: FloatingActionButton(
3 onPressed: () {
4 showModalBottomSheet(
5 context: context,
6 builder: (context) {
7 return Container(
8 padding: const EdgeInsets.all(16),
9 child: Column(
10 crossAxisAlignment: CrossAxisAlignment.start,
11 mainAxisSize: MainAxisSize.min,
12 children: [
13 const Text(
14 'New Todo',
15 style: TextStyle(
16 fontSize: 24,
17 fontWeight: FontWeight.bold,
18 ),
19 ),
20 TextField(
21 controller: _titleController,
22 decoration: const InputDecoration(labelText: 'Title'),
23 ),
24 TextField(
25 controller: _descriptionController,
26 decoration:
27 const InputDecoration(labelText: 'Description'),
28 ),
29 ElevatedButton(
30 onPressed: () {
31 final newTodo = {
32 'title': _titleController.text,
33 'description': _descriptionController.text,
34 'isCompleted': 0,
35 'createdAt': DateTime.now().toIso8601String(),
36 };
37 strapiService.createLocalTodo(newTodo);
38 _titleController.clear();
39 _descriptionController.clear();
40 Navigator.pop(context);
41 },
42 child: const Text('Create Todo'),
43 ),
44 ],
45 ),
46 );
47 },
48 );
49 },
50 child: const Icon(Icons.add),
51 ),
See the image below:
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.
1//...
2final TextEditingController _titleController = TextEditingController();
3final TextEditingController _descriptionController = TextEditingController();
4
5
6 @override
7 void dispose() {
8 _titleController.dispose();
9 _descriptionController.dispose();
10 super.dispose();
11 }
Now implement a background synchronization to synchronize the local and remote data in the HomePage screen. Add the code below after the initState
function:
1//...
2 @override
3 void initState() {
4 super.initState();
5 strapiService.fetchAndCacheTodos();
6 strapiService.syncLocalTodosWithBackend();
7 initPlatformState();
8 }
9
10 Future<void> initPlatformState() async {
11 await BackgroundFetch.configure(
12 BackgroundFetchConfig(
13 minimumFetchInterval: 15,
14 stopOnTerminate: false,
15 enableHeadless: true,
16 requiresBatteryNotLow: false,
17 requiresCharging: false,
18 requiresStorageNotLow: false,
19 requiresDeviceIdle: false,
20 requiredNetworkType: NetworkType.ANY),
21 _onBackgroundFetch,
22 _onBackgroundFetchTimeout);
23 setState(() {});
24
25 if (_enabled) {
26 await BackgroundFetch.start();
27 }
28 if (!mounted) return;
29 }
30
31 void _onBackgroundFetch(String taskId) async {
32 await strapiService.syncLocalTodosWithBackend();
33 BackgroundFetch.finish(taskId);
34 }
35
36 void _onBackgroundFetchTimeout(String taskId) {
37 BackgroundFetch.finish(taskId);
38 }
The above code sets up background fetching and synchronization for a todo application. The initState()
method first fetches and caches todos, then sync local todos with the backend and finally initializes the platform state for background operations.
The initPlatformState()
method configures BackgroundFetch
with specific settings, such as fetch interval and device requirements. It also sets up callbacks for background fetch events and timeouts and starts the background fetch process if enabled.
The onBackgroundFetch()
method is called when a background fetch event occurs, triggering a sync of local todos with the backend before signaling task completion. If a background fetch task times out, the onBackgroundFetchTimeout()
method is called to finish the task properly.
For this to work, you need to add the background_fetch
package to your pubspec.yaml
file:
1dependencies:
2 flutter:
3 sdk: flutter
4 sqflite: ^2.0.3+1
5 http: ^1.2.1
6 connectivity: ^3.0.6
7 background_fetch: ^1.3.4
When implementing background synchronization, one is bound to encounter issues like large datasets being uploaded simultaneously, leading to data conflicts or network interruptions. These conflicts emerge if, at any given time, modifications are done on both the local and remote server sides of our application. Also, intermediate disruptions might occur during this type of connection syncing. To address this disruption, your code should have proper error-handling mechanisms, and logs should be produced whenever an error occurs. Test the synchronization at different network speeds all time round. Users should be allowed to synchronize manually in case the automated one fails.
NOTE: With the
initPlatformState()
, the background fetch runs every 15 minutes to check if the Strapi server is back online and then syncs the data stored in your device's local database with your Strapi backend.
To implement authentication and authorization in the application, follow the steps below:
For a detailed step-by-step guide, refer to this guide on Mastering Flutter Authentication with Strapi CMS: A Step-by-Step Guide.
When we test our app, we should observe the following: 1. We can create a to-do item while offline. 2. Any data created when offline is synched and stored in Strapi after 15 minutes.
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. Explore the Strapi and Flutter documentation to learn other features you can add to your app.
Software Engineer and perpetual learner with a passion for OS and expertise in Python, JavaScript, Go, Rust, and Web 3.0.