From d2583cac5f01163658debc976812872ac00cbcd8 Mon Sep 17 00:00:00 2001 From: Miguel Beltran Date: Fri, 22 Nov 2024 18:20:46 +0100 Subject: [PATCH] Add "Offline-first" architecture cookbook recipe (#11425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Description of what this PR is changing or adding, and why:_ Following PRs #11410 and #11394 this PR adds the "Offline-first" cookbook recipe for the Architecture design patterns. _Issues fixed by this PR (if any):_ Part of https://github.com/flutter/website/issues/11374 _PRs or commits this PR depends on (if any):_ ## Presubmit checklist - [ ] This PR is marked as draft with an explanation if not meant to land until a future stable release. - [x] This PR doesn’t contain automatically generated corrections (Grammarly or similar). - [x] This PR follows the [Google Developer Documentation Style Guidelines](https://developers.google.com/style) — for example, it doesn’t use _i.e._ or _e.g._, and it avoids _I_ and _we_ (first person). - [x] This PR uses [semantic line breaks](https://github.com/dart-lang/site-shared/blob/main/doc/writing-for-dart-and-flutter-websites.md#semantic-line-breaks) of 80 characters or fewer. --------- Co-authored-by: Shams Zakhour (ignore Sfshaza) <44418985+sfshaza2@users.noreply.github.com> --- .../offline_first/analysis_options.yaml | 5 + .../repositories/user_profile_repository.dart | 149 +++++ .../lib/data/services/api_client_service.dart | 26 + .../lib/data/services/database_service.dart | 27 + .../lib/domain/model/user_profile.dart | 14 + .../domain/model/user_profile.freezed.dart | 182 ++++++ .../architecture/offline_first/lib/main.dart | 35 ++ .../ui/user_profile/user_profile_screen.dart | 30 + .../user_profile/user_profile_viewmodel.dart | 54 ++ .../architecture/offline_first/pubspec.yaml | 21 + src/_data/sidenav.yml | 2 + .../cookbook/architecture/offline-first.md | 525 ++++++++++++++++++ 12 files changed, 1070 insertions(+) create mode 100644 examples/cookbook/architecture/offline_first/analysis_options.yaml create mode 100644 examples/cookbook/architecture/offline_first/lib/data/repositories/user_profile_repository.dart create mode 100644 examples/cookbook/architecture/offline_first/lib/data/services/api_client_service.dart create mode 100644 examples/cookbook/architecture/offline_first/lib/data/services/database_service.dart create mode 100644 examples/cookbook/architecture/offline_first/lib/domain/model/user_profile.dart create mode 100644 examples/cookbook/architecture/offline_first/lib/domain/model/user_profile.freezed.dart create mode 100644 examples/cookbook/architecture/offline_first/lib/main.dart create mode 100644 examples/cookbook/architecture/offline_first/lib/ui/user_profile/user_profile_screen.dart create mode 100644 examples/cookbook/architecture/offline_first/lib/ui/user_profile/user_profile_viewmodel.dart create mode 100644 examples/cookbook/architecture/offline_first/pubspec.yaml create mode 100644 src/content/cookbook/architecture/offline-first.md diff --git a/examples/cookbook/architecture/offline_first/analysis_options.yaml b/examples/cookbook/architecture/offline_first/analysis_options.yaml new file mode 100644 index 0000000000..eee60e0f5a --- /dev/null +++ b/examples/cookbook/architecture/offline_first/analysis_options.yaml @@ -0,0 +1,5 @@ +# Take our settings from the example_utils analysis_options.yaml file. +# If necessary for a particular example, this file can also include +# overrides for individual lints. + +include: package:example_utils/analysis.yaml diff --git a/examples/cookbook/architecture/offline_first/lib/data/repositories/user_profile_repository.dart b/examples/cookbook/architecture/offline_first/lib/data/repositories/user_profile_repository.dart new file mode 100644 index 0000000000..edabe99bf1 --- /dev/null +++ b/examples/cookbook/architecture/offline_first/lib/data/repositories/user_profile_repository.dart @@ -0,0 +1,149 @@ +import 'dart:async'; + +import '../../domain/model/user_profile.dart'; +import '../services/api_client_service.dart'; +import '../services/database_service.dart'; + +class UserProfileRepository { + UserProfileRepository({ + required ApiClientService apiClientService, + required DatabaseService databaseService, + }) : _apiClientService = apiClientService, + _databaseService = databaseService { + // #docregion Timer + Timer.periodic( + const Duration(minutes: 5), + (timer) => sync(), + ); + // #enddocregion Timer + } + + final ApiClientService _apiClientService; + final DatabaseService _databaseService; + + // #docregion getUserProfile + Stream getUserProfile() async* { + // Fetch the user profile from the database + final userProfile = await _databaseService.fetchUserProfile(); + // Returns the database result if it exists + if (userProfile != null) { + yield userProfile; + } + + // Fetch the user profile from the API + try { + final apiUserProfile = await _apiClientService.getUserProfile(); + //Update the database with the API result + await _databaseService.updateUserProfile(apiUserProfile); + // Return the API result + yield apiUserProfile; + } catch (e) { + // Handle the error + } + } + // #enddocregion getUserProfile + + // #docregion getUserProfileFallback + Future getUserProfileFallback() async { + try { + // Fetch the user profile from the API + final apiUserProfile = await _apiClientService.getUserProfile(); + //Update the database with the API result + await _databaseService.updateUserProfile(apiUserProfile); + + return apiUserProfile; + } catch (e) { + // If the network call failed, + // fetch the user profile from the database + final databaseUserProfile = await _databaseService.fetchUserProfile(); + + // If the user profile was never fetched from the API + // it will be null, so throw an error + if (databaseUserProfile != null) { + return databaseUserProfile; + } else { + // Handle the error + throw Exception('User profile not found'); + } + } + } + // #enddocregion getUserProfileFallback + + // #docregion getUserProfileLocal + Future getUserProfileLocal() async { + // Fetch the user profile from the database + final userProfile = await _databaseService.fetchUserProfile(); + + // Return the database result if it exists + if (userProfile == null) { + throw Exception('Data not found'); + } + + return userProfile; + } + + Future syncRead() async { + try { + // Fetch the user profile from the API + final userProfile = await _apiClientService.getUserProfile(); + + // Update the database with the API result + await _databaseService.updateUserProfile(userProfile); + } catch (e) { + // Try again later + } + } + // #enddocregion getUserProfileLocal + + // #docregion updateUserProfileOnline + Future updateUserProfileOnline(UserProfile userProfile) async { + try { + // Update the API with the user profile + await _apiClientService.putUserProfile(userProfile); + + // Only if the API call was successful + // update the database with the user profile + await _databaseService.updateUserProfile(userProfile); + } catch (e) { + // Handle the error + } + } + // #enddocregion updateUserProfileOnline + + // #docregion updateUserProfileOffline + Future updateUserProfileOffline(UserProfile userProfile) async { + // Update the database with the user profile + await _databaseService.updateUserProfile(userProfile); + + try { + // Update the API with the user profile + await _apiClientService.putUserProfile(userProfile); + } catch (e) { + // Handle the error + } + } + // #enddocregion updateUserProfileOffline + + // #docregion sync + Future sync() async { + try { + // Fetch the user profile from the database + final userProfile = await _databaseService.fetchUserProfile(); + + // Check if the user profile requires synchronization + if (userProfile == null || userProfile.synchronized) { + return; + } + + // Update the API with the user profile + await _apiClientService.putUserProfile(userProfile); + + // Set the user profile as synchronized + await _databaseService + .updateUserProfile(userProfile.copyWith(synchronized: true)); + } catch (e) { + // Try again later + } + } + // #enddocregion sync +} diff --git a/examples/cookbook/architecture/offline_first/lib/data/services/api_client_service.dart b/examples/cookbook/architecture/offline_first/lib/data/services/api_client_service.dart new file mode 100644 index 0000000000..21d1b9cc64 --- /dev/null +++ b/examples/cookbook/architecture/offline_first/lib/data/services/api_client_service.dart @@ -0,0 +1,26 @@ +import '../../domain/model/user_profile.dart'; + +// #docregion ApiClientService +class ApiClientService { + /// performs GET network request to obtain a UserProfile + Future getUserProfile() async { + // #enddocregion ApiClientService + // Simulate a network GET request + await Future.delayed(const Duration(seconds: 2)); + // Return a dummy user profile + return const UserProfile( + name: 'John Doe (from API)', + photoUrl: 'https://example.com/john_doe.jpg', + ); + // #docregion ApiClientService + } + + /// performs PUT network request to update a UserProfile + Future putUserProfile(UserProfile userProfile) async { + // #enddocregion ApiClientService + // Simulate a network PUT request + await Future.delayed(const Duration(seconds: 2)); + // #docregion ApiClientService + } +} +// #enddocregion ApiClientService diff --git a/examples/cookbook/architecture/offline_first/lib/data/services/database_service.dart b/examples/cookbook/architecture/offline_first/lib/data/services/database_service.dart new file mode 100644 index 0000000000..6eab3d3cae --- /dev/null +++ b/examples/cookbook/architecture/offline_first/lib/data/services/database_service.dart @@ -0,0 +1,27 @@ +import '../../domain/model/user_profile.dart'; + +// #docregion DatabaseService +class DatabaseService { + /// Fetches the UserProfile from the database. + /// Returns null if the user profile is not found. + Future fetchUserProfile() async { + // #enddocregion DatabaseService + // Simulate a database select query + await Future.delayed(const Duration(milliseconds: 100)); + // Return a dummy user profile + return const UserProfile( + name: 'John Doe (from Database)', + photoUrl: 'https://example.com/john_doe.jpg', + ); + // #docregion DatabaseService + } + + /// Update UserProfile in the database. + Future updateUserProfile(UserProfile userProfile) async { + // #enddocregion DatabaseService + // Simulate a database update query + await Future.delayed(const Duration(milliseconds: 100)); + // #docregion DatabaseService + } +} +// #enddocregion DatabaseService diff --git a/examples/cookbook/architecture/offline_first/lib/domain/model/user_profile.dart b/examples/cookbook/architecture/offline_first/lib/domain/model/user_profile.dart new file mode 100644 index 0000000000..5411e652e6 --- /dev/null +++ b/examples/cookbook/architecture/offline_first/lib/domain/model/user_profile.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'user_profile.freezed.dart'; + +// #docregion UserProfile +@freezed +class UserProfile with _$UserProfile { + const factory UserProfile({ + required String name, + required String photoUrl, + @Default(false) bool synchronized, + }) = _UserProfile; +} +// #enddocregion UserProfile diff --git a/examples/cookbook/architecture/offline_first/lib/domain/model/user_profile.freezed.dart b/examples/cookbook/architecture/offline_first/lib/domain/model/user_profile.freezed.dart new file mode 100644 index 0000000000..e7f43d482d --- /dev/null +++ b/examples/cookbook/architecture/offline_first/lib/domain/model/user_profile.freezed.dart @@ -0,0 +1,182 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'user_profile.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$UserProfile { + String get name => throw _privateConstructorUsedError; + String get photoUrl => throw _privateConstructorUsedError; + bool get synchronized => throw _privateConstructorUsedError; + + /// Create a copy of UserProfile + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $UserProfileCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $UserProfileCopyWith<$Res> { + factory $UserProfileCopyWith( + UserProfile value, $Res Function(UserProfile) then) = + _$UserProfileCopyWithImpl<$Res, UserProfile>; + @useResult + $Res call({String name, String photoUrl, bool synchronized}); +} + +/// @nodoc +class _$UserProfileCopyWithImpl<$Res, $Val extends UserProfile> + implements $UserProfileCopyWith<$Res> { + _$UserProfileCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of UserProfile + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? photoUrl = null, + Object? synchronized = null, + }) { + return _then(_value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + photoUrl: null == photoUrl + ? _value.photoUrl + : photoUrl // ignore: cast_nullable_to_non_nullable + as String, + synchronized: null == synchronized + ? _value.synchronized + : synchronized // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$UserProfileImplCopyWith<$Res> + implements $UserProfileCopyWith<$Res> { + factory _$$UserProfileImplCopyWith( + _$UserProfileImpl value, $Res Function(_$UserProfileImpl) then) = + __$$UserProfileImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String name, String photoUrl, bool synchronized}); +} + +/// @nodoc +class __$$UserProfileImplCopyWithImpl<$Res> + extends _$UserProfileCopyWithImpl<$Res, _$UserProfileImpl> + implements _$$UserProfileImplCopyWith<$Res> { + __$$UserProfileImplCopyWithImpl( + _$UserProfileImpl _value, $Res Function(_$UserProfileImpl) _then) + : super(_value, _then); + + /// Create a copy of UserProfile + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? photoUrl = null, + Object? synchronized = null, + }) { + return _then(_$UserProfileImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + photoUrl: null == photoUrl + ? _value.photoUrl + : photoUrl // ignore: cast_nullable_to_non_nullable + as String, + synchronized: null == synchronized + ? _value.synchronized + : synchronized // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _$UserProfileImpl implements _UserProfile { + const _$UserProfileImpl( + {required this.name, required this.photoUrl, this.synchronized = false}); + + @override + final String name; + @override + final String photoUrl; + @override + @JsonKey() + final bool synchronized; + + @override + String toString() { + return 'UserProfile(name: $name, photoUrl: $photoUrl, synchronized: $synchronized)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$UserProfileImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.photoUrl, photoUrl) || + other.photoUrl == photoUrl) && + (identical(other.synchronized, synchronized) || + other.synchronized == synchronized)); + } + + @override + int get hashCode => Object.hash(runtimeType, name, photoUrl, synchronized); + + /// Create a copy of UserProfile + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$UserProfileImplCopyWith<_$UserProfileImpl> get copyWith => + __$$UserProfileImplCopyWithImpl<_$UserProfileImpl>(this, _$identity); +} + +abstract class _UserProfile implements UserProfile { + const factory _UserProfile( + {required final String name, + required final String photoUrl, + final bool synchronized}) = _$UserProfileImpl; + + @override + String get name; + @override + String get photoUrl; + @override + bool get synchronized; + + /// Create a copy of UserProfile + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$UserProfileImplCopyWith<_$UserProfileImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/examples/cookbook/architecture/offline_first/lib/main.dart b/examples/cookbook/architecture/offline_first/lib/main.dart new file mode 100644 index 0000000000..15d62269b5 --- /dev/null +++ b/examples/cookbook/architecture/offline_first/lib/main.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import 'data/repositories/user_profile_repository.dart'; +import 'data/services/api_client_service.dart'; +import 'data/services/database_service.dart'; +import 'ui/user_profile/user_profile_screen.dart'; +import 'ui/user_profile/user_profile_viewmodel.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: UserProfileScreen( + viewModel: UserProfileViewModel( + userProfileRepository: UserProfileRepository( + apiClientService: ApiClientService(), + databaseService: DatabaseService(), + ), + ), + ), + ); + } +} diff --git a/examples/cookbook/architecture/offline_first/lib/ui/user_profile/user_profile_screen.dart b/examples/cookbook/architecture/offline_first/lib/ui/user_profile/user_profile_screen.dart new file mode 100644 index 0000000000..cca041726d --- /dev/null +++ b/examples/cookbook/architecture/offline_first/lib/ui/user_profile/user_profile_screen.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +import 'user_profile_viewmodel.dart'; + +class UserProfileScreen extends StatelessWidget { + const UserProfileScreen({ + super.key, + required this.viewModel, + }); + + final UserProfileViewModel viewModel; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: const Text('User Profile'), + ), + body: ListenableBuilder( + listenable: viewModel, + builder: (context, child) { + return Center( + child: Text(viewModel.userProfile?.name ?? 'Loading'), + ); + }, + ), + ); + } +} diff --git a/examples/cookbook/architecture/offline_first/lib/ui/user_profile/user_profile_viewmodel.dart b/examples/cookbook/architecture/offline_first/lib/ui/user_profile/user_profile_viewmodel.dart new file mode 100644 index 0000000000..dcb2f307e4 --- /dev/null +++ b/examples/cookbook/architecture/offline_first/lib/ui/user_profile/user_profile_viewmodel.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import '../../data/repositories/user_profile_repository.dart'; +import '../../domain/model/user_profile.dart'; + +// #docregion UserProfileViewModel +class UserProfileViewModel extends ChangeNotifier { + // #enddocregion UserProfileViewModel + UserProfileViewModel({ + required UserProfileRepository userProfileRepository, + }) : _userProfileRepository = userProfileRepository { + load(); + } + + // #docregion UserProfileViewModel + final UserProfileRepository _userProfileRepository; + + UserProfile? get userProfile => _userProfile; + // #enddocregion UserProfileViewModel + + UserProfile? _userProfile; + + // #docregion UserProfileViewModel + + /// Load the user profile from the database or the network + // #docregion load + Future load() async { + // #enddocregion UserProfileViewModel + await _userProfileRepository.getUserProfile().listen((userProfile) { + _userProfile = userProfile; + notifyListeners(); + }, onError: (error) { + // handle error + }).asFuture(); + // #docregion UserProfileViewModel + } + // #enddocregion load + + /// Save the user profile with the new name + Future save(String newName) async { + // #enddocregion UserProfileViewModel + assert(_userProfile != null); + final newUserProfile = _userProfile!.copyWith(name: newName); + try { + await _userProfileRepository.updateUserProfileOnline(newUserProfile); + _userProfile = newUserProfile; + } catch (e) { + // handle error + } finally { + notifyListeners(); + } + // #docregion UserProfileViewModel + } +} +// #enddocregion UserProfileViewModel diff --git a/examples/cookbook/architecture/offline_first/pubspec.yaml b/examples/cookbook/architecture/offline_first/pubspec.yaml new file mode 100644 index 0000000000..53b6094767 --- /dev/null +++ b/examples/cookbook/architecture/offline_first/pubspec.yaml @@ -0,0 +1,21 @@ +name: offline_first +description: Example for offline_first cookbook recipe + +environment: + sdk: ^3.5.0 + +dependencies: + flutter: + sdk: flutter + freezed_annotation: ^2.4.4 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.4.13 + freezed: ^2.5.7 + example_utils: + path: ../../../example_utils + +flutter: + uses-material-design: true diff --git a/src/_data/sidenav.yml b/src/_data/sidenav.yml index a2b7fb4036..56cfca2c1b 100644 --- a/src/_data/sidenav.yml +++ b/src/_data/sidenav.yml @@ -445,6 +445,8 @@ permalink: /cookbook/architecture/key-value-data - title: "Persistent storage architecture: SQL" permalink: /cookbook/architecture/sql + - title: Offline-first + permalink: /cookbook/architecture/offline-first - title: Platform integration permalink: /platform-integration diff --git a/src/content/cookbook/architecture/offline-first.md b/src/content/cookbook/architecture/offline-first.md new file mode 100644 index 0000000000..40b202ed24 --- /dev/null +++ b/src/content/cookbook/architecture/offline-first.md @@ -0,0 +1,525 @@ +--- +title: "Offline-first support" +description: Create an app with offline-first support. +js: + - defer: true + url: /assets/js/inject_dartpad.js +--- + + + +An offline-first application is an app capable of offering most +or all of its functionality while being disconnected from the internet. +Offline-first applications usually rely on stored data +to offer users temporary access to data +that would otherwise only be available online. + +Some offline-first applications combine local and remote data seamlessly, +while other applications inform the user +when the application is using cached data. +In the same way, +some applications synchronize data in the background +while others require the user to explicitly synchronize it. +It all depends on the application requirements and the functionality it offers, +and it’s up to the developer to decide which implementation fits their needs. + +In this guide, +you will learn how to implement different approaches +to offline-first applications in Flutter, +following the [Flutter Architecture guidelines][]. + +## Offline-first architecture + +As explained in the common architecture concepts guide, +repositories act as the single source of truth. +They are responsible for presenting local or remote data, +and should be the only place where data can be modified. +In offline-first applications, +repositories combine different local and remote data sources +to present data in a single access point, +independently of the connectivity state of the device. + +This example uses the `UserProfileRepository`, +a repository that allows you to obtain and store `UserProfile` objects +with offline-first support. + +The `UserProfileRepository` uses two different data services: +one works with remote data, +and the other works with a local database. + +The API client,`ApiClientService`, +connects to a remote service using HTTP REST calls. + + +```dart +class ApiClientService { + /// performs GET network request to obtain a UserProfile + Future getUserProfile() async { + // ··· + } + + /// performs PUT network request to update a UserProfile + Future putUserProfile(UserProfile userProfile) async { + // ··· + } +} +``` + +The database service, `DatabaseService`, stores data using SQL, +similar to the one found in the [Persistent Storage Architecture: SQL][] recipe. + + +```dart +class DatabaseService { + /// Fetches the UserProfile from the database. + /// Returns null if the user profile is not found. + Future fetchUserProfile() async { + // ··· + } + + /// Update UserProfile in the database. + Future updateUserProfile(UserProfile userProfile) async { + // ··· + } +} +``` + +This example also uses the `UserProfile` data class +that has been created using the [`freezed`][] package. + + +```dart +@freezed +class UserProfile with _$UserProfile { + const factory UserProfile({ + required String name, + required String photoUrl, + }) = _UserProfile; +} +``` + +In apps that have complex data, +such as when the remote data contains more fields than the needed by the UI, +you might want to have one data class for the API and database services, +and another for the UI. +For example, +`UserProfileLocal` for the database entity, +`UserProfileRemote` for the API response object, +and then `UserProfile` for the UI data model class. +The `UserProfileRepository` would take care +of converting from one to the other when necessary. + +This example also includes the `UserProfileViewModel`, +a view model that uses the `UserProfileRepository` +to display the `UserProfile` on a widget. + + +```dart +class UserProfileViewModel extends ChangeNotifier { + // ··· + final UserProfileRepository _userProfileRepository; + + UserProfile? get userProfile => _userProfile; + // ··· + + /// Load the user profile from the database or the network + Future load() async { + // ··· + } + + /// Save the user profile with the new name + Future save(String newName) async { + // ··· + } +} +``` + +## Reading data + +Reading data is a fundamental part of any application +that relies on remote API services. + +In offline-first applications, +you want to ensure that the access to this data is as fast as possible, +and that it doesn’t depend on the device being online +to provide data to the user. +This is similar to the [Optimistic State design pattern][]. + +In this section, +you will learn two different approaches, +one that uses the database as a fallback, +and one that combines local and remote data using a `Stream`. + +### Using local data as a fallback + +As a first approach, +you can implement offline support by having a fallback mechanism +for when the user is offline or a network call fails. + +In this case, the `UserProfileRepository` attempts to obtain the `UserProfile` +from the remote API server using the `ApiClientService`. +If this request fails, +then returns the locally stored `UserProfile` from the `DatabaseService`. + + +```dart +Future getUserProfile() async { + try { + // Fetch the user profile from the API + final apiUserProfile = await _apiClientService.getUserProfile(); + //Update the database with the API result + await _databaseService.updateUserProfile(apiUserProfile); + + return apiUserProfile; + } catch (e) { + // If the network call failed, + // fetch the user profile from the database + final databaseUserProfile = await _databaseService.fetchUserProfile(); + + // If the user profile was never fetched from the API + // it will be null, so throw an error + if (databaseUserProfile != null) { + return databaseUserProfile; + } else { + // Handle the error + throw Exception('User profile not found'); + } + } +} +``` + +### Using a Stream + +A better alternative presents the data using a `Stream`. +In the best case scenario, +the `Stream` emits two values, +the locally stored data, and the data from the server. + +First, the stream emits the locally stored data using the `DatabaseService`. +This call is generally faster and less error prone than a network call, +and by doing it first the view model can already display data to the user. + +If the database does not contain any cached data, +then the `Stream` relies completely on the network call, +emitting only one value. + +Then, the method performs the network call using the `ApiClientService` +to obtain up-to-date data. +If the request was successful, +it updates the database with the newly obtained data, +and then yields the value to the view model, +so it can be displayed to the user. + + +```dart +Stream getUserProfile() async* { + // Fetch the user profile from the database + final userProfile = await _databaseService.fetchUserProfile(); + // Returns the database result if it exists + if (userProfile != null) { + yield userProfile; + } + + // Fetch the user profile from the API + try { + final apiUserProfile = await _apiClientService.getUserProfile(); + //Update the database with the API result + await _databaseService.updateUserProfile(apiUserProfile); + // Return the API result + yield apiUserProfile; + } catch (e) { + // Handle the error + } +} +``` + +The view model must subscribe +to this `Stream` and wait until it has completed. +For that, call `asFuture()` with the `Subscription` object and await the result. + +For each obtained value, +update the view model data and call `notifyListeners()` +so the UI shows the latest data. + + +```dart +Future load() async { + await _userProfileRepository.getUserProfile().listen((userProfile) { + _userProfile = userProfile; + notifyListeners(); + }, onError: (error) { + // handle error + }).asFuture(); +} +``` +### Using only local data + +Another possible approach uses locally stored data for read operations. +This approach requires that the data has been preloaded +at some point into the database, +and requires a synchronization mechanism that can keep the data up to date. + + + +```dart +Future getUserProfile() async { + // Fetch the user profile from the database + final userProfile = await _databaseService.fetchUserProfile(); + + // Return the database result if it exists + if (userProfile == null) { + throw Exception('Data not found'); + } + + return userProfile; +} + +Future sync() async { + try { + // Fetch the user profile from the API + final userProfile = await _apiClientService.getUserProfile(); + + // Update the database with the API result + await _databaseService.updateUserProfile(userProfile); + } catch (e) { + // Try again later + } +} +``` + +This approach can be useful for applications +that don’t require data to be in sync with the server at all times. +For example, a weather application +where the weather data is only updated once a day. + +Synchronization could be done manually by the user, +for example, a pull-to-refresh action that then calls the `sync()` method, +or done periodically by a `Timer` or a background process. +You can learn how to implement a synchronization task +in the section about synchronizing state. + +## Writing data + +Writing data in offline-first applications depends fundamentally +on the application use case. + +Some applications might require the user input data +to be immediately available on the server side, +while other applications might be more flexible +and allow data to be out-of-sync temporarily. + +This section explains two different approaches +for implementing writing data in offline-first applications. + +### Online-only writing + +One approach for writing data in offline-first applications +is to enforce being online to write data. +While this might sound counterintuitive, +this ensures that the data the user has modified +is fully synchronized with the server, +and the application doesn’t have a different state than the server. + +In this case, you first attempt to send the data to the API service, +and if the request succeeds, +then store the data in the database. + + +```dart +Future updateUserProfile(UserProfile userProfile) async { + try { + // Update the API with the user profile + await _apiClientService.putUserProfile(userProfile); + + // Only if the API call was successful + // update the database with the user profile + await _databaseService.updateUserProfile(userProfile); + } catch (e) { + // Handle the error + } +} +``` + +The disadvantage in this case is that the offline-first functionality +is only available for read operations, +but not for write operations, as those require the user being online. + +### Offline-first writing + +The second approach works the other way around. +Instead of performing the network call first, +the application first stores the new data in the database, +and then attempts to send it to the API service once it has been stored locally. + + +```dart +Future updateUserProfile(UserProfile userProfile) async { + // Update the database with the user profile + await _databaseService.updateUserProfile(userProfile); + + try { + // Update the API with the user profile + await _apiClientService.putUserProfile(userProfile); + } catch (e) { + // Handle the error + } +} +``` + +This approach allows users to store data locally +even when the application is offline, +however, if the network call fails, +the local database and the API service are no longer in sync. +In the next section, +you will learn different approaches to handle synchronization +between local and remote data. + +## Synchronizing state + +Keeping the local and remote data in sync +is an important part of offline-first applications, +as the changes that have been done locally +need to be copied to the remote service. +The app must also ensure that, when the user goes back to the application, +the locally stored data is the same as in the remote service. + + +### Writing a synchronization task + +There are different approaches for implementing +synchronization in a background task. + +A simple solution is to create a `Timer` +in the `UserProfileRepository` that runs periodically, +for example every five minutes. + + +```dart +Timer.periodic( + const Duration(minutes: 5), + (timer) => sync(), +); +``` + +The `sync()` method then fetches the `UserProfile` from the database, +and if it requires synchronization, it is then sent to the API service. + + +```dart +Future sync() async { + try { + // Fetch the user profile from the database + final userProfile = await _databaseService.fetchUserProfile(); + + // Check if the user profile requires synchronization + if (userProfile == null || userProfile.synchronized) { + return; + } + + // Update the API with the user profile + await _apiClientService.putUserProfile(userProfile); + + // Set the user profile as synchronized + await _databaseService + .updateUserProfile(userProfile.copyWith(synchronized: true)); + } catch (e) { + // Try again later + } +} +``` + +A more complex solution uses background processes +like the [`workmanager`][] plugin. +This allows your application to run the synchronization process +in the background even when the application is not running. + +:::note +Running background operations continuosly +can drain the device battery dramatically, +and some devices limit the background processing capabilities, +so this approach needs to be tuned +to the application requirements and one solution might not fit all cases. +::: + +It’s also recommended to only perform the synchronization task +when the network is available. +For example, you can use the [`connectivity_plus`][] plugin +to check if the device is connected to WiFi. +You can also use [`battery_plus`][] to verify +that the device is not running low on battery. + +In the previous example, the synchronization task runs every 5 minutes. +In some cases, that might be excessive, +while in others it might not be frequent enough. +The actual synchronization period time for your application +depends on your application needs and it’s something you will have to decide. + +### Storing a synchronization flag + +To know if the data requires synchronization, +add a flag to the data class indicating if the changes need to be synchronized. + +For example, `bool synchronized`: + + +```dart +@freezed +class UserProfile with _$UserProfile { + const factory UserProfile({ + required String name, + required String photoUrl, + @Default(false) bool synchronized, + }) = _UserProfile; +} +``` + +Your synchronization logic should attempt +to send it to the API service +only when the `synchronized` flag is `false`. +If the request is successful, then change it to `true`. + +### Pushing data from server + +A different approach for synchronization +is to use a push service to provide up-to-date data to the application. +In this case, the server notifies the application when data has changed, +instead of being the application asking for updates. + +For example, you can use [Firebase messaging][], +to push small payloads of data to the device, +as well as trigger synchronization tasks remotely using background messages. + +Instead of having a synchronization task running in the background, +the server notifies the application +when the stored data needs to be updated with a push notification. + +You can combine both approaches together, +having a background synchronization task and using background push messages, +to keep the application database synchronized with the server. + +## Putting it all together + +Writing an offline-first application +requires making decisions regarding +the way read, write and sync operations are implemented, +which depend on the requirements from the application you are developing. + +The key takeaways are: + +- When reading data, +you can use a `Stream` to combine locally stored data with remote data. +- When writing data, +decide if you need to be online or offline, +and if you need synchronizing data later or not. +- When implementing a background sync task, +take into account the device status and your application needs, +as different applications may have different requirements. + +[Flutter Architecture guidelines]:/app-architecture +[Persistent Storage Architecture: SQL]:/cookbook/architecture/sql +[`freezed`]:{{site.pub}}/packages/freezed +[Optimistic State design pattern]:/cookbook/architecture/optimistic-state +[`workmanager`]:{{site.pub}}/packages/workmanager +[`connectivity_plus`]:{{site.pub}}/packages/connectivity_plus +[`battery_plus`]:{{site.pub}}/packages/battery_plus +[Firebase messaging]:{{site.firebase}}/docs/cloud-messaging/flutter/client