├── example ├── lib │ ├── home_screen.dart │ ├── helpers │ │ └── storage_helper.dart │ ├── models │ │ ├── encryption_manager.dart │ │ └── todo.dart │ ├── main.dart │ └── screens │ │ └── todo_detail_screen.dart ├── .gitignore ├── analysis_options.yaml ├── README.md └── pubspec.yaml ├── .gitattributes ├── lib ├── src │ ├── enums │ │ ├── rest_method.dart │ │ └── sync_strategy.dart │ ├── models │ │ ├── connectivity_options.dart │ │ ├── sync_event_type.dart │ │ ├── sync_status.dart │ │ ├── sync_event.dart │ │ ├── sync_result.dart │ │ ├── conflict_resolution_strategy.dart │ │ ├── encryption_options.dart │ │ ├── sync_event_mapper.dart │ │ ├── sync_options.dart │ │ ├── websocket_config.dart │ │ └── sync_model.dart │ ├── services │ │ ├── sync_service.dart │ │ ├── storage_service.dart │ │ ├── connectivity_service.dart │ │ └── storage_service_impl.dart │ ├── offline_sync_kit.dart │ ├── network │ │ ├── network_client.dart │ │ ├── rest_requests.dart │ │ ├── rest_request.dart │ │ ├── websocket_network_client.dart │ │ ├── websocket_connection_manager.dart │ │ └── default_network_client.dart │ ├── repositories │ │ └── sync_repository.dart │ └── query │ │ ├── query.dart │ │ └── where_condition.dart └── offline_sync_kit.dart ├── analysis_options.yaml ├── .metadata ├── LICENSE ├── .gitignore ├── pubspec.yaml └── CHANGELOG.md /example/lib/home_screen.dart: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /lib/src/enums/rest_method.dart: -------------------------------------------------------------------------------- 1 | enum RestMethod { get, post, put, delete, patch } 2 | -------------------------------------------------------------------------------- /lib/src/models/connectivity_options.dart: -------------------------------------------------------------------------------- 1 | enum ConnectivityOptions { wifi, mobile, any, none } 2 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | 3 | # Additional information about this file can be found at 4 | # https://dart.dev/guides/language/analysis-options 5 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" 8 | channel: "stable" 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /lib/src/services/sync_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import '../models/sync_model.dart'; 3 | import '../models/sync_result.dart'; 4 | import '../models/sync_status.dart'; 5 | 6 | abstract class SyncService { 7 | Stream get statusStream; 8 | 9 | Future syncItem(T item); 10 | 11 | Future syncAll(List items); 12 | 13 | Future syncAllPending(); 14 | 15 | Future syncByModelType(String modelType); 16 | 17 | Future pullFromServer( 18 | String modelType, { 19 | DateTime? since, 20 | Map)>? modelFactories, 21 | }); 22 | 23 | Future startPeriodicSync(); 24 | 25 | Future stopPeriodicSync(); 26 | 27 | Future getCurrentStatus(); 28 | 29 | void dispose(); 30 | } 31 | -------------------------------------------------------------------------------- /lib/src/offline_sync_kit.dart: -------------------------------------------------------------------------------- 1 | library; 2 | 3 | // Core components 4 | export 'models/sync_model.dart'; 5 | export 'models/sync_options.dart'; 6 | export 'models/sync_result.dart'; 7 | export 'models/sync_status.dart'; 8 | export 'models/sync_event.dart'; 9 | export 'models/sync_event_type.dart'; 10 | 11 | // Services 12 | export 'services/connectivity_service.dart'; 13 | export 'services/storage_service.dart'; 14 | export 'services/sync_service.dart'; 15 | 16 | // Network 17 | export 'network/network_client.dart'; 18 | export 'network/default_network_client.dart'; 19 | export 'network/websocket_network_client.dart'; 20 | export 'network/rest_request.dart'; 21 | export 'network/rest_requests.dart'; 22 | 23 | // Repositories 24 | export 'repositories/sync_repository.dart'; 25 | export 'repositories/sync_repository_impl.dart'; 26 | 27 | // Main entry points 28 | export 'offline_sync_manager.dart'; 29 | export 'sync_engine.dart'; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 offline_sync_kit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/src/models/sync_event_type.dart: -------------------------------------------------------------------------------- 1 | /// An enum representing the types of events that occur in the synchronization system. 2 | /// 3 | /// These events allow different components of an application 4 | /// to monitor synchronization-related activities. 5 | enum SyncEventType { 6 | /// When a connection is established 7 | connectionEstablished, 8 | 9 | /// When a connection is closed 10 | connectionClosed, 11 | 12 | /// When a connection fails 13 | connectionFailed, 14 | 15 | /// When reconnection is attempted 16 | reconnecting, 17 | 18 | /// Indicates that synchronization has started 19 | syncStarted, 20 | 21 | /// Indicates that synchronization has completed 22 | syncCompleted, 23 | 24 | /// Indicates an error during synchronization 25 | syncError, 26 | 27 | /// Indicates a model has been updated 28 | modelUpdated, 29 | 30 | /// Indicates a model has been added 31 | modelAdded, 32 | 33 | /// Indicates a model has been deleted 34 | modelDeleted, 35 | 36 | /// When a synchronization conflict is detected 37 | conflictDetected, 38 | 39 | /// When a conflict is resolved 40 | conflictResolved, 41 | } 42 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .build/ 9 | .buildlog/ 10 | .history 11 | .svn/ 12 | .swiftpm/ 13 | migrate_working_dir/ 14 | 15 | # IntelliJ related 16 | *.iml 17 | *.ipr 18 | *.iws 19 | .idea/ 20 | 21 | # The .vscode folder contains launch configuration and tasks you configure in 22 | # VS Code which you may wish to be included in version control, so this line 23 | # is commented out by default. 24 | #.vscode/ 25 | 26 | # Flutter/Dart/Pub related 27 | **/ios/Runner/GoogleService-Info.plist 28 | **/android/app/google-service.json 29 | **/doc/api/ 30 | **/ios/Flutter/.last_build_id 31 | .dart_tool/ 32 | .vscode/ 33 | .flutter-plugins 34 | .flutter-plugins-dependencies 35 | .packages 36 | .pub-cache/ 37 | .metadata 38 | .pub/ 39 | /build/ 40 | pubspec.lock 41 | 42 | # Symbolication related 43 | app.*.symbols 44 | 45 | # Obfuscation related 46 | app.*.map.json 47 | 48 | # Android Studio will place build artifacts here 49 | /android/app/debug 50 | /android/app/profile 51 | /android/app/release 52 | 53 | # Flutter example folder platforms 54 | /android/ 55 | /ios/ 56 | /macos/ 57 | /windows/ 58 | /web/ 59 | /linux/ 60 | /build/ 61 | /test/ -------------------------------------------------------------------------------- /example/lib/helpers/storage_helper.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:flutter/foundation.dart'; 3 | import 'package:sqflite_common_ffi/sqflite_ffi.dart'; 4 | import 'package:offline_sync_kit/offline_sync_kit.dart'; 5 | 6 | /// A helper class that supports Windows platform 7 | class StorageHelper { 8 | /// Configures the correct SQLite settings for different platforms 9 | static Future initializeSqlite() async { 10 | if (Platform.isWindows || Platform.isLinux) { 11 | // Initialize SQLite FFI 12 | sqfliteFfiInit(); 13 | // Set FFI database factory 14 | databaseFactory = databaseFactoryFfi; 15 | debugPrint('SQLite FFI initialized for ${Platform.operatingSystem}'); 16 | } else { 17 | debugPrint('Using default SQLite for ${Platform.operatingSystem}'); 18 | } 19 | } 20 | 21 | /// Creates a StorageServiceImpl class extended for Windows platform 22 | static Future createPlatformAwareStorageService() async { 23 | // Initialize SQLite first 24 | await initializeSqlite(); 25 | 26 | // Return StorageServiceImpl - now it will work on all platforms 27 | final storageService = StorageServiceImpl(); 28 | await storageService.initialize(); 29 | 30 | return storageService; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/offline_sync_kit.dart: -------------------------------------------------------------------------------- 1 | export 'src/models/sync_model.dart'; 2 | export 'src/models/sync_options.dart'; 3 | export 'src/models/sync_status.dart'; 4 | export 'src/models/sync_result.dart'; 5 | export 'src/models/connectivity_options.dart'; 6 | export 'src/models/sync_event.dart'; 7 | export 'src/models/sync_event_type.dart'; 8 | export 'src/models/sync_event_mapper.dart'; 9 | export 'src/models/websocket_config.dart'; 10 | 11 | export 'src/repositories/sync_repository.dart'; 12 | export 'src/repositories/sync_repository_impl.dart'; 13 | 14 | export 'src/services/sync_service.dart'; 15 | export 'src/services/connectivity_service.dart'; 16 | export 'src/services/storage_service.dart'; 17 | export 'src/services/storage_service_impl.dart'; 18 | 19 | export 'src/network/network_client.dart'; 20 | export 'src/network/default_network_client.dart'; 21 | export 'src/network/websocket_connection_manager.dart'; 22 | export 'src/network/websocket_network_client.dart'; 23 | export 'src/network/rest_request.dart'; 24 | export 'src/network/rest_requests.dart'; 25 | export 'src/enums/rest_method.dart'; 26 | 27 | export 'src/sync_engine.dart'; 28 | export 'src/offline_sync_manager.dart'; 29 | 30 | export 'src/models/conflict_resolution_strategy.dart'; 31 | 32 | export 'src/query/query.dart'; 33 | export 'src/query/where_condition.dart'; 34 | export 'src/enums/sync_strategy.dart'; 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/ios/Runner/GoogleService-Info.plist 26 | **/android/app/google-service.json 27 | **/doc/api/ 28 | **/ios/Flutter/.last_build_id 29 | .dart_tool/ 30 | .vscode/ 31 | .flutter-plugins 32 | .flutter-plugins-dependencies 33 | .packages 34 | .pubspec.lock 35 | .pub-cache/ 36 | .pub/ 37 | /build/ 38 | pubspec.lock 39 | 40 | # Symbolication related 41 | app.*.symbols 42 | 43 | # Obfuscation related 44 | app.*.map.json 45 | 46 | # Android Studio will place build artifacts here 47 | /android/app/debug 48 | /android/app/profile 49 | /android/app/release 50 | 51 | # Flutter platforms 52 | /macos/ 53 | /windows/ 54 | /web/ 55 | /linux/ 56 | 57 | # Android Gradle related 58 | /android/.gradle/ 59 | /android/app/.cxx/ 60 | /android/local.properties 61 | 62 | # iOS CocoaPods related 63 | /ios/Pods/ 64 | /ios/.symlinks/ 65 | /ios/.flutter-plugins/ 66 | /ios/.flutter-plugins-dependencies/ 67 | /ios/Podfile.lock -------------------------------------------------------------------------------- /example/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at https://dart.dev/lints. 17 | # 18 | # Instead of disabling a lint rule for the entire project in the 19 | # section below, it can also be suppressed for a single line of code 20 | # or a specific dart file by using the `// ignore: name_of_lint` and 21 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 22 | # producing the lint. 23 | rules: 24 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 25 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 26 | 27 | # Additional information about this file can be found at 28 | # https://dart.dev/guides/language/analysis-options 29 | -------------------------------------------------------------------------------- /lib/src/network/network_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'rest_request.dart'; 3 | 4 | abstract class NetworkClient { 5 | Future get( 6 | String endpoint, { 7 | Map? headers, 8 | Map? queryParameters, 9 | RestRequest? requestConfig, 10 | }); 11 | 12 | Future post( 13 | String endpoint, { 14 | Map? body, 15 | Map? headers, 16 | RestRequest? requestConfig, 17 | }); 18 | 19 | Future put( 20 | String endpoint, { 21 | Map? body, 22 | Map? headers, 23 | RestRequest? requestConfig, 24 | }); 25 | 26 | Future patch( 27 | String endpoint, { 28 | Map? body, 29 | Map? headers, 30 | RestRequest? requestConfig, 31 | }); 32 | 33 | Future delete( 34 | String endpoint, { 35 | Map? headers, 36 | RestRequest? requestConfig, 37 | }); 38 | } 39 | 40 | class NetworkResponse { 41 | final int statusCode; 42 | final dynamic data; 43 | final String error; 44 | final Map headers; 45 | 46 | const NetworkResponse({ 47 | required this.statusCode, 48 | this.data, 49 | this.error = '', 50 | this.headers = const {}, 51 | }); 52 | 53 | bool get isSuccessful => statusCode >= 200 && statusCode < 300; 54 | 55 | bool get isCreated => statusCode == 201; 56 | 57 | bool get isNoContent => statusCode == 204; 58 | 59 | bool get isBadRequest => statusCode == 400; 60 | 61 | bool get isUnauthorized => statusCode == 401; 62 | 63 | bool get isNotFound => statusCode == 404; 64 | 65 | bool get isServerError => statusCode >= 500; 66 | } 67 | -------------------------------------------------------------------------------- /lib/src/models/sync_status.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | class SyncStatus extends Equatable { 4 | final bool isConnected; 5 | final bool isSyncing; 6 | final int pendingChanges; 7 | final DateTime lastSyncTime; 8 | final String lastSyncError; 9 | final bool hasErrors; 10 | final int totalSynced; 11 | final double syncProgress; 12 | 13 | SyncStatus({ 14 | this.isConnected = false, 15 | this.isSyncing = false, 16 | this.pendingChanges = 0, 17 | DateTime? lastSyncTime, 18 | this.lastSyncError = '', 19 | this.hasErrors = false, 20 | this.totalSynced = 0, 21 | this.syncProgress = 0.0, 22 | }) : lastSyncTime = lastSyncTime ?? DateTime.fromMillisecondsSinceEpoch(0); 23 | 24 | SyncStatus copyWith({ 25 | bool? isConnected, 26 | bool? isSyncing, 27 | int? pendingChanges, 28 | DateTime? lastSyncTime, 29 | String? lastSyncError, 30 | bool? hasErrors, 31 | int? totalSynced, 32 | double? syncProgress, 33 | }) { 34 | return SyncStatus( 35 | isConnected: isConnected ?? this.isConnected, 36 | isSyncing: isSyncing ?? this.isSyncing, 37 | pendingChanges: pendingChanges ?? this.pendingChanges, 38 | lastSyncTime: lastSyncTime ?? this.lastSyncTime, 39 | lastSyncError: lastSyncError ?? this.lastSyncError, 40 | hasErrors: hasErrors ?? this.hasErrors, 41 | totalSynced: totalSynced ?? this.totalSynced, 42 | syncProgress: syncProgress ?? this.syncProgress, 43 | ); 44 | } 45 | 46 | bool get needsSync => pendingChanges > 0; 47 | 48 | @override 49 | List get props => [ 50 | isConnected, 51 | isSyncing, 52 | pendingChanges, 53 | lastSyncTime, 54 | lastSyncError, 55 | hasErrors, 56 | totalSynced, 57 | syncProgress, 58 | ]; 59 | } 60 | -------------------------------------------------------------------------------- /lib/src/enums/sync_strategy.dart: -------------------------------------------------------------------------------- 1 | /// Defines different strategies for handling delete operations in offline-first mode. 2 | /// Can be specified at both the global level (via SyncOptions) and the model level (via SyncModel). 3 | enum DeleteStrategy { 4 | /// Delete local data immediately before waiting for the remote response. 5 | /// Provides better user experience but may lead to inconsistency if remote operation fails. 6 | optimisticDelete, 7 | 8 | /// Delete local data only after successful remote deletion. 9 | /// More consistent but may appear slower to users. 10 | waitForRemote, 11 | } 12 | 13 | /// Defines strategies for retrieving data with offline-first approach. 14 | /// Can be specified at both the global level (via SyncOptions) and the model level (via SyncModel). 15 | enum FetchStrategy { 16 | /// Returns local data immediately while fetching from remote in the background. 17 | /// Remote fetch happens on every call but is not awaited. 18 | backgroundSync, 19 | 20 | /// Always waits for remote data before returning results. 21 | /// Returns empty if offline or remote fails. 22 | remoteFirst, 23 | 24 | /// Uses local data if available, otherwise waits for remote data. 25 | /// Good balance between performance and freshness. 26 | localWithRemoteFallback, 27 | 28 | /// Only uses locally cached data without any remote operations. 29 | /// Fastest but may return stale data. 30 | localOnly, 31 | } 32 | 33 | /// Defines strategies for save operations (insert/update) in offline-first mode. 34 | /// Can be specified at both the global level (via SyncOptions) and the model level (via SyncModel). 35 | enum SaveStrategy { 36 | /// Saves data locally immediately before waiting for remote response. 37 | /// Better user experience but may lead to inconsistency if remote operation fails. 38 | optimisticSave, 39 | 40 | /// Saves data locally only after successful remote operation. 41 | /// More consistent but may appear slower to users. 42 | waitForRemote, 43 | } 44 | -------------------------------------------------------------------------------- /lib/src/models/sync_event.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'sync_event_type.dart'; 3 | import 'sync_model.dart'; 4 | 5 | /// A class representing an event occurring in the synchronization system. 6 | /// 7 | /// This class contains information about events triggered by the 8 | /// synchronization system, including relevant data and metadata. 9 | class SyncEvent extends Equatable { 10 | /// The type of event 11 | final SyncEventType type; 12 | 13 | /// Message describing the event 14 | final String message; 15 | 16 | /// The model related to the event (optional) 17 | final SyncModel? model; 18 | 19 | /// Additional data related to the event 20 | final Map? data; 21 | 22 | /// Timestamp when the event occurred 23 | final DateTime timestamp; 24 | 25 | /// Creates a new SyncEvent instance. 26 | /// 27 | /// [type] event type, [message] event description, 28 | /// [model] related model (optional), [data] additional data (optional). 29 | /// [timestamp] defaults to current time if not provided. 30 | SyncEvent({ 31 | required this.type, 32 | this.message = '', 33 | this.model, 34 | this.data, 35 | DateTime? timestamp, 36 | }) : timestamp = timestamp ?? DateTime.now(); 37 | 38 | @override 39 | List get props => [type, message, model, data, timestamp]; 40 | 41 | @override 42 | String toString() { 43 | return 'SyncEvent{type: $type, message: $message, timestamp: $timestamp}'; 44 | } 45 | 46 | /// Creates a copy of this event with the given fields replaced. 47 | SyncEvent copyWith({ 48 | SyncEventType? type, 49 | String? message, 50 | SyncModel? model, 51 | Map? data, 52 | DateTime? timestamp, 53 | }) { 54 | return SyncEvent( 55 | type: type ?? this.type, 56 | message: message ?? this.message, 57 | model: model ?? this.model, 58 | data: data ?? this.data, 59 | timestamp: timestamp ?? this.timestamp, 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/src/services/storage_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import '../models/sync_model.dart'; 3 | import '../query/query.dart'; 4 | 5 | abstract class StorageService { 6 | Future initialize(); 7 | 8 | Future get(String id, String modelType); 9 | 10 | Future> getAll(String modelType); 11 | 12 | Future> getPending(String modelType); 13 | 14 | /// Retrieve items of a specific type with optional query parameters 15 | /// 16 | /// Parameters: 17 | /// - [modelType]: The model type to retrieve 18 | /// - [query]: Optional query parameters to filter the items 19 | /// 20 | /// Returns a list of items matching the query 21 | @Deprecated('Use getItemsWithQuery instead') 22 | Future> getItems( 23 | String modelType, { 24 | Map? query, 25 | }); 26 | 27 | /// Retrieve items of a specific type with a structured Query 28 | /// 29 | /// This method provides a more powerful and type-safe way to query data 30 | /// using the Query class which supports where conditions, ordering, and pagination. 31 | /// 32 | /// Parameters: 33 | /// - [modelType]: The model type to retrieve 34 | /// - [query]: Optional structured query to filter the items 35 | /// 36 | /// Returns a list of items matching the query 37 | Future> getItemsWithQuery( 38 | String modelType, { 39 | Query? query, 40 | }); 41 | 42 | Future save(T model); 43 | 44 | Future saveAll(List models); 45 | 46 | Future update(T model); 47 | 48 | /// Delete a model by its ID and type 49 | Future delete(String id, String modelType); 50 | 51 | /// Delete a model directly using the model instance 52 | Future deleteModel(T model); 53 | 54 | Future markAsSynced(String id, String modelType); 55 | 56 | Future markSyncFailed( 57 | String id, 58 | String modelType, 59 | String error, 60 | ); 61 | 62 | Future getPendingCount(); 63 | 64 | Future getLastSyncTime(); 65 | 66 | Future setLastSyncTime(DateTime time); 67 | 68 | Future clearAll(); 69 | 70 | Future close(); 71 | } 72 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: offline_sync_kit 2 | description: A comprehensive Flutter package for offline data synchronization with remote APIs, featuring real-time WebSocket support, automatic conflict resolution, and customizable options. 3 | version: 1.5.3 4 | homepage: https://github.com/CanArslanDev/offline_sync_kit 5 | 6 | environment: 7 | sdk: ^3.7.0 8 | flutter: ">=1.17.0" 9 | 10 | dependencies: 11 | flutter: 12 | sdk: flutter 13 | sqflite: ^2.3.0 14 | sqflite_common_ffi: ^2.3.0 15 | isar: ^3.1.0+1 16 | isar_flutter_libs: ^3.1.0+1 17 | uuid: ^4.5.1 18 | http: ^1.3.0 19 | connectivity_plus: ^6.1.3 20 | path_provider: ^2.1.5 21 | equatable: ^2.0.7 22 | intl: ^0.20.2 23 | rxdart: ^0.28.0 24 | path: ^1.9.1 25 | encrypt: ^5.0.3 26 | crypto: ^3.0.6 27 | 28 | dev_dependencies: 29 | flutter_test: 30 | sdk: flutter 31 | flutter_lints: ^5.0.0 32 | isar_generator: ^3.1.0+1 33 | build_runner: ^2.4.8 34 | 35 | # For information on the generic Dart part of this file, see the 36 | # following page: https://dart.dev/tools/pub/pubspec 37 | 38 | # The following section is specific to Flutter packages. 39 | flutter: 40 | 41 | # To add assets to your package, add an assets section, like this: 42 | # assets: 43 | # - images/a_dot_burr.jpeg 44 | # - images/a_dot_ham.jpeg 45 | # 46 | # For details regarding assets in packages, see 47 | # https://flutter.dev/to/asset-from-package 48 | # 49 | # An image asset can refer to one or more resolution-specific "variants", see 50 | # https://flutter.dev/to/resolution-aware-images 51 | 52 | # To add custom fonts to your package, add a fonts section here, 53 | # in this "flutter" section. Each entry in this list should have a 54 | # "family" key with the font family name, and a "fonts" key with a 55 | # list giving the asset and other descriptors for the font. For 56 | # example: 57 | # fonts: 58 | # - family: Schyler 59 | # fonts: 60 | # - asset: fonts/Schyler-Regular.ttf 61 | # - asset: fonts/Schyler-Italic.ttf 62 | # style: italic 63 | # - family: Trajan Pro 64 | # fonts: 65 | # - asset: fonts/TrajanPro.ttf 66 | # - asset: fonts/TrajanPro_Bold.ttf 67 | # weight: 700 68 | # 69 | # For details regarding fonts in packages, see 70 | # https://flutter.dev/to/font-from-package 71 | 72 | platforms: 73 | android: 74 | ios: 75 | macos: 76 | windows: 77 | -------------------------------------------------------------------------------- /lib/src/services/connectivity_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'package:connectivity_plus/connectivity_plus.dart'; 3 | import 'package:rxdart/rxdart.dart'; 4 | import '../models/connectivity_options.dart'; 5 | 6 | abstract class ConnectivityService { 7 | Stream get connectionStream; 8 | Future get isConnected; 9 | Future isConnectionSatisfied(ConnectivityOptions requirements); 10 | } 11 | 12 | class ConnectivityServiceImpl implements ConnectivityService { 13 | final Connectivity _connectivity; 14 | final BehaviorSubject _connectionSubject = BehaviorSubject(); 15 | 16 | ConnectivityServiceImpl({Connectivity? connectivity}) 17 | : _connectivity = connectivity ?? Connectivity() { 18 | _initConnectivity(); 19 | _setupConnectivityListener(); 20 | } 21 | 22 | Future _initConnectivity() async { 23 | try { 24 | final results = await _connectivity.checkConnectivity(); 25 | _updateConnectionStatus(results); 26 | } catch (e) { 27 | _connectionSubject.add(false); 28 | } 29 | } 30 | 31 | void _setupConnectivityListener() { 32 | _connectivity.onConnectivityChanged.listen(_updateConnectionStatus); 33 | } 34 | 35 | void _updateConnectionStatus(List results) { 36 | final isConnected = 37 | results.isNotEmpty && 38 | results.any((result) => result != ConnectivityResult.none); 39 | _connectionSubject.add(isConnected); 40 | } 41 | 42 | @override 43 | Stream get connectionStream => _connectionSubject.stream; 44 | 45 | @override 46 | Future get isConnected async { 47 | final results = await _connectivity.checkConnectivity(); 48 | return results.isNotEmpty && 49 | results.any((result) => result != ConnectivityResult.none); 50 | } 51 | 52 | @override 53 | Future isConnectionSatisfied(ConnectivityOptions requirements) async { 54 | final results = await _connectivity.checkConnectivity(); 55 | 56 | switch (requirements) { 57 | case ConnectivityOptions.any: 58 | return results.isNotEmpty && 59 | results.any((result) => result != ConnectivityResult.none); 60 | case ConnectivityOptions.wifi: 61 | return results.contains(ConnectivityResult.wifi); 62 | case ConnectivityOptions.mobile: 63 | return results.contains(ConnectivityResult.mobile); 64 | case ConnectivityOptions.none: 65 | return true; 66 | } 67 | } 68 | 69 | void dispose() { 70 | _connectionSubject.close(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/src/models/sync_result.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | enum SyncResultStatus { 4 | success, 5 | failed, 6 | partial, 7 | noChanges, 8 | connectionError, 9 | serverError, 10 | } 11 | 12 | /// Defines the source of data in a sync result 13 | enum ResultSource { 14 | /// Data came from the remote provider 15 | remote, 16 | 17 | /// Data came from local storage 18 | local, 19 | 20 | /// Data came from local storage due to being offline 21 | offlineCache, 22 | } 23 | 24 | class SyncResult extends Equatable { 25 | final SyncResultStatus status; 26 | final int processedItems; 27 | final int failedItems; 28 | final List errorMessages; 29 | final DateTime timestamp; 30 | final Duration timeTaken; 31 | final T? data; 32 | final ResultSource source; 33 | 34 | SyncResult({ 35 | required this.status, 36 | this.processedItems = 0, 37 | this.failedItems = 0, 38 | List? errorMessages, 39 | DateTime? timestamp, 40 | this.timeTaken = Duration.zero, 41 | this.data, 42 | this.source = ResultSource.remote, 43 | }) : errorMessages = errorMessages ?? [], 44 | timestamp = timestamp ?? DateTime.now(); 45 | 46 | bool get isSuccessful => 47 | status == SyncResultStatus.success || 48 | status == SyncResultStatus.noChanges; 49 | 50 | @override 51 | List get props => [ 52 | status, 53 | processedItems, 54 | failedItems, 55 | errorMessages, 56 | timestamp, 57 | timeTaken, 58 | data, 59 | source, 60 | ]; 61 | 62 | static SyncResult success({ 63 | int processedItems = 0, 64 | Duration timeTaken = Duration.zero, 65 | T? data, 66 | ResultSource source = ResultSource.remote, 67 | }) { 68 | return SyncResult( 69 | status: SyncResultStatus.success, 70 | processedItems: processedItems, 71 | timeTaken: timeTaken, 72 | data: data, 73 | source: source, 74 | ); 75 | } 76 | 77 | static SyncResult failed({ 78 | String error = '', 79 | Duration timeTaken = Duration.zero, 80 | }) { 81 | return SyncResult( 82 | status: SyncResultStatus.failed, 83 | errorMessages: error.isNotEmpty ? [error] : [], 84 | timeTaken: timeTaken, 85 | ); 86 | } 87 | 88 | static SyncResult noChanges() { 89 | return SyncResult(status: SyncResultStatus.noChanges); 90 | } 91 | 92 | static SyncResult connectionError() { 93 | return SyncResult( 94 | status: SyncResultStatus.connectionError, 95 | errorMessages: ['No internet connection available'], 96 | source: ResultSource.offlineCache, 97 | ); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /example/lib/models/encryption_manager.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:offline_sync_kit/offline_sync_kit.dart'; 4 | 5 | // A singleton to manage encryption settings 6 | class EncryptionManager { 7 | static final EncryptionManager _instance = EncryptionManager._internal(); 8 | 9 | factory EncryptionManager() => _instance; 10 | 11 | EncryptionManager._internal(); 12 | 13 | // Stream controller to notify listeners when encryption settings change 14 | final StreamController _encryptionStatusController = 15 | StreamController.broadcast(); 16 | 17 | // Current encryption status 18 | bool _isEncryptionEnabled = true; 19 | 20 | // Getter for encryption status 21 | bool get isEncryptionEnabled => _isEncryptionEnabled; 22 | 23 | // Stream to listen for encryption status changes 24 | Stream get encryptionStatusStream => _encryptionStatusController.stream; 25 | 26 | // Initialize encryption manager 27 | Future initialize() async { 28 | // In a real app, you would get the initial state from somewhere persistent 29 | _isEncryptionEnabled = true; 30 | _encryptionStatusController.add(_isEncryptionEnabled); 31 | } 32 | 33 | // Enable encryption with the given key 34 | Future enableEncryption(String key) async { 35 | try { 36 | // Call the OfflineSyncManager to enable encryption 37 | OfflineSyncManager.instance.enableEncryption(key); 38 | 39 | _isEncryptionEnabled = true; 40 | _encryptionStatusController.add(_isEncryptionEnabled); 41 | 42 | debugPrint('Encryption enabled with key: $key'); 43 | } catch (e) { 44 | debugPrint('Error enabling encryption: $e'); 45 | rethrow; 46 | } 47 | } 48 | 49 | // Disable encryption 50 | Future disableEncryption() async { 51 | try { 52 | // Call the OfflineSyncManager to disable encryption 53 | OfflineSyncManager.instance.disableEncryption(); 54 | 55 | _isEncryptionEnabled = false; 56 | _encryptionStatusController.add(_isEncryptionEnabled); 57 | 58 | debugPrint('Encryption disabled'); 59 | } catch (e) { 60 | debugPrint('Error disabling encryption: $e'); 61 | rethrow; 62 | } 63 | } 64 | 65 | // Toggle encryption with the given key (if enabling) 66 | Future toggleEncryption(bool enabled, [String? key]) async { 67 | if (enabled) { 68 | if (key == null || key.isEmpty) { 69 | throw ArgumentError( 70 | 'Encryption key must be provided when enabling encryption', 71 | ); 72 | } 73 | await enableEncryption(key); 74 | } else { 75 | await disableEncryption(); 76 | } 77 | } 78 | 79 | // Dispose resources 80 | void dispose() { 81 | _encryptionStatusController.close(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Offline Sync Kit - Example App 2 | 3 | This example app demonstrates the powerful features and usage of the Offline Sync Kit package. 4 | 5 | ## Demonstrated Features 6 | 7 | ### 1. Delta Synchronization 8 | 9 | Delta synchronization saves bandwidth by only sending fields that have changed to the server. 10 | 11 | ```dart 12 | // Todo model tracks changed fields 13 | todo = todo.updateTitle("New title"); // Only the title field is modified 14 | await OfflineSyncManager.instance.syncItemDelta(todo); // Only sends changed field 15 | ``` 16 | 17 | ### 2. Data Encryption (Optional) 18 | 19 | End-to-end encryption support has been added to secure sensitive data. 20 | 21 | ```dart 22 | // Enable encryption when initializing the app 23 | await OfflineSyncManager.initialize( 24 | baseUrl: 'https://api.example.com', 25 | storageService: myStorage, 26 | enableEncryption: true, 27 | encryptionKey: 'secure-key', 28 | ); 29 | ``` 30 | 31 | ### 3. Conflict Resolution Strategies 32 | 33 | Various strategies for resolving conflicts when the same data is updated in different environments: 34 | 35 | - Server Wins: Server version always takes precedence 36 | - Client Wins: Client version always takes precedence 37 | - Last Update Wins: Most recently updated version takes precedence 38 | - Custom: Custom resolution strategy 39 | 40 | ### 4. Offline Data Management 41 | 42 | Full offline mode that allows the app to function even without an internet connection: 43 | 44 | - Offline data addition 45 | - Offline updates 46 | - Automatic synchronization when connection is restored 47 | - Synchronization status tracking 48 | 49 | ### 5. User Interface Enhancements 50 | 51 | - Status bar showing synchronization state 52 | - Conflict strategy selector 53 | - Delta synchronization toggle 54 | - Synchronization status indicator for each item 55 | 56 | ## Usage 57 | 58 | 1. Start the application 59 | 2. Click the "+" button in the bottom right corner to add a Todo 60 | 3. Click on a Todo to view and edit its details 61 | 4. Use the toggle to enable/disable delta synchronization 62 | 5. Click the settings icon to change the conflict resolution strategy 63 | 6. Check the top bar for synchronization status information 64 | 7. Use the sync button for manual synchronization 65 | 66 | ## Recommended Test Scenarios 67 | 68 | 1. **Delta Synchronization Test**: Add a Todo and change just one field, observe that only that field is synchronized 69 | 2. **Offline Mode Test**: Turn on airplane mode, add several Todos, then turn off airplane mode and observe automatic synchronization 70 | 3. **Conflict Resolution Test**: Test different conflict resolution strategies to observe behavioral differences 71 | 72 | This example app is designed to showcase the use of Offline Sync Kit in real-world scenarios. You can easily implement similar functionality in your own projects using this package. 73 | -------------------------------------------------------------------------------- /lib/src/repositories/sync_repository.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import '../models/sync_model.dart'; 3 | import '../models/sync_result.dart'; 4 | import '../network/rest_request.dart'; 5 | 6 | abstract class SyncRepository { 7 | Future syncItem(T item); 8 | 9 | /// Synchronizes only the changed fields of an item with the server 10 | /// 11 | /// This method sends only the delta (changed fields) of the model to reduce 12 | /// bandwidth and improve performance. 13 | /// 14 | /// Parameters: 15 | /// - [item]: The model to synchronize 16 | /// - [changedFields]: Map of field names to their new values 17 | /// 18 | /// Returns a [SyncResult] with the outcome of the operation 19 | Future syncDelta( 20 | T item, 21 | Map changedFields, 22 | ); 23 | 24 | Future syncAll( 25 | List items, { 26 | bool bidirectional = true, 27 | }); 28 | 29 | Future pullFromServer( 30 | String modelType, 31 | DateTime? lastSyncTime, { 32 | Map)>? modelFactories, 33 | }); 34 | 35 | /// Creates a new item on the server 36 | /// 37 | /// Parameters: 38 | /// - [item]: The model to create 39 | /// - [requestConfig]: Optional custom request configuration 40 | /// 41 | /// Returns the created model with updated sync status or null if failed 42 | Future createItem( 43 | T item, { 44 | RestRequest? requestConfig, 45 | }); 46 | 47 | /// Updates an existing item on the server 48 | /// 49 | /// Parameters: 50 | /// - [item]: The model to update 51 | /// - [requestConfig]: Optional custom request configuration 52 | /// 53 | /// Returns the updated model with sync status or null if failed 54 | Future updateItem( 55 | T item, { 56 | RestRequest? requestConfig, 57 | }); 58 | 59 | Future deleteItem(T item); 60 | 61 | /// Fetches items of a specific model type from the server 62 | /// 63 | /// [modelType] - The type of model to fetch 64 | /// [since] - Optional timestamp to only fetch items modified since this time 65 | /// [limit] - Optional maximum number of items to fetch 66 | /// [offset] - Optional offset for pagination 67 | /// [modelFactories] - Map of model factories to create instances from JSON 68 | /// Returns a list of model instances 69 | Future> fetchItems( 70 | String modelType, { 71 | DateTime? since, 72 | int? limit, 73 | int? offset, 74 | Map)>? modelFactories, 75 | }); 76 | 77 | /// Gets items with query parameters and returns a SyncResult 78 | /// 79 | /// This method is similar to fetchItems but returns a SyncResult with additional information 80 | /// about the source and status of the data. 81 | /// 82 | /// Parameters: 83 | /// - [modelType]: The model type to fetch 84 | /// - [query]: Optional query parameters to filter the items 85 | /// - [requestConfig]: Optional custom request configuration 86 | /// 87 | /// Returns a [SyncResult] containing the items and operation details 88 | Future>> getItems( 89 | String modelType, { 90 | Map? query, 91 | RestRequest? requestConfig, 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /lib/src/models/conflict_resolution_strategy.dart: -------------------------------------------------------------------------------- 1 | /// Defines the strategy to use when a conflict is detected during synchronization 2 | enum ConflictResolutionStrategy { 3 | /// Server version always wins over the local version 4 | serverWins, 5 | 6 | /// Local version always wins over the server version 7 | clientWins, 8 | 9 | /// The most recently updated version wins 10 | lastUpdateWins, 11 | 12 | /// Custom resolution strategy using a provided resolver function 13 | custom, 14 | } 15 | 16 | /// Represents a data conflict between local and server versions 17 | class SyncConflict { 18 | /// The local version of the data 19 | final T localVersion; 20 | 21 | /// The server version of the data 22 | final T serverVersion; 23 | 24 | /// Creates a new conflict instance 25 | SyncConflict({required this.localVersion, required this.serverVersion}); 26 | } 27 | 28 | /// Signature for a custom conflict resolver function 29 | typedef ConflictResolver = T Function(SyncConflict conflict); 30 | 31 | /// Handles conflict resolution for synchronization operations 32 | class ConflictResolutionHandler { 33 | /// The strategy to use when resolving conflicts 34 | final ConflictResolutionStrategy strategy; 35 | 36 | /// Custom resolver function, required when using [ConflictResolutionStrategy.custom] 37 | final ConflictResolver? customResolver; 38 | 39 | /// Creates a new conflict resolution handler 40 | /// 41 | /// Parameters: 42 | /// - [strategy]: The conflict resolution strategy to use 43 | /// - [customResolver]: Custom resolver function, required when using [ConflictResolutionStrategy.custom] 44 | ConflictResolutionHandler({ 45 | this.strategy = ConflictResolutionStrategy.lastUpdateWins, 46 | this.customResolver, 47 | }) { 48 | if (strategy == ConflictResolutionStrategy.custom && 49 | customResolver == null) { 50 | throw ArgumentError( 51 | 'Custom resolver function must be provided when using custom resolution strategy', 52 | ); 53 | } 54 | } 55 | 56 | /// Resolves a conflict between local and server versions 57 | /// 58 | /// Parameters: 59 | /// - [conflict]: The conflict to resolve 60 | /// 61 | /// Returns the resolved version that should be used 62 | T resolveConflict(SyncConflict conflict) { 63 | switch (strategy) { 64 | case ConflictResolutionStrategy.serverWins: 65 | return conflict.serverVersion; 66 | 67 | case ConflictResolutionStrategy.clientWins: 68 | return conflict.localVersion; 69 | 70 | case ConflictResolutionStrategy.lastUpdateWins: 71 | // Check if both versions have updatedAt timestamps 72 | if (_hasUpdatedAtProperty(conflict.localVersion) && 73 | _hasUpdatedAtProperty(conflict.serverVersion)) { 74 | // Use null-safe way to access the updatedAt properties 75 | final localVersion = conflict.localVersion as dynamic; 76 | final serverVersion = conflict.serverVersion as dynamic; 77 | 78 | final localTime = localVersion.updatedAt as DateTime; 79 | final serverTime = serverVersion.updatedAt as DateTime; 80 | 81 | // Return the most recently updated version 82 | return localTime.isAfter(serverTime) 83 | ? conflict.localVersion 84 | : conflict.serverVersion; 85 | } 86 | // Fallback to server version if timestamps can't be compared 87 | return conflict.serverVersion; 88 | 89 | case ConflictResolutionStrategy.custom: 90 | if (customResolver != null) { 91 | return customResolver!(conflict); 92 | } 93 | throw StateError('Custom resolver is null'); 94 | } 95 | } 96 | 97 | /// Checks if the object has an updatedAt property that is not null 98 | bool _hasUpdatedAtProperty(dynamic object) { 99 | if (object == null) return false; 100 | 101 | try { 102 | return object.updatedAt != null && object.updatedAt is DateTime; 103 | } catch (_) { 104 | return false; 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /lib/src/models/encryption_options.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: public_member_api_docs 2 | 3 | import 'package:encrypt/encrypt.dart' as encrypt; 4 | 5 | /// Represents the encryption configuration options 6 | /// 7 | /// This class is used to configure various aspects of data encryption 8 | /// within the offline sync system. 9 | class EncryptionOptions { 10 | /// Whether encryption is enabled 11 | final bool enabled; 12 | 13 | /// The encryption key to use 14 | final String? key; 15 | 16 | /// The encryption mode 17 | final EncryptionMode mode; 18 | 19 | /// Initialization vector length in bytes 20 | final int ivLength; 21 | 22 | /// Creates a new encryption options instance 23 | /// 24 | /// [enabled] Whether encryption is enabled (default: false) 25 | /// [key] The encryption key to use (required if enabled is true) 26 | /// [mode] The encryption mode to use (default: EncryptionMode.aes) 27 | /// [ivLength] Initialization vector length in bytes (default: 16) 28 | const EncryptionOptions({ 29 | this.enabled = false, 30 | this.key, 31 | this.mode = EncryptionMode.aes, 32 | this.ivLength = 16, 33 | }) : assert( 34 | !enabled || (enabled && key != null), 35 | 'An encryption key must be provided when encryption is enabled', 36 | ); 37 | 38 | /// Creates a copy of this object with the specified fields replaced 39 | EncryptionOptions copyWith({ 40 | bool? enabled, 41 | String? key, 42 | EncryptionMode? mode, 43 | int? ivLength, 44 | }) { 45 | return EncryptionOptions( 46 | enabled: enabled ?? this.enabled, 47 | key: key ?? this.key, 48 | mode: mode ?? this.mode, 49 | ivLength: ivLength ?? this.ivLength, 50 | ); 51 | } 52 | 53 | /// Creates a disabled encryption options instance 54 | static const EncryptionOptions disabled = EncryptionOptions(enabled: false); 55 | 56 | /// Creates a production-ready encryption options instance with AES encryption 57 | static EncryptionOptions secure(String key) => 58 | EncryptionOptions(enabled: true, key: key, mode: EncryptionMode.aes); 59 | 60 | /// A factory method to create encryption options from a map 61 | factory EncryptionOptions.fromJson(Map json) { 62 | return EncryptionOptions( 63 | enabled: json['enabled'] as bool? ?? false, 64 | key: json['key'] as String?, 65 | mode: EncryptionMode.values.firstWhere( 66 | (e) => e.name == (json['mode'] as String? ?? EncryptionMode.aes.name), 67 | orElse: () => EncryptionMode.aes, 68 | ), 69 | ivLength: json['ivLength'] as int? ?? 16, 70 | ); 71 | } 72 | 73 | /// Converts this options object to a JSON map 74 | Map toJson() { 75 | return { 76 | 'enabled': enabled, 77 | 'key': key, 78 | 'mode': mode.name, 79 | 'ivLength': ivLength, 80 | }; 81 | } 82 | } 83 | 84 | /// The encryption mode to use 85 | enum EncryptionMode { 86 | /// AES encryption (default) 87 | aes, 88 | 89 | /// Salsa20 encryption 90 | salsa20, 91 | 92 | /// Fernet encryption (includes authentication) 93 | fernet, 94 | } 95 | 96 | /// Used to determine which fields should be encrypted 97 | /// 98 | /// This is used to control which fields in a model are encrypted 99 | enum FieldEncryptionPolicy { 100 | /// Encrypt all fields except for ID and modelType 101 | allExceptIdAndType, 102 | 103 | /// Encrypt only the specified fields 104 | onlySpecifiedFields, 105 | 106 | /// Encrypt all except for the specified fields 107 | allExceptSpecifiedFields, 108 | } 109 | 110 | /// Extension methods for EncryptionMode 111 | extension EncryptionModeExtension on EncryptionMode { 112 | /// Gets the actual encrypter implementation 113 | encrypt.Encrypter getEncrypter(encrypt.Key key) { 114 | switch (this) { 115 | case EncryptionMode.aes: 116 | return encrypt.Encrypter(encrypt.AES(key)); 117 | case EncryptionMode.salsa20: 118 | return encrypt.Encrypter(encrypt.Salsa20(key)); 119 | case EncryptionMode.fernet: 120 | return encrypt.Encrypter(encrypt.Fernet(key)); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /lib/src/network/rest_requests.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import '../enums/rest_method.dart'; 3 | import 'rest_request.dart'; 4 | 5 | /// Container class to hold request configurations for different HTTP methods 6 | /// 7 | /// This class allows specifying custom request configurations for each 8 | /// HTTP method (GET, POST, PUT, DELETE, PATCH) separately. 9 | class RestRequests extends Equatable { 10 | /// Configuration for GET requests 11 | final RestRequest? get; 12 | 13 | /// Configuration for POST requests 14 | final RestRequest? post; 15 | 16 | /// Configuration for PUT requests 17 | final RestRequest? put; 18 | 19 | /// Configuration for DELETE requests 20 | final RestRequest? delete; 21 | 22 | /// Configuration for PATCH requests 23 | final RestRequest? patch; 24 | 25 | /// Creates a new RestRequests instance 26 | /// 27 | /// All parameters are optional - only define the ones you need to customize 28 | const RestRequests({this.get, this.post, this.put, this.delete, this.patch}); 29 | 30 | /// Creates a copy of this object with the specified fields replaced 31 | RestRequests copyWith({ 32 | RestRequest? get, 33 | RestRequest? post, 34 | RestRequest? put, 35 | RestRequest? delete, 36 | RestRequest? patch, 37 | }) { 38 | return RestRequests( 39 | get: get ?? this.get, 40 | post: post ?? this.post, 41 | put: put ?? this.put, 42 | delete: delete ?? this.delete, 43 | patch: patch ?? this.patch, 44 | ); 45 | } 46 | 47 | /// Creates a RestRequests instance from a JSON map 48 | factory RestRequests.fromJson(Map json) { 49 | return RestRequests( 50 | get: 51 | json['get'] != null 52 | ? RestRequest.fromJson(json['get'] as Map) 53 | : null, 54 | post: 55 | json['post'] != null 56 | ? RestRequest.fromJson(json['post'] as Map) 57 | : null, 58 | put: 59 | json['put'] != null 60 | ? RestRequest.fromJson(json['put'] as Map) 61 | : null, 62 | delete: 63 | json['delete'] != null 64 | ? RestRequest.fromJson(json['delete'] as Map) 65 | : null, 66 | patch: 67 | json['patch'] != null 68 | ? RestRequest.fromJson(json['patch'] as Map) 69 | : null, 70 | ); 71 | } 72 | 73 | /// Converts this RestRequests to a JSON map 74 | Map toJson() { 75 | return { 76 | if (get != null) 'get': get!.toJson(), 77 | if (post != null) 'post': post!.toJson(), 78 | if (put != null) 'put': put!.toJson(), 79 | if (delete != null) 'delete': delete!.toJson(), 80 | if (patch != null) 'patch': patch!.toJson(), 81 | }; 82 | } 83 | 84 | /// Gets the request configuration for the specified HTTP method 85 | /// 86 | /// [method] The REST method enum (GET, POST, PUT, DELETE, PATCH) 87 | /// Returns the corresponding RestRequest or null if not configured 88 | RestRequest? getForMethod(RestMethod method) { 89 | switch (method) { 90 | case RestMethod.get: 91 | return get; 92 | case RestMethod.post: 93 | return post; 94 | case RestMethod.put: 95 | return put; 96 | case RestMethod.delete: 97 | return delete; 98 | case RestMethod.patch: 99 | return patch; 100 | } 101 | } 102 | 103 | /// Gets the request configuration for the specified HTTP method as string 104 | /// 105 | /// This is provided for backward compatibility 106 | /// 107 | /// [methodString] The HTTP method as a string ('GET', 'POST', etc.) 108 | /// Returns the corresponding RestRequest or null if not configured 109 | RestRequest? getForMethodString(String methodString) { 110 | final upperMethod = methodString.toUpperCase(); 111 | switch (upperMethod) { 112 | case 'GET': 113 | return get; 114 | case 'POST': 115 | return post; 116 | case 'PUT': 117 | return put; 118 | case 'DELETE': 119 | return delete; 120 | case 'PATCH': 121 | return patch; 122 | default: 123 | return null; 124 | } 125 | } 126 | 127 | @override 128 | List get props => [get, post, put, delete, patch]; 129 | } 130 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: offline_sync_kit_example 2 | description: "A new Flutter project showcasing offline_sync_kit package." 3 | # The following line prevents the package from being accidentally published to 4 | # pub.dev using `flutter pub publish`. This is preferred for private packages. 5 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 6 | 7 | # The following defines the version and build number for your application. 8 | # A version number is three numbers separated by dots, like 1.2.43 9 | # followed by an optional build number separated by a +. 10 | # Both the version and the builder number may be overridden in flutter 11 | # build by specifying --build-name and --build-number, respectively. 12 | # In Android, build-name is used as versionName while build-number used as versionCode. 13 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 14 | # In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. 15 | # Read more about iOS versioning at 16 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 17 | # In Windows, build-name is used as the major, minor, and patch parts 18 | # of the product and file versions while build-number is used as the build suffix. 19 | version: 1.0.0+1 20 | 21 | environment: 22 | sdk: '>=3.0.0 <4.0.0' 23 | flutter: ">=1.17.0" 24 | 25 | # Dependencies specify other packages that your package needs in order to work. 26 | # To automatically upgrade your package dependencies to the latest versions 27 | # consider running `flutter pub upgrade --major-versions`. Alternatively, 28 | # dependencies can be manually updated by changing the version numbers below to 29 | # the latest version available on pub.dev. To see which dependencies have newer 30 | # versions available, run `flutter pub outdated`. 31 | dependencies: 32 | flutter: 33 | sdk: flutter 34 | 35 | # The following adds the Cupertino Icons font to your application. 36 | # Use with the CupertinoIcons class for iOS style icons. 37 | cupertino_icons: ^1.0.5 38 | offline_sync_kit: 39 | path: ../ 40 | path: ^1.8.3 41 | path_provider: ^2.1.1 42 | sqflite: ^2.3.0 43 | sqflite_common_ffi: ^2.3.0 44 | uuid: ^4.0.0 45 | equatable: ^2.0.5 46 | http: ^1.1.0 47 | 48 | dev_dependencies: 49 | flutter_test: 50 | sdk: flutter 51 | 52 | # The "flutter_lints" package below contains a set of recommended lints to 53 | # encourage good coding practices. The lint set provided by the package is 54 | # activated in the `analysis_options.yaml` file located at the root of your 55 | # package. See that file for information about deactivating specific lint 56 | # rules and activating additional ones. 57 | flutter_lints: ^5.0.0 58 | 59 | # For information on the generic Dart part of this file, see the 60 | # following page: https://dart.dev/tools/pub/pubspec 61 | 62 | # The following section is specific to Flutter packages. 63 | flutter: 64 | 65 | # The following line ensures that the Material Icons font is 66 | # included with your application, so that you can use the icons in 67 | # the material Icons class. 68 | uses-material-design: true 69 | 70 | # To add assets to your application, add an assets section, like this: 71 | # assets: 72 | # - images/a_dot_burr.jpeg 73 | # - images/a_dot_ham.jpeg 74 | 75 | # An image asset can refer to one or more resolution-specific "variants", see 76 | # https://flutter.dev/to/resolution-aware-images 77 | 78 | # For details regarding adding assets from package dependencies, see 79 | # https://flutter.dev/to/asset-from-package 80 | 81 | # To add custom fonts to your application, add a fonts section here, 82 | # in this "flutter" section. Each entry in this list should have a 83 | # "family" key with the font family name, and a "fonts" key with a 84 | # list giving the asset and other descriptors for the font. For 85 | # example: 86 | # fonts: 87 | # - family: Schyler 88 | # fonts: 89 | # - asset: fonts/Schyler-Regular.ttf 90 | # - asset: fonts/Schyler-Italic.ttf 91 | # style: italic 92 | # - family: Trajan Pro 93 | # fonts: 94 | # - asset: fonts/TrajanPro.ttf 95 | # - asset: fonts/TrajanPro_Bold.ttf 96 | # weight: 700 97 | # 98 | # For details regarding fonts from package dependencies, 99 | # see https://flutter.dev/to/font-from-package 100 | -------------------------------------------------------------------------------- /lib/src/models/sync_event_mapper.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'sync_event_type.dart'; 3 | 4 | /// A mapper for converting WebSocket event names to SyncEventType and vice versa. 5 | /// 6 | /// This class allows customizing the event type mapping between WebSocket messages 7 | /// and the application's SyncEventType enum, enabling full customization of event names. 8 | class SyncEventMapper extends Equatable { 9 | /// Mapping from WebSocket event names to SyncEventType 10 | final Map eventNameToTypeMap; 11 | 12 | /// Mapping from SyncEventType to WebSocket event names 13 | final Map typeToEventNameMap; 14 | 15 | /// Creates a new SyncEventMapper with custom mappings. 16 | /// 17 | /// By default, it uses standard event names that match the SyncEventType enum names. 18 | /// These can be customized to match any WebSocket server's event naming conventions. 19 | const SyncEventMapper({ 20 | Map? eventNameToTypeMap, 21 | Map? typeToEventNameMap, 22 | }) : eventNameToTypeMap = eventNameToTypeMap ?? _defaultEventNameToTypeMap, 23 | typeToEventNameMap = typeToEventNameMap ?? _defaultTypeToEventNameMap; 24 | 25 | /// Maps a WebSocket event name to a SyncEventType. 26 | /// 27 | /// Returns null if the event name does not have a defined mapping. 28 | SyncEventType? mapEventNameToType(String eventName) { 29 | return eventNameToTypeMap[eventName]; 30 | } 31 | 32 | /// Maps a SyncEventType to a WebSocket event name. 33 | /// 34 | /// Returns the enum name as a fallback if no mapping is defined. 35 | String mapTypeToEventName(SyncEventType type) { 36 | return typeToEventNameMap[type] ?? type.name; 37 | } 38 | 39 | /// Creates a copy of this mapper with the specified fields replaced. 40 | SyncEventMapper copyWith({ 41 | Map? eventNameToTypeMap, 42 | Map? typeToEventNameMap, 43 | }) { 44 | return SyncEventMapper( 45 | eventNameToTypeMap: eventNameToTypeMap ?? this.eventNameToTypeMap, 46 | typeToEventNameMap: typeToEventNameMap ?? this.typeToEventNameMap, 47 | ); 48 | } 49 | 50 | /// Adds a new mapping or updates an existing one. 51 | SyncEventMapper withMapping(String eventName, SyncEventType type) { 52 | final updatedEventToType = Map.from( 53 | eventNameToTypeMap, 54 | )..putIfAbsent(eventName, () => type); 55 | 56 | final updatedTypeToEvent = Map.from( 57 | typeToEventNameMap, 58 | )..putIfAbsent(type, () => eventName); 59 | 60 | return SyncEventMapper( 61 | eventNameToTypeMap: updatedEventToType, 62 | typeToEventNameMap: updatedTypeToEvent, 63 | ); 64 | } 65 | 66 | /// Default mapping from WebSocket event names to SyncEventType 67 | static const Map _defaultEventNameToTypeMap = { 68 | 'modelUpdated': SyncEventType.modelUpdated, 69 | 'modelAdded': SyncEventType.modelAdded, 70 | 'modelDeleted': SyncEventType.modelDeleted, 71 | 'syncCompleted': SyncEventType.syncCompleted, 72 | 'syncStarted': SyncEventType.syncStarted, 73 | 'syncError': SyncEventType.syncError, 74 | 'connectionEstablished': SyncEventType.connectionEstablished, 75 | 'connectionClosed': SyncEventType.connectionClosed, 76 | 'connectionFailed': SyncEventType.connectionFailed, 77 | 'reconnecting': SyncEventType.reconnecting, 78 | 'conflictDetected': SyncEventType.conflictDetected, 79 | 'conflictResolved': SyncEventType.conflictResolved, 80 | }; 81 | 82 | /// Default mapping from SyncEventType to WebSocket event names 83 | static const Map _defaultTypeToEventNameMap = { 84 | SyncEventType.modelUpdated: 'modelUpdated', 85 | SyncEventType.modelAdded: 'modelAdded', 86 | SyncEventType.modelDeleted: 'modelDeleted', 87 | SyncEventType.syncCompleted: 'syncCompleted', 88 | SyncEventType.syncStarted: 'syncStarted', 89 | SyncEventType.syncError: 'syncError', 90 | SyncEventType.connectionEstablished: 'connectionEstablished', 91 | SyncEventType.connectionClosed: 'connectionClosed', 92 | SyncEventType.connectionFailed: 'connectionFailed', 93 | SyncEventType.reconnecting: 'reconnecting', 94 | SyncEventType.conflictDetected: 'conflictDetected', 95 | SyncEventType.conflictResolved: 'conflictResolved', 96 | }; 97 | 98 | @override 99 | List get props => [eventNameToTypeMap, typeToEventNameMap]; 100 | } 101 | -------------------------------------------------------------------------------- /lib/src/models/sync_options.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'connectivity_options.dart'; 3 | import 'conflict_resolution_strategy.dart'; 4 | import '../enums/sync_strategy.dart'; 5 | 6 | /// Configuration options for synchronization behavior 7 | class SyncOptions extends Equatable { 8 | /// The interval between automatic synchronization attempts 9 | final Duration syncInterval; 10 | 11 | /// The maximum number of retry attempts for failed synchronization 12 | final int maxRetryAttempts; 13 | 14 | /// Whether synchronization should be performed automatically 15 | final bool autoSync; 16 | 17 | /// Whether to sync both from client to server and server to client 18 | final bool bidirectionalSync; 19 | 20 | /// Network connectivity requirements for synchronization 21 | final ConnectivityOptions connectivityRequirement; 22 | 23 | /// Number of items to process in a single batch 24 | final int batchSize; 25 | 26 | /// Factor by which to increase delay between retry attempts 27 | final Duration retryBackoffFactor; 28 | 29 | /// Initial delay before the first retry attempt 30 | final Duration initialBackoffDelay; 31 | 32 | /// Maximum delay between retry attempts 33 | final Duration maxBackoffDelay; 34 | 35 | /// Whether to use delta synchronization (only sync changed fields) 36 | final bool useDeltaSync; 37 | 38 | /// Strategy to use when resolving conflicts 39 | final ConflictResolutionStrategy conflictStrategy; 40 | 41 | /// Custom conflict resolver for advanced conflict resolution 42 | final ConflictResolver? conflictResolver; 43 | 44 | /// Strategy to use when fetching data 45 | final FetchStrategy fetchStrategy; 46 | 47 | /// Strategy to use when deleting data 48 | final DeleteStrategy deleteStrategy; 49 | 50 | /// Strategy to use when saving data (insert/update) 51 | final SaveStrategy saveStrategy; 52 | 53 | /// Creates a new set of synchronization options 54 | const SyncOptions({ 55 | this.syncInterval = const Duration(minutes: 15), 56 | this.maxRetryAttempts = 5, 57 | this.autoSync = true, 58 | this.bidirectionalSync = true, 59 | this.connectivityRequirement = ConnectivityOptions.any, 60 | this.batchSize = 10, 61 | this.retryBackoffFactor = const Duration(seconds: 2), 62 | this.initialBackoffDelay = const Duration(seconds: 1), 63 | this.maxBackoffDelay = const Duration(minutes: 5), 64 | this.useDeltaSync = false, 65 | this.conflictStrategy = ConflictResolutionStrategy.lastUpdateWins, 66 | this.conflictResolver, 67 | this.fetchStrategy = FetchStrategy.backgroundSync, 68 | this.deleteStrategy = DeleteStrategy.optimisticDelete, 69 | this.saveStrategy = SaveStrategy.optimisticSave, 70 | }); 71 | 72 | /// Creates a copy of these options with the given fields replaced with new values 73 | SyncOptions copyWith({ 74 | Duration? syncInterval, 75 | int? maxRetryAttempts, 76 | bool? autoSync, 77 | bool? bidirectionalSync, 78 | ConnectivityOptions? connectivityRequirement, 79 | int? batchSize, 80 | Duration? retryBackoffFactor, 81 | Duration? initialBackoffDelay, 82 | Duration? maxBackoffDelay, 83 | bool? useDeltaSync, 84 | ConflictResolutionStrategy? conflictStrategy, 85 | ConflictResolver? conflictResolver, 86 | FetchStrategy? fetchStrategy, 87 | DeleteStrategy? deleteStrategy, 88 | SaveStrategy? saveStrategy, 89 | }) { 90 | return SyncOptions( 91 | syncInterval: syncInterval ?? this.syncInterval, 92 | maxRetryAttempts: maxRetryAttempts ?? this.maxRetryAttempts, 93 | autoSync: autoSync ?? this.autoSync, 94 | bidirectionalSync: bidirectionalSync ?? this.bidirectionalSync, 95 | connectivityRequirement: 96 | connectivityRequirement ?? this.connectivityRequirement, 97 | batchSize: batchSize ?? this.batchSize, 98 | retryBackoffFactor: retryBackoffFactor ?? this.retryBackoffFactor, 99 | initialBackoffDelay: initialBackoffDelay ?? this.initialBackoffDelay, 100 | maxBackoffDelay: maxBackoffDelay ?? this.maxBackoffDelay, 101 | useDeltaSync: useDeltaSync ?? this.useDeltaSync, 102 | conflictStrategy: conflictStrategy ?? this.conflictStrategy, 103 | conflictResolver: conflictResolver ?? this.conflictResolver, 104 | fetchStrategy: fetchStrategy ?? this.fetchStrategy, 105 | deleteStrategy: deleteStrategy ?? this.deleteStrategy, 106 | saveStrategy: saveStrategy ?? this.saveStrategy, 107 | ); 108 | } 109 | 110 | @override 111 | List get props => [ 112 | syncInterval, 113 | maxRetryAttempts, 114 | autoSync, 115 | bidirectionalSync, 116 | connectivityRequirement, 117 | batchSize, 118 | retryBackoffFactor, 119 | initialBackoffDelay, 120 | maxBackoffDelay, 121 | useDeltaSync, 122 | conflictStrategy, 123 | conflictResolver, 124 | fetchStrategy, 125 | deleteStrategy, 126 | saveStrategy, 127 | ]; 128 | 129 | /// The interval in seconds for periodic synchronization 130 | int get syncIntervalSeconds => syncInterval.inSeconds; 131 | 132 | /// Whether periodic synchronization is enabled 133 | bool get periodicSyncEnabled => autoSync && syncInterval.inSeconds > 0; 134 | 135 | /// Whether to synchronize when connectivity is restored 136 | bool get syncOnConnect => autoSync; 137 | 138 | /// Creates a conflict resolution handler based on these options 139 | ConflictResolutionHandler createConflictHandler() { 140 | return ConflictResolutionHandler( 141 | strategy: conflictStrategy, 142 | customResolver: conflictResolver, 143 | ); 144 | } 145 | } 146 | 147 | /// Network connectivity requirements for sync operations 148 | enum ConnectivityRequirement { 149 | /// Sync can be performed on any available network connection 150 | any, 151 | 152 | /// Sync should only be performed on WiFi connections 153 | wifi, 154 | 155 | /// Sync should only be performed on unmetered connections (no data charges) 156 | unmetered, 157 | } 158 | -------------------------------------------------------------------------------- /example/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:offline_sync_kit/offline_sync_kit.dart'; 3 | import 'models/todo.dart'; 4 | import 'models/encryption_manager.dart'; 5 | import 'helpers/storage_helper.dart'; 6 | import 'screens/home_screen.dart'; 7 | 8 | void main() async { 9 | WidgetsFlutterBinding.ensureInitialized(); 10 | 11 | // Initialize encryption manager 12 | await EncryptionManager().initialize(); 13 | 14 | // Initialize sync manager 15 | await initSyncManager(); 16 | 17 | runApp(const MyApp()); 18 | } 19 | 20 | Future initSyncManager() async { 21 | // Put your API address here in a real application 22 | const baseUrl = 'https://jsonplaceholder.typicode.com'; 23 | 24 | // Create platform-independent storage service 25 | final storageService = 26 | await StorageHelper.createPlatformAwareStorageService(); 27 | 28 | // Register model factory 29 | (storageService as StorageServiceImpl).registerModelDeserializer( 30 | 'todo', 31 | (json) => Todo.fromJson(json), 32 | ); 33 | 34 | // Enhanced security and performance options with SyncOptions 35 | final syncOptions = SyncOptions( 36 | syncInterval: const Duration(minutes: 15), 37 | useDeltaSync: true, // Enable delta synchronization 38 | conflictStrategy: ConflictResolutionStrategy.lastUpdateWins, 39 | batchSize: 20, // Higher value for batch operations 40 | autoSync: true, 41 | bidirectionalSync: true, 42 | ); 43 | 44 | // Get encryption status from the manager 45 | final encryptionManager = EncryptionManager(); 46 | final isEncryptionEnabled = encryptionManager.isEncryptionEnabled; 47 | 48 | /* 49 | // --- WebSocket Configuration Example --- 50 | // This code is disabled by default. Uncomment to use WebSocket functionality. 51 | 52 | // Create a custom WebSocket configuration 53 | final webSocketConfig = WebSocketConfig( 54 | serverUrl: wsUrl, 55 | pingInterval: 45, // Custom ping interval (45 seconds) 56 | reconnectDelay: 3000, // Custom reconnect delay (3 seconds) 57 | maxReconnectAttempts: 5, // Custom max reconnect attempts 58 | 59 | // Custom messages 60 | connectionEstablishedMessage: 'Connected to server successfully', 61 | connectionClosedMessage: 'Disconnected from server', 62 | connectionFailedMessage: 'Failed to connect to server', 63 | reconnectingMessage: 'Attempting to reconnect', 64 | requestTimeoutMessage: 'Request to server timed out', 65 | 66 | // Custom ping message format 67 | pingMessageFormat: {'type': 'heartbeat', 'status': 'alive'}, 68 | 69 | // Custom subscription message formatter 70 | subscriptionMessageFormatter: (channel, parameters) => { 71 | 'action': 'join', 72 | 'room': channel, 73 | 'params': parameters, 74 | 'client_info': {'app_version': '1.0.0'}, 75 | }, 76 | 77 | // Custom message types 78 | subscriptionMessageType: 'room_subscription', 79 | requestMessageType: 'api_request', 80 | ); 81 | 82 | // Create a custom event mapper for WebSocket events 83 | final eventMapper = SyncEventMapper( 84 | // Custom WebSocket event name to SyncEventType mappings 85 | eventNameToTypeMap: { 86 | 'item_updated': SyncEventType.modelUpdated, 87 | 'item_created': SyncEventType.modelAdded, 88 | 'item_removed': SyncEventType.modelDeleted, 89 | 'sync_complete': SyncEventType.syncCompleted, 90 | 'sync_begin': SyncEventType.syncStarted, 91 | 'sync_failure': SyncEventType.syncError, 92 | 'connection_opened': SyncEventType.connectionEstablished, 93 | 'connection_closed': SyncEventType.connectionClosed, 94 | 'connection_error': SyncEventType.connectionFailed, 95 | 'connection_retry': SyncEventType.reconnecting, 96 | 'data_conflict': SyncEventType.conflictDetected, 97 | 'conflict_resolved': SyncEventType.conflictResolved, 98 | }, 99 | // Custom SyncEventType to WebSocket event name mappings 100 | typeToEventNameMap: { 101 | SyncEventType.modelUpdated: 'item_updated', 102 | SyncEventType.modelAdded: 'item_created', 103 | SyncEventType.modelDeleted: 'item_removed', 104 | SyncEventType.syncCompleted: 'sync_complete', 105 | SyncEventType.syncStarted: 'sync_begin', 106 | SyncEventType.syncError: 'sync_failure', 107 | SyncEventType.connectionEstablished: 'connection_opened', 108 | SyncEventType.connectionClosed: 'connection_closed', 109 | SyncEventType.connectionFailed: 'connection_error', 110 | SyncEventType.reconnecting: 'connection_retry', 111 | SyncEventType.conflictDetected: 'data_conflict', 112 | SyncEventType.conflictResolved: 'conflict_resolved', 113 | }, 114 | ); 115 | 116 | // Create WebSocket network client with custom config and event mapper 117 | final webSocketClient = WebSocketNetworkClient.withConfig( 118 | config: webSocketConfig, 119 | eventMapper: eventMapper, 120 | requestTimeout: 20000, // 20 seconds timeout 121 | ); 122 | 123 | // Listen to WebSocket events with custom event mapper 124 | webSocketClient.eventStream.listen((event) { 125 | // Get the custom event name from our mapper 126 | final customEventName = eventMapper.mapTypeToEventName(event.type); 127 | debugPrint('WebSocket event: $customEventName - ${event.message}'); 128 | 129 | // Handle different event types 130 | switch (event.type) { 131 | case SyncEventType.modelUpdated: 132 | debugPrint('Model updated: ${event.data}'); 133 | break; 134 | case SyncEventType.connectionEstablished: 135 | debugPrint('Connected to WebSocket server'); 136 | break; 137 | case SyncEventType.connectionClosed: 138 | debugPrint('Disconnected from WebSocket server'); 139 | break; 140 | default: 141 | break; 142 | } 143 | }); 144 | 145 | // Connect to WebSocket server (would be managed by OfflineSyncManager in a real app) 146 | webSocketClient.connect().then((_) { 147 | // Subscribe to channels after connection 148 | webSocketClient.subscribe('todos'); 149 | webSocketClient.subscribe('users'); 150 | }).catchError((error) { 151 | debugPrint('Failed to connect to WebSocket server: $error'); 152 | }); 153 | 154 | // You can use the WebSocket client here if your sync manager supports it 155 | // networkClient: webSocketClient, 156 | */ 157 | 158 | // Initialize sync manager with optional encryption 159 | await OfflineSyncManager.initialize( 160 | baseUrl: baseUrl, 161 | storageService: storageService, 162 | syncOptions: syncOptions, 163 | enableEncryption: isEncryptionEnabled, // Use value from EncryptionManager 164 | encryptionKey: isEncryptionEnabled 165 | ? 'secure-key-here' 166 | : null, // Only provide key if enabled 167 | ); 168 | 169 | // Register Todo model with OfflineSyncManager 170 | OfflineSyncManager.instance.registerModelFactory( 171 | 'todo', 172 | (json) => Todo.fromJson(json), 173 | ); 174 | } 175 | 176 | class MyApp extends StatelessWidget { 177 | const MyApp({super.key}); 178 | 179 | @override 180 | Widget build(BuildContext context) { 181 | return MaterialApp( 182 | title: 'Offline Sync Demo', 183 | theme: ThemeData( 184 | colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), 185 | useMaterial3: true, 186 | ), 187 | home: const HomeScreen(), 188 | ); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.5.3 2 | 3 | - Added support for Appwrite integration with custom request configurations 4 | - Enhanced `getModelsWithQuery` with additional parameters: 5 | - `forceLocalSyncFromRemote`: Force refreshing data from remote API 6 | - `policy`: Control fetching strategy (local/remote preference) 7 | - `restConfig`: Customize request with specific API requirements 8 | - Improved URL handling in repository methods to support services with non-standard endpoints 9 | - Added example documentation for complex API integrations 10 | - Fixed issue with endpoint configuration when using custom APIs 11 | 12 | ## 1.5.2 13 | - Fixed the issue where windows support was not visible on Pub.dev 14 | - Fixed wrong version number in readme. 15 | 16 | ## 1.5.1 17 | 18 | - Enhanced Query API with improved type safety and fluent interface 19 | - Added method chaining with addWhere(), addOrderBy(), page(), limitTo(), offsetBy() 20 | - Added SortDirection enum for sorting control 21 | - Exported Query and WhereCondition classes in main package file 22 | - Implemented SQL and in-memory filtering with getItemsWithQuery() and getModelsWithQuery() 23 | - Added comprehensive Query API example documentation for various use cases 24 | - Fixed compatibility issues with older query methods 25 | - Translated all Turkish comments to English for better internationalization 26 | - Fixed deprecation warnings in SyncEngine with Query API integration 27 | 28 | ## 1.5.0 29 | 30 | - Enhanced Synchronization Strategies 31 | - Added three new customizable sync strategies: 32 | - `DeleteStrategy`: Control whether deletions are optimistic (local-first) or pessimistic (remote-first) 33 | - `FetchStrategy`: Configure how data is retrieved (background sync, remote-first, local with fallback, or local-only) 34 | - `SaveStrategy`: Manage save operations with optimistic or pessimistic approaches 35 | - Implemented proper handling of marked-for-deletion items in offline mode 36 | - Added generic result type support for improved type safety 37 | - Enhanced query capabilities with optional parameters 38 | - Enhanced REST API Integration 39 | - Dynamic URL parameter replacement with `urlParameters` support 40 | - Request timeout customization via `timeoutMillis` 41 | - Automatic retry handling with `retryCount` 42 | - Custom response transformers with `responseTransformer` 43 | - Added `RestRequest` and `RestRequests` classes to customize API requests and responses 44 | - Support for custom request body formatting (wrapping data in top-level keys) 45 | - Support for adding supplemental data to request bodies 46 | - Support for extracting data from specific response fields 47 | - Improved type safety with `RestMethod` enum instead of string literals 48 | - Cross-platform support with sqflite_common_ffi 49 | - Added Windows and Linux platform support via sqflite_common_ffi 50 | - Created storage_helper for platform-specific SQLite initialization 51 | - Ensured consistent behavior across all platforms (Android, iOS, macOS, Windows, Linux) 52 | - Internationalization improvements 53 | - English translation for all Turkish comments 54 | - Converted all comments to English for better international developer experience 55 | - Consistent code documentation across the codebase 56 | - Updated example app demonstrating advanced request customization 57 | 58 | - Fixed WebSocketNetworkClient implementation to correctly implement NetworkClient interface 59 | - Fixed RestRequest handling in SyncRepositoryImpl 60 | - Fixed handling of API responses with nested data structures 61 | - Fixed issue with network error handling in example app 62 | - Improved error messages and debugging information 63 | - Enhanced example app with better offline capabilities 64 | 65 | - Added cross-platform support (Windows/Linux) via sqflite_common_ffi 66 | - Added mock API support for testing without real server 67 | - Improved internationalization with English translations 68 | - Fixed various bugs including API endpoint handling 69 | - Added model-level sync strategy configuration 70 | - Improved error handling 71 | 72 | ## 1.4.0 73 | 74 | * WebSocket Support and Real-Time Synchronization: 75 | * Added **WebSocketConnectionManager** - Manages WebSocket connection lifecycle, reconnection attempts, and status notifications 76 | * Added **WebSocketNetworkClient** - Provides WebSocket-based alternative alongside Http-based network client 77 | * Added **WebSocketConfig** - Comprehensive configuration options for WebSocket behavior 78 | * Added **SyncEventMapper** class - Maps WebSocket event names and SyncEventType enumeration 79 | * Pub/sub messaging system for subscriptions and channel listening 80 | 81 | * Customization Enhancements: 82 | * Support for multiple message formatters for customizable message format 83 | * Advanced hooks for connection lifecycle management and status monitoring 84 | * Extensible behavior for WebSocket connection handling 85 | * Customizable ping/pong messages 86 | 87 | * Event System Improvements: 88 | * Extended SyncEventType for tracking and listening to synchronization events 89 | * Client-side event filtering and transformation capabilities 90 | * Enhanced support for event listeners and stream creation 91 | 92 | ## 1.3.0 93 | 94 | * Custom repository support: 95 | * Added customRepository parameter to OfflineSyncManager.initialize() 96 | * Repository can now be accessed via new getter in SyncEngine 97 | * Improved syncItemDelta to better support custom implementations 98 | * Model factory handling: 99 | * fetchItems and pullFromServer now correctly use registered model factories 100 | * Fixed issue where modelFactories was null in custom repositories 101 | * modelFactories are now passed into fetchItems from pullFromServer 102 | * Delta sync improvements: 103 | * SyncModel.toJsonDelta() made more reliable and consistent 104 | * Error handling improvements: 105 | * Better handling of invalid or unexpected API responses 106 | * Safer defaults applied when responses are incomplete 107 | 108 | ## 1.2.0 109 | 110 | * Added github address to pubspec.yaml file. 111 | * Readme file updated. 112 | 113 | ## 1.1.0 114 | 115 | * Advanced conflict resolution strategies: 116 | * Server-wins, client-wins, last-update-wins policies 117 | * Custom conflict resolution handler support 118 | * Conflict detection and reporting 119 | * Delta synchronization: 120 | * Optimized syncing of changed fields only 121 | * Automatic tracking of field-level changes 122 | * Reduced network bandwidth usage 123 | * Optional data encryption: 124 | * Secure storage of sensitive information 125 | * Configurable encryption keys 126 | * Transparent encryption/decryption process 127 | * Performance optimizations: 128 | * Batched synchronization support 129 | * Prioritized sync queue management 130 | * Enhanced offline processing 131 | * Extended configuration options: 132 | * Flexible synchronization intervals 133 | * Custom batch size settings 134 | * Bidirectional sync controls 135 | 136 | ## 1.0.0 137 | 138 | * Initial official release 139 | * Core features: 140 | * Flexible data model integration based on SyncModel 141 | * Offline data storage with local database 142 | * Automatic data synchronization 143 | * Internet connectivity monitoring 144 | * Synchronization status tracking 145 | * Exponential backoff retry mechanism 146 | * Bidirectional synchronization support 147 | * Data conflict management 148 | * Customizable API integration 149 | * Example application demonstrating usage 150 | * SQLite-based StorageServiceImpl implementation 151 | * HTTP-based DefaultNetworkClient implementation 152 | * Comprehensive documentation -------------------------------------------------------------------------------- /lib/src/models/websocket_config.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | /// Configuration options for WebSocket connections. 4 | /// 5 | /// This class allows customizing various aspects of WebSocket connections 6 | /// including connection behavior, messages, and event handling. 7 | class WebSocketConfig extends Equatable { 8 | /// The server URL for WebSocket connection 9 | final String serverUrl; 10 | 11 | /// Ping interval in seconds 12 | final int pingInterval; 13 | 14 | /// Reconnection delay in milliseconds 15 | final int reconnectDelay; 16 | 17 | /// Maximum number of reconnection attempts 18 | final int maxReconnectAttempts; 19 | 20 | /// Custom message for connection established event 21 | final String connectionEstablishedMessage; 22 | 23 | /// Custom message for connection closed event 24 | final String connectionClosedMessage; 25 | 26 | /// Custom message for connection failed event (without error details) 27 | final String connectionFailedMessage; 28 | 29 | /// Custom message for reconnection attempt event (without attempt details) 30 | final String reconnectingMessage; 31 | 32 | /// Custom message for request timeout error 33 | final String requestTimeoutMessage; 34 | 35 | /// Custom message for error when sending message 36 | final String errorSendingMessage; 37 | 38 | /// Custom message for error when sending request 39 | final String errorSendingRequestMessage; 40 | 41 | /// Custom message for client closed 42 | final String clientClosedMessage; 43 | 44 | /// Custom message for WebSocket connection closed error 45 | final String connectionClosedErrorMessage; 46 | 47 | /// Custom format for ping message 48 | final Map pingMessageFormat; 49 | 50 | /// Custom format for subscription message 51 | final Map Function( 52 | String channel, 53 | Map? parameters, 54 | ) 55 | subscriptionMessageFormatter; 56 | 57 | /// Custom format for unsubscription message 58 | final Map Function(String channel) 59 | unsubscriptionMessageFormatter; 60 | 61 | /// Custom message type for subscription messages 62 | final String subscriptionMessageType; 63 | 64 | /// Custom message type for request messages 65 | final String requestMessageType; 66 | 67 | /// Creates a WebSocketConfig with customizable options. 68 | /// 69 | /// All parameters can be customized to personalize the WebSocket connection 70 | /// behavior and messaging. 71 | const WebSocketConfig({ 72 | required this.serverUrl, 73 | this.pingInterval = 30, 74 | this.reconnectDelay = 5000, 75 | this.maxReconnectAttempts = 10, 76 | this.connectionEstablishedMessage = 'WebSocket connection established', 77 | this.connectionClosedMessage = 'WebSocket connection closed', 78 | this.connectionFailedMessage = 'WebSocket connection failed', 79 | this.reconnectingMessage = 'Reconnecting', 80 | this.requestTimeoutMessage = 'Request timed out', 81 | this.errorSendingMessage = 'Error sending message', 82 | this.errorSendingRequestMessage = 'Error sending request', 83 | this.clientClosedMessage = 'Client closed', 84 | this.connectionClosedErrorMessage = 'WebSocket connection closed', 85 | this.pingMessageFormat = const {'type': 'ping'}, 86 | this.subscriptionMessageType = 'subscription', 87 | this.requestMessageType = 'request', 88 | Map Function( 89 | String channel, 90 | Map? parameters, 91 | )? 92 | subscriptionMessageFormatter, 93 | Map Function(String channel)? 94 | unsubscriptionMessageFormatter, 95 | }) : subscriptionMessageFormatter = 96 | subscriptionMessageFormatter ?? _defaultSubscriptionFormatter, 97 | unsubscriptionMessageFormatter = 98 | unsubscriptionMessageFormatter ?? _defaultUnsubscriptionFormatter; 99 | 100 | /// Creates a copy of this config with the specified fields replaced. 101 | WebSocketConfig copyWith({ 102 | String? serverUrl, 103 | int? pingInterval, 104 | int? reconnectDelay, 105 | int? maxReconnectAttempts, 106 | String? connectionEstablishedMessage, 107 | String? connectionClosedMessage, 108 | String? connectionFailedMessage, 109 | String? reconnectingMessage, 110 | String? requestTimeoutMessage, 111 | String? errorSendingMessage, 112 | String? errorSendingRequestMessage, 113 | String? clientClosedMessage, 114 | String? connectionClosedErrorMessage, 115 | Map? pingMessageFormat, 116 | String? subscriptionMessageType, 117 | String? requestMessageType, 118 | Map Function( 119 | String channel, 120 | Map? parameters, 121 | )? 122 | subscriptionMessageFormatter, 123 | Map Function(String channel)? 124 | unsubscriptionMessageFormatter, 125 | }) { 126 | return WebSocketConfig( 127 | serverUrl: serverUrl ?? this.serverUrl, 128 | pingInterval: pingInterval ?? this.pingInterval, 129 | reconnectDelay: reconnectDelay ?? this.reconnectDelay, 130 | maxReconnectAttempts: maxReconnectAttempts ?? this.maxReconnectAttempts, 131 | connectionEstablishedMessage: 132 | connectionEstablishedMessage ?? this.connectionEstablishedMessage, 133 | connectionClosedMessage: 134 | connectionClosedMessage ?? this.connectionClosedMessage, 135 | connectionFailedMessage: 136 | connectionFailedMessage ?? this.connectionFailedMessage, 137 | reconnectingMessage: reconnectingMessage ?? this.reconnectingMessage, 138 | requestTimeoutMessage: 139 | requestTimeoutMessage ?? this.requestTimeoutMessage, 140 | errorSendingMessage: errorSendingMessage ?? this.errorSendingMessage, 141 | errorSendingRequestMessage: 142 | errorSendingRequestMessage ?? this.errorSendingRequestMessage, 143 | clientClosedMessage: clientClosedMessage ?? this.clientClosedMessage, 144 | connectionClosedErrorMessage: 145 | connectionClosedErrorMessage ?? this.connectionClosedErrorMessage, 146 | pingMessageFormat: pingMessageFormat ?? this.pingMessageFormat, 147 | subscriptionMessageType: 148 | subscriptionMessageType ?? this.subscriptionMessageType, 149 | requestMessageType: requestMessageType ?? this.requestMessageType, 150 | subscriptionMessageFormatter: 151 | subscriptionMessageFormatter ?? this.subscriptionMessageFormatter, 152 | unsubscriptionMessageFormatter: 153 | unsubscriptionMessageFormatter ?? this.unsubscriptionMessageFormatter, 154 | ); 155 | } 156 | 157 | /// Default formatter for subscription messages 158 | static Map _defaultSubscriptionFormatter( 159 | String channel, 160 | Map? parameters, 161 | ) { 162 | return { 163 | 'action': 'subscribe', 164 | 'channel': channel, 165 | if (parameters != null) 'parameters': parameters, 166 | }; 167 | } 168 | 169 | /// Default formatter for unsubscription messages 170 | static Map _defaultUnsubscriptionFormatter(String channel) { 171 | return {'action': 'unsubscribe', 'channel': channel}; 172 | } 173 | 174 | @override 175 | List get props => [ 176 | serverUrl, 177 | pingInterval, 178 | reconnectDelay, 179 | maxReconnectAttempts, 180 | connectionEstablishedMessage, 181 | connectionClosedMessage, 182 | connectionFailedMessage, 183 | reconnectingMessage, 184 | requestTimeoutMessage, 185 | errorSendingMessage, 186 | errorSendingRequestMessage, 187 | clientClosedMessage, 188 | connectionClosedErrorMessage, 189 | pingMessageFormat, 190 | subscriptionMessageType, 191 | requestMessageType, 192 | // Function references are excluded from equality comparison 193 | ]; 194 | } 195 | -------------------------------------------------------------------------------- /lib/src/network/rest_request.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import '../enums/rest_method.dart'; 3 | 4 | /// Represents a customizable REST API request configuration 5 | /// 6 | /// This class allows for fine-grained control over how HTTP requests 7 | /// are formatted and processed to accommodate different API structures. 8 | class RestRequest extends Equatable { 9 | /// The HTTP method for this request (GET, POST, PUT, DELETE, PATCH) 10 | final RestMethod method; 11 | 12 | /// The URL or endpoint path for this request 13 | final String url; 14 | 15 | /// Additional HTTP headers to include with this request 16 | final Map? headers; 17 | 18 | /// Optional top-level key to wrap the request body in 19 | /// 20 | /// For example, if topLevelKey is 'data', the request body will be: 21 | /// { "data": { ... original body ... } } 22 | final String? topLevelKey; 23 | 24 | /// Additional top-level data to merge alongside the main request body 25 | /// 26 | /// For example, if supplementalTopLevelData is {'documentId': '123'}, 27 | /// the request body will include this field at the top level: 28 | /// { "documentId": "123", ... other data ... } 29 | final Map? supplementalTopLevelData; 30 | 31 | /// Optional key to extract data from the response 32 | /// 33 | /// For example, if responseDataKey is 'documents', the code will 34 | /// extract response.data['documents'] as the actual data. 35 | final String? responseDataKey; 36 | 37 | /// URL parameters to replace in the URL string 38 | /// 39 | /// For example, if the URL is '/users/{userId}/posts/{postId}' and 40 | /// urlParameters is {'userId': '123', 'postId': '456'}, the resulting 41 | /// URL will be '/users/123/posts/456' 42 | final Map? urlParameters; 43 | 44 | /// Request timeout in milliseconds 45 | /// 46 | /// Overrides the default timeout for this specific request 47 | final int? timeoutMillis; 48 | 49 | /// Number of retry attempts for this request in case of failure 50 | /// 51 | /// If set, the request will be retried this many times before failing 52 | final int? retryCount; 53 | 54 | /// Custom response transformer function 55 | /// 56 | /// If provided, this function will be called with the raw response data 57 | /// and should return the transformed data 58 | final dynamic Function(dynamic data)? responseTransformer; 59 | 60 | /// Creates a new RestRequest instance 61 | /// 62 | /// At minimum, [method] and [url] must be provided. 63 | const RestRequest({ 64 | required this.method, 65 | required this.url, 66 | this.headers, 67 | this.topLevelKey, 68 | this.supplementalTopLevelData, 69 | this.responseDataKey, 70 | this.urlParameters, 71 | this.timeoutMillis, 72 | this.retryCount, 73 | this.responseTransformer, 74 | }); 75 | 76 | /// Creates a copy of this request with the specified fields replaced 77 | RestRequest copyWith({ 78 | RestMethod? method, 79 | String? url, 80 | Map? headers, 81 | String? topLevelKey, 82 | Map? supplementalTopLevelData, 83 | String? responseDataKey, 84 | Map? urlParameters, 85 | int? timeoutMillis, 86 | int? retryCount, 87 | dynamic Function(dynamic)? responseTransformer, 88 | }) { 89 | return RestRequest( 90 | method: method ?? this.method, 91 | url: url ?? this.url, 92 | headers: headers ?? this.headers, 93 | topLevelKey: topLevelKey ?? this.topLevelKey, 94 | supplementalTopLevelData: 95 | supplementalTopLevelData ?? this.supplementalTopLevelData, 96 | responseDataKey: responseDataKey ?? this.responseDataKey, 97 | urlParameters: urlParameters ?? this.urlParameters, 98 | timeoutMillis: timeoutMillis ?? this.timeoutMillis, 99 | retryCount: retryCount ?? this.retryCount, 100 | responseTransformer: responseTransformer ?? this.responseTransformer, 101 | ); 102 | } 103 | 104 | /// Apply URL parameters to replace placeholders in the URL string 105 | /// 106 | /// For example, if the URL is '/users/{userId}/posts/{postId}' and 107 | /// parameters is {'userId': '123', 'postId': '456'}, the result will be 108 | /// '/users/123/posts/456' 109 | String getProcessedUrl([Map? additionalParams]) { 110 | if ((urlParameters == null || urlParameters!.isEmpty) && 111 | (additionalParams == null || additionalParams.isEmpty)) { 112 | return url; 113 | } 114 | 115 | String processedUrl = url; 116 | final Map allParams = {}; 117 | 118 | if (urlParameters != null) { 119 | allParams.addAll(urlParameters!); 120 | } 121 | 122 | if (additionalParams != null) { 123 | allParams.addAll(additionalParams); 124 | } 125 | 126 | for (final entry in allParams.entries) { 127 | processedUrl = processedUrl.replaceAll( 128 | '{${entry.key}}', 129 | Uri.encodeComponent(entry.value), 130 | ); 131 | } 132 | 133 | return processedUrl; 134 | } 135 | 136 | /// Creates a RestRequest instance from a JSON map 137 | factory RestRequest.fromJson(Map json) { 138 | return RestRequest( 139 | method: _methodFromString(json['method'] as String), 140 | url: json['url'] as String, 141 | headers: 142 | json['headers'] != null 143 | ? Map.from(json['headers'] as Map) 144 | : null, 145 | topLevelKey: json['topLevelKey'] as String?, 146 | supplementalTopLevelData: 147 | json['supplementalTopLevelData'] != null 148 | ? Map.from( 149 | json['supplementalTopLevelData'] as Map, 150 | ) 151 | : null, 152 | responseDataKey: json['responseDataKey'] as String?, 153 | urlParameters: 154 | json['urlParameters'] != null 155 | ? Map.from(json['urlParameters'] as Map) 156 | : null, 157 | timeoutMillis: json['timeoutMillis'] as int?, 158 | retryCount: json['retryCount'] as int?, 159 | ); 160 | } 161 | 162 | /// Helper method to convert a string to RestMethod enum 163 | static RestMethod _methodFromString(String methodString) { 164 | final lowerMethod = methodString.toLowerCase(); 165 | switch (lowerMethod) { 166 | case 'get': 167 | return RestMethod.get; 168 | case 'post': 169 | return RestMethod.post; 170 | case 'put': 171 | return RestMethod.put; 172 | case 'delete': 173 | return RestMethod.delete; 174 | case 'patch': 175 | return RestMethod.patch; 176 | default: 177 | throw ArgumentError('Invalid method: $methodString'); 178 | } 179 | } 180 | 181 | /// Method name as a string 182 | String get methodString => method.toString().split('.').last.toUpperCase(); 183 | 184 | /// Converts this RestRequest to a JSON map 185 | Map toJson() { 186 | return { 187 | 'method': methodString, 188 | 'url': url, 189 | if (headers != null) 'headers': headers, 190 | if (topLevelKey != null) 'topLevelKey': topLevelKey, 191 | if (supplementalTopLevelData != null) 192 | 'supplementalTopLevelData': supplementalTopLevelData, 193 | if (responseDataKey != null) 'responseDataKey': responseDataKey, 194 | if (urlParameters != null) 'urlParameters': urlParameters, 195 | if (timeoutMillis != null) 'timeoutMillis': timeoutMillis, 196 | if (retryCount != null) 'retryCount': retryCount, 197 | }; 198 | } 199 | 200 | /// Process response data using the custom transformer if available 201 | dynamic transformResponse(dynamic data) { 202 | if (responseTransformer != null) { 203 | return responseTransformer!(data); 204 | } 205 | return data; 206 | } 207 | 208 | @override 209 | List get props => [ 210 | method, 211 | url, 212 | headers, 213 | topLevelKey, 214 | supplementalTopLevelData, 215 | responseDataKey, 216 | urlParameters, 217 | timeoutMillis, 218 | retryCount, 219 | ]; 220 | } 221 | -------------------------------------------------------------------------------- /lib/src/models/sync_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'package:uuid/uuid.dart'; 3 | import '../network/rest_requests.dart'; 4 | import '../enums/sync_strategy.dart'; 5 | 6 | /// Base abstract class for all models that need offline synchronization. 7 | /// 8 | /// All models that should be synchronized with a remote API should extend this class. 9 | /// It includes all necessary properties for tracking sync state (sync status, timestamps, etc.). 10 | /// Supports delta synchronization by tracking which fields have been changed since last sync. 11 | /// 12 | /// Example: 13 | /// ```dart 14 | /// class Todo extends SyncModel { 15 | /// final String title; 16 | /// final bool isCompleted; 17 | /// 18 | /// Todo({ 19 | /// super.id, 20 | /// super.createdAt, 21 | /// super.updatedAt, 22 | /// super.isSynced, 23 | /// super.changedFields, 24 | /// super.fetchStrategy, 25 | /// super.saveStrategy, 26 | /// super.deleteStrategy, 27 | /// required this.title, 28 | /// this.isCompleted = false, 29 | /// }); 30 | /// 31 | /// @override 32 | /// String get endpoint => 'todos'; 33 | /// 34 | /// @override 35 | /// String get modelType => 'todo'; 36 | /// 37 | /// @override 38 | /// Map toJson() { 39 | /// return { 40 | /// 'id': id, 41 | /// 'title': title, 42 | /// 'isCompleted': isCompleted, 43 | /// }; 44 | /// } 45 | /// 46 | /// @override 47 | /// Map toJsonDelta() { 48 | /// final Map delta = {'id': id}; 49 | /// if (changedFields.contains('title')) delta['title'] = title; 50 | /// if (changedFields.contains('isCompleted')) delta['isCompleted'] = isCompleted; 51 | /// return delta; 52 | /// } 53 | /// 54 | /// @override 55 | /// Todo copyWith({...}) { 56 | /// // Implementation 57 | /// } 58 | /// 59 | /// Todo updateTitle(String newTitle) { 60 | /// return copyWith( 61 | /// title: newTitle, 62 | /// changedFields: {...changedFields, 'title'}, 63 | /// ); 64 | /// } 65 | /// } 66 | /// ``` 67 | abstract class SyncModel extends Equatable { 68 | /// Unique identifier for the model instance (UUID v4) 69 | final String id; 70 | 71 | /// Timestamp when the model was first created 72 | final DateTime createdAt; 73 | 74 | /// Timestamp of the most recent update 75 | final DateTime updatedAt; 76 | 77 | /// Flag indicating whether this model has been synced with the server 78 | final bool isSynced; 79 | 80 | /// Error message for the last failed sync attempt, empty if none 81 | final String syncError; 82 | 83 | /// Number of failed sync attempts for this model 84 | final int syncAttempts; 85 | 86 | /// Set of field names that have been changed since last sync 87 | /// Used for delta synchronization to only send changed fields 88 | final Set changedFields; 89 | 90 | /// Flag indicating whether this model is marked for deletion 91 | final bool markedForDeletion; 92 | 93 | /// Optional fetch strategy that can override the global fetch strategy 94 | /// When null, the global strategy from SyncOptions will be used 95 | final FetchStrategy? fetchStrategy; 96 | 97 | /// Optional save strategy that can override the global save strategy 98 | /// When null, the global strategy from SyncOptions will be used 99 | final SaveStrategy? saveStrategy; 100 | 101 | /// Optional delete strategy that can override the global delete strategy 102 | /// When null, the global strategy from SyncOptions will be used 103 | final DeleteStrategy? deleteStrategy; 104 | 105 | /// Creates a new SyncModel instance 106 | /// 107 | /// If [id] is not provided, a new UUID v4 will be generated 108 | /// If [createdAt] or [updatedAt] are not provided, the current time is used 109 | /// A new model is not synced by default ([isSynced] = false) 110 | SyncModel({ 111 | String? id, 112 | DateTime? createdAt, 113 | DateTime? updatedAt, 114 | this.isSynced = false, 115 | this.syncError = '', 116 | this.syncAttempts = 0, 117 | Set? changedFields, 118 | this.markedForDeletion = false, 119 | this.fetchStrategy, 120 | this.saveStrategy, 121 | this.deleteStrategy, 122 | }) : id = id ?? const Uuid().v4(), 123 | createdAt = createdAt ?? DateTime.now(), 124 | updatedAt = updatedAt ?? DateTime.now(), 125 | changedFields = changedFields ?? {}; 126 | 127 | /// The API endpoint for this model type (without leading slash) 128 | /// 129 | /// For example: 'todos', 'users', 'products' 130 | String get endpoint; 131 | 132 | /// A unique string identifier for this model type 133 | /// 134 | /// This is used to identify different model types in storage 135 | /// For example: 'todo', 'user', 'product' 136 | String get modelType; 137 | 138 | /// Optional custom REST request configurations for this model 139 | /// 140 | /// Override this to provide custom request shaping for different APIs 141 | /// By default, returns null to use standard request formatting 142 | RestRequests? get restRequests => null; 143 | 144 | /// Converts the model to a JSON map for API requests and storage 145 | Map toJson(); 146 | 147 | /// Converts only the changed fields to a JSON map for delta synchronization 148 | /// 149 | /// This is used for efficient data transfer when only specific fields have changed. 150 | /// The implementation includes the model ID and only the fields that are tracked 151 | /// in the changedFields set. 152 | /// 153 | /// Derived classes should override this method for proper delta synchronization. 154 | Map toJsonDelta() { 155 | // Always include id for record identification 156 | final Map delta = {'id': id}; 157 | 158 | // If no specific fields are tracked, return just the ID 159 | if (changedFields.isEmpty) { 160 | return delta; 161 | } 162 | 163 | // Get the full JSON representation 164 | final fullJson = toJson(); 165 | 166 | // Add only changed fields to the delta 167 | for (final field in changedFields) { 168 | if (fullJson.containsKey(field)) { 169 | delta[field] = fullJson[field]; 170 | } 171 | } 172 | 173 | return delta; 174 | } 175 | 176 | /// Creates a copy of this model with optionally modified properties 177 | /// 178 | /// Allows updating a model immutably. Make sure to implement 179 | /// this in derived classes to handle custom fields. 180 | SyncModel copyWith({ 181 | String? id, 182 | DateTime? createdAt, 183 | DateTime? updatedAt, 184 | bool? isSynced, 185 | String? syncError, 186 | int? syncAttempts, 187 | Set? changedFields, 188 | bool? markedForDeletion, 189 | FetchStrategy? fetchStrategy, 190 | SaveStrategy? saveStrategy, 191 | DeleteStrategy? deleteStrategy, 192 | }); 193 | 194 | /// Marks this model as successfully synchronized with the server 195 | /// 196 | /// Returns a new instance with [isSynced] = true, cleared [syncError], 197 | /// and empty [changedFields] since all changes are now synced 198 | SyncModel markAsSynced() { 199 | return copyWith( 200 | isSynced: true, 201 | syncError: '', 202 | updatedAt: DateTime.now(), 203 | changedFields: {}, 204 | ); 205 | } 206 | 207 | /// Marks this model as failed to synchronize 208 | /// 209 | /// Returns a new instance with [isSynced] = false, increased [syncAttempts], 210 | /// and the [error] message stored in [syncError] 211 | SyncModel markSyncFailed(String error) { 212 | return copyWith( 213 | isSynced: false, 214 | syncError: error, 215 | syncAttempts: syncAttempts + 1, 216 | updatedAt: DateTime.now(), 217 | ); 218 | } 219 | 220 | /// Returns true if this model has any unsynchronized changes 221 | bool get hasChanges => changedFields.isNotEmpty; 222 | 223 | /// Marks this model for deletion 224 | /// 225 | /// This is used when a model needs to be deleted offline and then 226 | /// synchronized with the server when connectivity is restored. 227 | /// 228 | /// Returns a new instance with [markedForDeletion] = true 229 | SyncModel markForDeletion() { 230 | return copyWith(markedForDeletion: true, updatedAt: DateTime.now()); 231 | } 232 | 233 | @override 234 | List get props => [ 235 | id, 236 | createdAt, 237 | updatedAt, 238 | isSynced, 239 | syncError, 240 | syncAttempts, 241 | changedFields, 242 | markedForDeletion, 243 | fetchStrategy, 244 | saveStrategy, 245 | deleteStrategy, 246 | ]; 247 | } 248 | -------------------------------------------------------------------------------- /lib/src/query/query.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | import 'where_condition.dart'; 3 | 4 | /// Sort direction for query ordering 5 | enum SortDirection { 6 | /// Ascending order (A-Z, 0-9) 7 | ascending, 8 | 9 | /// Descending order (Z-A, 9-0) 10 | descending, 11 | } 12 | 13 | /// A structured query for database operations. 14 | /// 15 | /// This class provides a way to build SQL-like queries for filtering data 16 | /// in the offline storage system. It supports where conditions, ordering, 17 | /// pagination, and more. 18 | /// 19 | /// Example: 20 | /// ```dart 21 | /// // Traditional constructor usage 22 | /// final query = Query( 23 | /// where: [WhereCondition.exact('name', 'John'), WhereCondition.greaterThan('age', 18)], 24 | /// orderBy: 'createdAt', 25 | /// descending: true, 26 | /// limit: 10, 27 | /// ); 28 | /// 29 | /// // Fluent API usage 30 | /// final fluentQuery = Query() 31 | /// .addWhere(WhereCondition.exact('name', 'John')) 32 | /// .addWhere(WhereCondition.greaterThan('age', 18)) 33 | /// .addOrderBy('createdAt', direction: SortDirection.descending) 34 | /// .page(0, pageSize: 10); 35 | /// ``` 36 | class Query extends Equatable { 37 | /// List of where conditions for filtering 38 | final List? where; 39 | 40 | /// Field to order results by 41 | final String? orderBy; 42 | 43 | /// Whether to sort in descending order 44 | final bool descending; 45 | 46 | /// Maximum number of results to return 47 | final int? limit; 48 | 49 | /// Number of results to skip 50 | final int? offset; 51 | 52 | /// Creates a new query. 53 | /// 54 | /// [where] - List of where conditions for filtering results 55 | /// [orderBy] - Field name to order results by 56 | /// [descending] - Whether to sort in descending order 57 | /// [limit] - Maximum number of results to return 58 | /// [offset] - Number of results to skip (for pagination) 59 | const Query({ 60 | this.where, 61 | this.orderBy, 62 | this.descending = false, 63 | this.limit, 64 | this.offset, 65 | }); 66 | 67 | /// Creates a new query with only the given where conditions. 68 | /// 69 | /// This is a convenience constructor for simple queries. 70 | factory Query.where(List conditions) { 71 | return Query(where: conditions); 72 | } 73 | 74 | /// Creates a new query that filters for a single exact match. 75 | /// 76 | /// This is a convenience constructor for the common case of 77 | /// finding an item by a single field value. 78 | factory Query.exact(String field, dynamic value) { 79 | return Query(where: [WhereCondition.exact(field, value)]); 80 | } 81 | 82 | /// Creates a copy of this query with the given fields replaced. 83 | Query copyWith({ 84 | List? where, 85 | String? orderBy, 86 | bool? descending, 87 | int? limit, 88 | int? offset, 89 | }) { 90 | return Query( 91 | where: where ?? this.where, 92 | orderBy: orderBy ?? this.orderBy, 93 | descending: descending ?? this.descending, 94 | limit: limit ?? this.limit, 95 | offset: offset ?? this.offset, 96 | ); 97 | } 98 | 99 | /// Adds a where condition to this query. 100 | /// 101 | /// Returns a new query with the added condition. 102 | Query addWhere(WhereCondition condition) { 103 | final newWhere = [...?where, condition]; 104 | return copyWith(where: newWhere); 105 | } 106 | 107 | /// Sets the ordering field and direction for this query. 108 | /// 109 | /// [field] - The field to order by 110 | /// [direction] - The sort direction (ascending or descending) 111 | /// 112 | /// Returns a new query with the ordering applied. 113 | Query addOrderBy( 114 | String field, { 115 | SortDirection direction = SortDirection.ascending, 116 | }) { 117 | return copyWith( 118 | orderBy: field, 119 | descending: direction == SortDirection.descending, 120 | ); 121 | } 122 | 123 | /// Sets pagination parameters for this query. 124 | /// 125 | /// [pageNumber] - The zero-based page number 126 | /// [pageSize] - The number of items per page 127 | /// 128 | /// Returns a new query with pagination applied. 129 | Query page(int pageNumber, {int pageSize = 20}) { 130 | return copyWith(limit: pageSize, offset: pageNumber * pageSize); 131 | } 132 | 133 | /// Sets a limit on the number of results returned. 134 | /// 135 | /// [count] - Maximum number of results to return 136 | /// 137 | /// Returns a new query with the limit applied. 138 | Query limitTo(int count) { 139 | return copyWith(limit: count); 140 | } 141 | 142 | /// Sets an offset for the results. 143 | /// 144 | /// [count] - Number of results to skip 145 | /// 146 | /// Returns a new query with the offset applied. 147 | Query offsetBy(int count) { 148 | return copyWith(offset: count); 149 | } 150 | 151 | /// Converts this query to a SQL WHERE clause. 152 | /// 153 | /// This is used internally by the storage implementation. 154 | /// 155 | /// Returns a tuple of (whereClause, arguments) where: 156 | /// - whereClause is the SQL WHERE clause string 157 | /// - arguments is the list of arguments for the prepared statement 158 | (String, List) toSqlWhereClause() { 159 | if (where == null || where!.isEmpty) { 160 | return ('', []); 161 | } 162 | 163 | final clauses = []; 164 | final args = []; 165 | 166 | for (final condition in where!) { 167 | final (clause, conditionArgs) = condition.toSqlClause(); 168 | clauses.add(clause); 169 | args.addAll(conditionArgs); 170 | } 171 | 172 | return ('(${clauses.join(' AND ')})', args); 173 | } 174 | 175 | /// Converts this query to a SQL ORDER BY clause. 176 | /// 177 | /// This is used internally by the storage implementation. 178 | String toSqlOrderByClause() { 179 | if (orderBy == null) { 180 | return ''; 181 | } 182 | 183 | return 'ORDER BY $orderBy ${descending ? 'DESC' : 'ASC'}'; 184 | } 185 | 186 | /// Converts this query to a SQL LIMIT/OFFSET clause. 187 | /// 188 | /// This is used internally by the storage implementation. 189 | String toSqlLimitOffsetClause() { 190 | final limitClause = limit != null ? 'LIMIT $limit' : ''; 191 | final offsetClause = offset != null ? 'OFFSET $offset' : ''; 192 | 193 | if (limitClause.isNotEmpty && offsetClause.isNotEmpty) { 194 | return '$limitClause $offsetClause'; 195 | } 196 | 197 | return limitClause + offsetClause; 198 | } 199 | 200 | /// Applies this query to filter a list of items in memory. 201 | /// 202 | /// This is used when SQL filtering is not available. 203 | /// 204 | /// [items] - The list of items to filter 205 | /// [getField] - A function that extracts a field value from an item 206 | /// 207 | /// Returns the filtered list 208 | List applyToList( 209 | List items, 210 | dynamic Function(T item, String field) getField, 211 | ) { 212 | // Start with the full list 213 | var result = List.from(items); 214 | 215 | // Apply where conditions 216 | if (where != null && where!.isNotEmpty) { 217 | result = 218 | result.where((item) { 219 | // Item must match all where conditions 220 | return where!.every((condition) { 221 | return condition.matches(item, getField); 222 | }); 223 | }).toList(); 224 | } 225 | 226 | // Apply ordering 227 | if (orderBy != null) { 228 | result.sort((a, b) { 229 | final aValue = getField(a, orderBy!); 230 | final bValue = getField(b, orderBy!); 231 | 232 | // Handle null values 233 | if (aValue == null && bValue == null) return 0; 234 | if (aValue == null) return descending ? 1 : -1; 235 | if (bValue == null) return descending ? -1 : 1; 236 | 237 | // Compare based on type 238 | int compareResult; 239 | 240 | if (aValue is Comparable && bValue is Comparable) { 241 | compareResult = Comparable.compare(aValue, bValue); 242 | } else { 243 | // Fall back to string comparison 244 | compareResult = aValue.toString().compareTo(bValue.toString()); 245 | } 246 | 247 | return descending ? -compareResult : compareResult; 248 | }); 249 | } 250 | 251 | // Apply pagination 252 | if (offset != null && offset! > 0) { 253 | if (offset! >= result.length) { 254 | return []; 255 | } 256 | result = result.sublist(offset!); 257 | } 258 | 259 | if (limit != null && limit! > 0) { 260 | if (limit! < result.length) { 261 | result = result.sublist(0, limit); 262 | } 263 | } 264 | 265 | return result; 266 | } 267 | 268 | @override 269 | List get props => [where, orderBy, descending, limit, offset]; 270 | 271 | @override 272 | String toString() { 273 | final parts = []; 274 | 275 | if (where != null && where!.isNotEmpty) { 276 | parts.add('where: [${where!.join(', ')}]'); 277 | } 278 | 279 | if (orderBy != null) { 280 | parts.add('orderBy: $orderBy${descending ? ' DESC' : ''}'); 281 | } 282 | 283 | if (limit != null) { 284 | parts.add('limit: $limit'); 285 | } 286 | 287 | if (offset != null) { 288 | parts.add('offset: $offset'); 289 | } 290 | 291 | return 'Query(${parts.join(', ')})'; 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /example/lib/models/todo.dart: -------------------------------------------------------------------------------- 1 | import 'package:offline_sync_kit/offline_sync_kit.dart'; 2 | 3 | class Todo extends SyncModel { 4 | final String title; 5 | final String description; 6 | final bool isCompleted; 7 | final int priority; 8 | 9 | Todo({ 10 | super.id, 11 | super.createdAt, 12 | super.updatedAt, 13 | super.isSynced, 14 | super.syncError, 15 | super.syncAttempts, 16 | super.changedFields, 17 | super.markedForDeletion, 18 | super.fetchStrategy, 19 | super.saveStrategy, 20 | super.deleteStrategy, 21 | required this.title, 22 | this.description = '', 23 | this.isCompleted = false, 24 | this.priority = 0, 25 | }); 26 | 27 | @override 28 | String get endpoint => 'todos'; 29 | 30 | @override 31 | String get modelType => 'todo'; 32 | 33 | @override 34 | Map toJson() { 35 | return { 36 | 'id': id, 37 | 'title': title, 38 | 'description': description, 39 | 'isCompleted': isCompleted, 40 | 'priority': priority, 41 | 'createdAt': createdAt.millisecondsSinceEpoch, 42 | 'updatedAt': updatedAt.millisecondsSinceEpoch, 43 | }; 44 | } 45 | 46 | @override 47 | Map toJsonDelta() { 48 | final Map delta = {'id': id}; 49 | 50 | if (changedFields.contains('title')) delta['title'] = title; 51 | if (changedFields.contains('description')) { 52 | delta['description'] = description; 53 | } 54 | if (changedFields.contains('isCompleted')) { 55 | delta['isCompleted'] = isCompleted; 56 | } 57 | if (changedFields.contains('priority')) delta['priority'] = priority; 58 | 59 | return delta; 60 | } 61 | 62 | @override 63 | Todo copyWith({ 64 | String? id, 65 | DateTime? createdAt, 66 | DateTime? updatedAt, 67 | bool? isSynced, 68 | String? syncError, 69 | int? syncAttempts, 70 | Set? changedFields, 71 | bool? markedForDeletion, 72 | FetchStrategy? fetchStrategy, 73 | SaveStrategy? saveStrategy, 74 | DeleteStrategy? deleteStrategy, 75 | String? title, 76 | String? description, 77 | bool? isCompleted, 78 | int? priority, 79 | }) { 80 | return Todo( 81 | id: id ?? this.id, 82 | createdAt: createdAt ?? this.createdAt, 83 | updatedAt: updatedAt ?? this.updatedAt, 84 | isSynced: isSynced ?? this.isSynced, 85 | syncError: syncError ?? this.syncError, 86 | syncAttempts: syncAttempts ?? this.syncAttempts, 87 | changedFields: changedFields ?? this.changedFields, 88 | markedForDeletion: markedForDeletion ?? this.markedForDeletion, 89 | fetchStrategy: fetchStrategy ?? this.fetchStrategy, 90 | saveStrategy: saveStrategy ?? this.saveStrategy, 91 | deleteStrategy: deleteStrategy ?? this.deleteStrategy, 92 | title: title ?? this.title, 93 | description: description ?? this.description, 94 | isCompleted: isCompleted ?? this.isCompleted, 95 | priority: priority ?? this.priority, 96 | ); 97 | } 98 | 99 | factory Todo.fromJson(Map json) { 100 | return Todo( 101 | id: json['id'] as String, 102 | title: json['title'] as String, 103 | description: json['description'] as String? ?? '', 104 | isCompleted: json['isCompleted'] as bool? ?? false, 105 | priority: json['priority'] as int? ?? 0, 106 | createdAt: DateTime.fromMillisecondsSinceEpoch( 107 | json['createdAt'] as int? ?? DateTime.now().millisecondsSinceEpoch), 108 | updatedAt: DateTime.fromMillisecondsSinceEpoch( 109 | json['updatedAt'] as int? ?? DateTime.now().millisecondsSinceEpoch), 110 | isSynced: json['isSynced'] as bool? ?? false, 111 | ); 112 | } 113 | 114 | // Helper methods - change fields for delta synchronization 115 | Todo updateTitle(String newTitle) { 116 | return copyWith( 117 | title: newTitle, 118 | changedFields: {...changedFields, 'title'}, 119 | isSynced: false, 120 | ); 121 | } 122 | 123 | Todo updateDescription(String newDescription) { 124 | return copyWith( 125 | description: newDescription, 126 | changedFields: {...changedFields, 'description'}, 127 | isSynced: false, 128 | ); 129 | } 130 | 131 | Todo updateCompletionStatus(bool isCompleted) { 132 | return copyWith( 133 | isCompleted: isCompleted, 134 | changedFields: {...changedFields, 'isCompleted'}, 135 | isSynced: false, 136 | ); 137 | } 138 | 139 | Todo updatePriority(int newPriority) { 140 | return copyWith( 141 | priority: newPriority, 142 | changedFields: {...changedFields, 'priority'}, 143 | isSynced: false, 144 | ); 145 | } 146 | 147 | Todo markComplete() { 148 | return copyWith( 149 | isCompleted: true, 150 | changedFields: {...changedFields, 'isCompleted'}, 151 | updatedAt: DateTime.now(), 152 | ); 153 | } 154 | 155 | Todo markIncomplete() { 156 | return copyWith( 157 | isCompleted: false, 158 | changedFields: {...changedFields, 'isCompleted'}, 159 | updatedAt: DateTime.now(), 160 | ); 161 | } 162 | 163 | Todo withCustomSyncStrategies({ 164 | FetchStrategy? fetchStrategy, 165 | SaveStrategy? saveStrategy, 166 | DeleteStrategy? deleteStrategy, 167 | }) { 168 | return copyWith( 169 | fetchStrategy: fetchStrategy, 170 | saveStrategy: saveStrategy, 171 | deleteStrategy: deleteStrategy, 172 | ); 173 | } 174 | 175 | @override 176 | List get props => [ 177 | id, 178 | title, 179 | description, 180 | isCompleted, 181 | priority, 182 | createdAt, 183 | updatedAt, 184 | isSynced, 185 | syncError, 186 | syncAttempts, 187 | changedFields, 188 | markedForDeletion, 189 | fetchStrategy, 190 | saveStrategy, 191 | deleteStrategy, 192 | ]; 193 | } 194 | 195 | /// Query examples: 196 | /// 197 | /// 1. Simple ID-based query example: 198 | /// ```dart 199 | /// // To find a specific todo by id 200 | /// final query = Query.exact('id', '123'); 201 | /// final todo = await storageService.getModel(query); 202 | /// ``` 203 | /// 204 | /// 2. Query example with multiple conditions: 205 | /// ```dart 206 | /// // To find incomplete todos with high priority 207 | /// final query = Query( 208 | /// where: [ 209 | /// WhereCondition.exact('isCompleted', false), 210 | /// WhereCondition.greaterThan('priority', 2) 211 | /// ], 212 | /// orderBy: 'priority', 213 | /// descending: true 214 | /// ); 215 | /// final todos = await storageService.getAllModels(query); 216 | /// ``` 217 | /// 218 | /// 3. Text search example: 219 | /// ```dart 220 | /// // To find todos containing specific text in title or description 221 | /// final searchQuery = 'important'; 222 | /// final query = Query( 223 | /// where: [ 224 | /// WhereCondition.contains('title', searchQuery), 225 | /// // To apply OR operator, you need to run multiple queries and combine results 226 | /// ] 227 | /// ); 228 | /// final titleMatches = await storageService.getAllModels(query); 229 | /// 230 | /// // Searching in description 231 | /// final descriptionQuery = Query( 232 | /// where: [WhereCondition.contains('description', searchQuery)] 233 | /// ); 234 | /// final descriptionMatches = await storageService.getAllModels(descriptionQuery); 235 | /// 236 | /// // Combine results and make unique 237 | /// final allMatches = {...titleMatches, ...descriptionMatches}.toList(); 238 | /// ``` 239 | /// 240 | /// 4. Pagination example: 241 | /// ```dart 242 | /// // Pagination retrieving 10 todos at a time 243 | /// int page = 0; 244 | /// int pageSize = 10; 245 | /// 246 | /// final query = Query( 247 | /// orderBy: 'updatedAt', 248 | /// descending: true, 249 | /// limit: pageSize, 250 | /// offset: page * pageSize 251 | /// ); 252 | /// 253 | /// // To get the next page: 254 | /// page++; 255 | /// final nextPageQuery = query.copyWith(offset: page * pageSize); 256 | /// ``` 257 | /// 258 | /// 5. Complex query example: 259 | /// ```dart 260 | /// // Get high priority, incomplete todos created in the last 7 days 261 | /// // that contain "project" in the title 262 | /// final sevenDaysAgo = DateTime.now().subtract(Duration(days: 7)); 263 | /// 264 | /// final query = Query( 265 | /// where: [ 266 | /// WhereCondition.greaterThanOrEquals('createdAt', sevenDaysAgo.millisecondsSinceEpoch), 267 | /// WhereCondition.exact('isCompleted', false), 268 | /// WhereCondition.greaterThanOrEquals('priority', 3), 269 | /// WhereCondition.contains('title', 'project') 270 | /// ], 271 | /// orderBy: 'priority', 272 | /// descending: true 273 | /// ); 274 | /// 275 | /// final todos = await storageService.getAllModels(query); 276 | /// ``` 277 | /// 278 | /// 6. SQL generation example: 279 | /// ```dart 280 | /// // To generate SQL query: 281 | /// final query = Query( 282 | /// where: [ 283 | /// WhereCondition.exact('isCompleted', false), 284 | /// WhereCondition.greaterThan('priority', 2) 285 | /// ], 286 | /// orderBy: 'updatedAt', 287 | /// descending: true, 288 | /// limit: 20 289 | /// ); 290 | /// 291 | /// final (whereClause, args) = query.toSqlWhereClause(); 292 | /// final orderByClause = query.toSqlOrderByClause(); 293 | /// final limitClause = query.toSqlLimitOffsetClause(); 294 | /// 295 | /// // Generated SQL parts: 296 | /// // whereClause: "(isCompleted = ? AND priority > ?)" 297 | /// // args: [false, 2] 298 | /// // orderByClause: "ORDER BY updatedAt DESC" 299 | /// // limitClause: "LIMIT 20" 300 | /// ``` 301 | -------------------------------------------------------------------------------- /lib/src/network/websocket_network_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import '../models/sync_event.dart'; 4 | import '../models/sync_event_mapper.dart'; 5 | import '../models/websocket_config.dart'; 6 | import 'network_client.dart'; 7 | import 'rest_request.dart'; 8 | import 'websocket_connection_manager.dart'; 9 | 10 | /// Network client that operates over WebSocket protocol. 11 | /// 12 | /// This class implements the NetworkClient interface to provide data exchange 13 | /// over WebSockets instead of HTTP. It enables real-time data synchronization. 14 | class WebSocketNetworkClient implements NetworkClient { 15 | /// WebSocket connection manager 16 | final WebSocketConnectionManager _connectionManager; 17 | 18 | /// Request timeout in milliseconds 19 | final int _requestTimeout; 20 | 21 | /// Event mapper for converting between event names and types 22 | final SyncEventMapper _eventMapper; 23 | 24 | /// Pending responses for incomplete requests 25 | final Map> _pendingRequests = {}; 26 | 27 | /// Next request ID 28 | int _nextRequestId = 1; 29 | 30 | /// Event controller 31 | final _eventController = StreamController.broadcast(); 32 | 33 | /// Event stream 34 | Stream get eventStream => _eventController.stream; 35 | 36 | /// Combined event stream (both connection and data events) 37 | Stream get combinedEventStream { 38 | return Stream.multi((controller) { 39 | final subscription1 = _eventController.stream.listen(controller.add); 40 | final subscription2 = _connectionManager.eventStream.listen( 41 | controller.add, 42 | ); 43 | 44 | controller.onCancel = () { 45 | subscription1.cancel(); 46 | subscription2.cancel(); 47 | }; 48 | }); 49 | } 50 | 51 | /// Creates a new WebSocketNetworkClient instance. 52 | /// 53 | /// [connectionManager] WebSocket connection manager. 54 | /// [requestTimeout] request timeout in milliseconds. 55 | /// [eventMapper] optional custom event mapper for WebSocket events. 56 | WebSocketNetworkClient({ 57 | required WebSocketConnectionManager connectionManager, 58 | int requestTimeout = 30000, // 30 seconds 59 | SyncEventMapper? eventMapper, 60 | }) : _connectionManager = connectionManager, 61 | _requestTimeout = requestTimeout, 62 | _eventMapper = eventMapper ?? SyncEventMapper() { 63 | _setupEventListeners(); 64 | } 65 | 66 | /// Creates a new WebSocketNetworkClient with a custom WebSocketConfig. 67 | /// 68 | /// [config] WebSocket configuration options. 69 | /// [eventMapper] optional custom event mapper for WebSocket events. 70 | factory WebSocketNetworkClient.withConfig({ 71 | required WebSocketConfig config, 72 | SyncEventMapper? eventMapper, 73 | int requestTimeout = 30000, 74 | }) { 75 | return WebSocketNetworkClient( 76 | connectionManager: WebSocketConnectionManager.withConfig(config), 77 | requestTimeout: requestTimeout, 78 | eventMapper: eventMapper, 79 | ); 80 | } 81 | 82 | /// Sets up event listeners. 83 | void _setupEventListeners() { 84 | _connectionManager.stateStream.listen((state) { 85 | if (state == WebSocketConnectionState.closed || 86 | state == WebSocketConnectionState.error) { 87 | // Cancel all pending requests when connection is closed or error occurs 88 | _cancelAllPendingRequests('WebSocket connection closed'); 89 | } 90 | }); 91 | 92 | // Add message handler 93 | _connectionManager.addMessageHandler(_handleIncomingMessage); 94 | } 95 | 96 | /// Handles incoming WebSocket messages. 97 | void _handleIncomingMessage(Map message) { 98 | // Process response messages (custom format: request/response protocol) 99 | if (message.containsKey('requestId')) { 100 | final String requestId = message['requestId']; 101 | 102 | final completer = _pendingRequests[requestId]; 103 | if (completer != null) { 104 | final bool isSuccess = message['status'] == 'success'; 105 | final int statusCode = message['statusCode'] ?? (isSuccess ? 200 : 400); 106 | final data = message['data']; 107 | 108 | completer.complete( 109 | NetworkResponse( 110 | statusCode: statusCode, 111 | data: data, 112 | error: message['error'] ?? '', 113 | headers: Map.from(message['headers'] ?? {}), 114 | ), 115 | ); 116 | 117 | _pendingRequests.remove(requestId); 118 | } 119 | } 120 | // Process event messages 121 | else if (message.containsKey('event')) { 122 | final String event = message['event']; 123 | 124 | // Map the event name to SyncEventType using the event mapper 125 | final eventType = _eventMapper.mapEventNameToType(event); 126 | 127 | if (eventType != null) { 128 | _emitEvent( 129 | SyncEvent( 130 | type: eventType, 131 | message: message['message'] ?? '', 132 | data: message['data'], 133 | ), 134 | ); 135 | } 136 | } 137 | } 138 | 139 | /// Emits an event. 140 | void _emitEvent(SyncEvent event) { 141 | _eventController.add(event); 142 | } 143 | 144 | /// Initiates a WebSocket connection. 145 | Future connect() { 146 | return _connectionManager.connect(); 147 | } 148 | 149 | /// Closes the WebSocket connection. 150 | Future disconnect() { 151 | return _connectionManager.disconnect(); 152 | } 153 | 154 | /// Subscribes to a WebSocket channel. 155 | /// 156 | /// [channel] name of the channel to subscribe to. 157 | /// [parameters] channel parameters (optional). 158 | Future subscribe(String channel, {Map? parameters}) { 159 | return _connectionManager.subscribe(channel, parameters: parameters); 160 | } 161 | 162 | /// Unsubscribes from a WebSocket channel. 163 | /// 164 | /// [channel] name of the channel to unsubscribe from. 165 | Future unsubscribe(String channel) { 166 | return _connectionManager.unsubscribe(channel); 167 | } 168 | 169 | /// Sends a request and waits for a response. 170 | /// 171 | /// [method] HTTP method ('GET', 'POST', etc.) 172 | /// [endpoint] request endpoint 173 | /// [body] request body (optional) 174 | /// [headers] request headers (optional) 175 | /// [queryParameters] query parameters (optional) 176 | Future _sendRequest({ 177 | required String method, 178 | required String endpoint, 179 | Map? body, 180 | Map? headers, 181 | Map? queryParameters, 182 | }) async { 183 | if (!_connectionManager.isConnected) { 184 | await connect(); 185 | } 186 | 187 | final String requestId = (_nextRequestId++).toString(); 188 | final completer = Completer(); 189 | _pendingRequests[requestId] = completer; 190 | 191 | // Prepare the request 192 | final Map request = { 193 | 'requestId': requestId, 194 | 'method': method, 195 | 'endpoint': endpoint, 196 | if (body != null) 'body': body, 197 | if (headers != null) 'headers': headers, 198 | if (queryParameters != null) 'queryParameters': queryParameters, 199 | }; 200 | 201 | // Set up request timeout 202 | final timer = Timer(Duration(milliseconds: _requestTimeout), () { 203 | if (!completer.isCompleted) { 204 | final WebSocketConfig config = _connectionManager.config; 205 | completer.complete( 206 | NetworkResponse( 207 | statusCode: 408, // Request Timeout 208 | error: config.requestTimeoutMessage, 209 | ), 210 | ); 211 | _pendingRequests.remove(requestId); 212 | } 213 | }); 214 | 215 | try { 216 | // Send the request 217 | await _connectionManager.send( 218 | request, 219 | eventType: _connectionManager.config.requestMessageType, 220 | ); 221 | 222 | // Wait for response 223 | final response = await completer.future; 224 | timer.cancel(); 225 | return response; 226 | } catch (e) { 227 | timer.cancel(); 228 | _pendingRequests.remove(requestId); 229 | return NetworkResponse( 230 | statusCode: 500, 231 | error: '${_connectionManager.config.errorSendingRequestMessage}: $e', 232 | ); 233 | } 234 | } 235 | 236 | /// Cancels all pending requests. 237 | void _cancelAllPendingRequests(String reason) { 238 | for (final requestId in _pendingRequests.keys) { 239 | final completer = _pendingRequests[requestId]; 240 | if (completer != null && !completer.isCompleted) { 241 | completer.complete(NetworkResponse(statusCode: 0, error: reason)); 242 | } 243 | } 244 | _pendingRequests.clear(); 245 | } 246 | 247 | @override 248 | Future get( 249 | String endpoint, { 250 | Map? headers, 251 | Map? queryParameters, 252 | RestRequest? requestConfig, 253 | }) { 254 | return _sendRequest( 255 | method: 'GET', 256 | endpoint: endpoint, 257 | headers: headers, 258 | queryParameters: queryParameters, 259 | ); 260 | } 261 | 262 | @override 263 | Future post( 264 | String endpoint, { 265 | Map? body, 266 | Map? headers, 267 | RestRequest? requestConfig, 268 | }) { 269 | return _sendRequest( 270 | method: 'POST', 271 | endpoint: endpoint, 272 | body: body, 273 | headers: headers, 274 | ); 275 | } 276 | 277 | @override 278 | Future put( 279 | String endpoint, { 280 | Map? body, 281 | Map? headers, 282 | RestRequest? requestConfig, 283 | }) { 284 | return _sendRequest( 285 | method: 'PUT', 286 | endpoint: endpoint, 287 | body: body, 288 | headers: headers, 289 | ); 290 | } 291 | 292 | @override 293 | Future delete( 294 | String endpoint, { 295 | Map? headers, 296 | RestRequest? requestConfig, 297 | }) { 298 | return _sendRequest(method: 'DELETE', endpoint: endpoint, headers: headers); 299 | } 300 | 301 | /// Sends a PATCH request 302 | @override 303 | Future patch( 304 | String endpoint, { 305 | Map? body, 306 | Map? headers, 307 | RestRequest? requestConfig, 308 | }) { 309 | return _sendRequest( 310 | method: 'PATCH', 311 | endpoint: endpoint, 312 | body: body, 313 | headers: headers, 314 | ); 315 | } 316 | 317 | /// Cleans up resources. 318 | void dispose() { 319 | _cancelAllPendingRequests(_connectionManager.config.clientClosedMessage); 320 | _connectionManager.removeMessageHandler(_handleIncomingMessage); 321 | _eventController.close(); 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /lib/src/query/where_condition.dart: -------------------------------------------------------------------------------- 1 | import 'package:equatable/equatable.dart'; 2 | 3 | /// Type of comparison operator used in a where condition 4 | enum WhereOperator { 5 | /// field == value 6 | equals, 7 | 8 | /// field != value 9 | notEquals, 10 | 11 | /// field > value 12 | greaterThan, 13 | 14 | /// field >= value 15 | greaterThanOrEquals, 16 | 17 | /// field < value 18 | lessThan, 19 | 20 | /// field <= value 21 | lessThanOrEquals, 22 | 23 | /// field LIKE '%value%' 24 | contains, 25 | 26 | /// field LIKE 'value%' 27 | startsWith, 28 | 29 | /// field LIKE '%value' 30 | endsWith, 31 | 32 | /// field IN (value1, value2, ...) 33 | inList, 34 | 35 | /// field IS NULL 36 | isNull, 37 | 38 | /// field IS NOT NULL 39 | isNotNull, 40 | } 41 | 42 | /// Represents a condition for filtering data in queries. 43 | /// 44 | /// This class defines conditions like equals, greater than, contains, etc. 45 | /// that can be used to filter data in the query system. 46 | /// 47 | /// Example: 48 | /// ```dart 49 | /// final condition = WhereCondition.exact('name', 'John'); 50 | /// final greaterThan = WhereCondition.greaterThan('age', 18); 51 | /// ``` 52 | class WhereCondition extends Equatable { 53 | /// The field name to filter on 54 | final String field; 55 | 56 | /// The value to compare against 57 | final dynamic value; 58 | 59 | /// The comparison operator 60 | final WhereOperator operator; 61 | 62 | /// Creates a new where condition. 63 | /// 64 | /// [field] - The field name to filter on 65 | /// [value] - The value to compare against 66 | /// [operator] - The comparison operator 67 | const WhereCondition({ 68 | required this.field, 69 | required this.operator, 70 | this.value, 71 | }); 72 | 73 | /// Creates a condition checking for equality. 74 | /// 75 | /// This is equivalent to `field = value` in SQL. 76 | factory WhereCondition.exact(String field, dynamic value) { 77 | return WhereCondition( 78 | field: field, 79 | operator: WhereOperator.equals, 80 | value: value, 81 | ); 82 | } 83 | 84 | /// Creates a condition checking for inequality. 85 | /// 86 | /// This is equivalent to `field != value` in SQL. 87 | factory WhereCondition.notEquals(String field, dynamic value) { 88 | return WhereCondition( 89 | field: field, 90 | operator: WhereOperator.notEquals, 91 | value: value, 92 | ); 93 | } 94 | 95 | /// Creates a condition checking if field is greater than value. 96 | /// 97 | /// This is equivalent to `field > value` in SQL. 98 | factory WhereCondition.greaterThan(String field, dynamic value) { 99 | return WhereCondition( 100 | field: field, 101 | operator: WhereOperator.greaterThan, 102 | value: value, 103 | ); 104 | } 105 | 106 | /// Creates a condition checking if field is greater than or equal to value. 107 | /// 108 | /// This is equivalent to `field >= value` in SQL. 109 | factory WhereCondition.greaterThanOrEquals(String field, dynamic value) { 110 | return WhereCondition( 111 | field: field, 112 | operator: WhereOperator.greaterThanOrEquals, 113 | value: value, 114 | ); 115 | } 116 | 117 | /// Creates a condition checking if field is less than value. 118 | /// 119 | /// This is equivalent to `field < value` in SQL. 120 | factory WhereCondition.lessThan(String field, dynamic value) { 121 | return WhereCondition( 122 | field: field, 123 | operator: WhereOperator.lessThan, 124 | value: value, 125 | ); 126 | } 127 | 128 | /// Creates a condition checking if field is less than or equal to value. 129 | /// 130 | /// This is equivalent to `field <= value` in SQL. 131 | factory WhereCondition.lessThanOrEquals(String field, dynamic value) { 132 | return WhereCondition( 133 | field: field, 134 | operator: WhereOperator.lessThanOrEquals, 135 | value: value, 136 | ); 137 | } 138 | 139 | /// Creates a condition checking if field contains value. 140 | /// 141 | /// This is equivalent to `field LIKE '%value%'` in SQL. 142 | factory WhereCondition.contains(String field, String value) { 143 | return WhereCondition( 144 | field: field, 145 | operator: WhereOperator.contains, 146 | value: value, 147 | ); 148 | } 149 | 150 | /// Creates a condition checking if field starts with value. 151 | /// 152 | /// This is equivalent to `field LIKE 'value%'` in SQL. 153 | factory WhereCondition.startsWith(String field, String value) { 154 | return WhereCondition( 155 | field: field, 156 | operator: WhereOperator.startsWith, 157 | value: value, 158 | ); 159 | } 160 | 161 | /// Creates a condition checking if field ends with value. 162 | /// 163 | /// This is equivalent to `field LIKE '%value'` in SQL. 164 | factory WhereCondition.endsWith(String field, String value) { 165 | return WhereCondition( 166 | field: field, 167 | operator: WhereOperator.endsWith, 168 | value: value, 169 | ); 170 | } 171 | 172 | /// Creates a condition checking if field is in a list of values. 173 | /// 174 | /// This is equivalent to `field IN (value1, value2, ...)` in SQL. 175 | factory WhereCondition.inList(String field, List values) { 176 | return WhereCondition( 177 | field: field, 178 | operator: WhereOperator.inList, 179 | value: values, 180 | ); 181 | } 182 | 183 | /// Creates a condition checking if field is null. 184 | /// 185 | /// This is equivalent to `field IS NULL` in SQL. 186 | factory WhereCondition.isNull(String field) { 187 | return WhereCondition(field: field, operator: WhereOperator.isNull); 188 | } 189 | 190 | /// Creates a condition checking if field is not null. 191 | /// 192 | /// This is equivalent to `field IS NOT NULL` in SQL. 193 | factory WhereCondition.isNotNull(String field) { 194 | return WhereCondition(field: field, operator: WhereOperator.isNotNull); 195 | } 196 | 197 | /// Converts this condition to a SQL clause. 198 | /// 199 | /// This is used internally by the query system to generate SQL. 200 | /// 201 | /// Returns a tuple of (clause, arguments) where: 202 | /// - clause is the SQL WHERE clause fragment 203 | /// - arguments is the list of arguments for the prepared statement 204 | (String, List) toSqlClause() { 205 | switch (operator) { 206 | case WhereOperator.equals: 207 | return ('$field = ?', [value]); 208 | case WhereOperator.notEquals: 209 | return ('$field != ?', [value]); 210 | case WhereOperator.greaterThan: 211 | return ('$field > ?', [value]); 212 | case WhereOperator.greaterThanOrEquals: 213 | return ('$field >= ?', [value]); 214 | case WhereOperator.lessThan: 215 | return ('$field < ?', [value]); 216 | case WhereOperator.lessThanOrEquals: 217 | return ('$field <= ?', [value]); 218 | case WhereOperator.contains: 219 | return ('$field LIKE ?', ['%$value%']); 220 | case WhereOperator.startsWith: 221 | return ('$field LIKE ?', ['$value%']); 222 | case WhereOperator.endsWith: 223 | return ('$field LIKE ?', ['%$value']); 224 | case WhereOperator.inList: 225 | final List values = value as List; 226 | final placeholders = List.filled(values.length, '?').join(', '); 227 | return ('$field IN ($placeholders)', values); 228 | case WhereOperator.isNull: 229 | return ('$field IS NULL', []); 230 | case WhereOperator.isNotNull: 231 | return ('$field IS NOT NULL', []); 232 | } 233 | } 234 | 235 | /// Checks if this condition matches an item. 236 | /// 237 | /// This is used when applying queries in memory rather than via SQL. 238 | /// 239 | /// [item] - The item to check 240 | /// [getField] - A function that extracts a field value from the item 241 | /// 242 | /// Returns true if the condition matches the item 243 | bool matches(T item, dynamic Function(T item, String field) getField) { 244 | final fieldValue = getField(item, field); 245 | 246 | switch (operator) { 247 | case WhereOperator.equals: 248 | return fieldValue == value; 249 | case WhereOperator.notEquals: 250 | return fieldValue != value; 251 | case WhereOperator.greaterThan: 252 | if (fieldValue == null) return false; 253 | if (fieldValue is Comparable && value is Comparable) { 254 | return Comparable.compare(fieldValue, value as Comparable) > 0; 255 | } 256 | return false; 257 | case WhereOperator.greaterThanOrEquals: 258 | if (fieldValue == null) return false; 259 | if (fieldValue is Comparable && value is Comparable) { 260 | return Comparable.compare(fieldValue, value as Comparable) >= 0; 261 | } 262 | return false; 263 | case WhereOperator.lessThan: 264 | if (fieldValue == null) return false; 265 | if (fieldValue is Comparable && value is Comparable) { 266 | return Comparable.compare(fieldValue, value as Comparable) < 0; 267 | } 268 | return false; 269 | case WhereOperator.lessThanOrEquals: 270 | if (fieldValue == null) return false; 271 | if (fieldValue is Comparable && value is Comparable) { 272 | return Comparable.compare(fieldValue, value as Comparable) <= 0; 273 | } 274 | return false; 275 | case WhereOperator.contains: 276 | if (fieldValue == null) return false; 277 | return fieldValue.toString().contains(value.toString()); 278 | case WhereOperator.startsWith: 279 | if (fieldValue == null) return false; 280 | return fieldValue.toString().startsWith(value.toString()); 281 | case WhereOperator.endsWith: 282 | if (fieldValue == null) return false; 283 | return fieldValue.toString().endsWith(value.toString()); 284 | case WhereOperator.inList: 285 | if (fieldValue == null) return false; 286 | return (value as List).contains(fieldValue); 287 | case WhereOperator.isNull: 288 | return fieldValue == null; 289 | case WhereOperator.isNotNull: 290 | return fieldValue != null; 291 | } 292 | } 293 | 294 | @override 295 | List get props => [field, operator, value]; 296 | 297 | @override 298 | String toString() { 299 | switch (operator) { 300 | case WhereOperator.equals: 301 | return '$field = $value'; 302 | case WhereOperator.notEquals: 303 | return '$field != $value'; 304 | case WhereOperator.greaterThan: 305 | return '$field > $value'; 306 | case WhereOperator.greaterThanOrEquals: 307 | return '$field >= $value'; 308 | case WhereOperator.lessThan: 309 | return '$field < $value'; 310 | case WhereOperator.lessThanOrEquals: 311 | return '$field <= $value'; 312 | case WhereOperator.contains: 313 | return '$field CONTAINS "$value"'; 314 | case WhereOperator.startsWith: 315 | return '$field STARTS WITH "$value"'; 316 | case WhereOperator.endsWith: 317 | return '$field ENDS WITH "$value"'; 318 | case WhereOperator.inList: 319 | return '$field IN $value'; 320 | case WhereOperator.isNull: 321 | return '$field IS NULL'; 322 | case WhereOperator.isNotNull: 323 | return '$field IS NOT NULL'; 324 | } 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /lib/src/network/websocket_connection_manager.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:flutter/foundation.dart'; 6 | import '../models/sync_event.dart'; 7 | import '../models/sync_event_type.dart'; 8 | import '../models/websocket_config.dart'; 9 | 10 | /// WebSocket connection states 11 | enum WebSocketConnectionState { 12 | /// Connection is closed 13 | closed, 14 | 15 | /// Connection is being established 16 | connecting, 17 | 18 | /// Connection is open 19 | open, 20 | 21 | /// Reconnection is being attempted 22 | reconnecting, 23 | 24 | /// Connection error occurred 25 | error, 26 | } 27 | 28 | /// WebSocket message handler type 29 | typedef WebSocketMessageHandler = void Function(Map message); 30 | 31 | /// WebSocket connection manager. 32 | /// 33 | /// This class manages WebSocket connections, including connecting, disconnecting, 34 | /// reconnecting and broadcasting events related to the connection state. 35 | class WebSocketConnectionManager { 36 | /// WebSocket configuration 37 | final WebSocketConfig config; 38 | 39 | /// WebSocket connection 40 | WebSocket? _socket; 41 | 42 | /// Connection state 43 | WebSocketConnectionState _state = WebSocketConnectionState.closed; 44 | 45 | /// Reconnection attempt count 46 | int _reconnectAttempts = 0; 47 | 48 | /// Ping/Pong timer 49 | Timer? _pingTimer; 50 | 51 | /// Reconnection timer 52 | Timer? _reconnectTimer; 53 | 54 | /// Connection state controller 55 | final _stateController = 56 | StreamController.broadcast(); 57 | 58 | /// Event controller 59 | final _eventController = StreamController.broadcast(); 60 | 61 | /// Message handlers 62 | final List _messageHandlers = []; 63 | 64 | /// Event stream 65 | Stream get eventStream => _eventController.stream; 66 | 67 | /// Connection state stream 68 | Stream get stateStream => _stateController.stream; 69 | 70 | /// Current connection state 71 | WebSocketConnectionState get state => _state; 72 | 73 | /// Whether the connection is open 74 | bool get isConnected => _state == WebSocketConnectionState.open; 75 | 76 | /// Creates a new WebSocketConnectionManager instance. 77 | /// 78 | /// [serverUrl] WebSocket server URL 79 | /// [pingInterval] ping interval in seconds 80 | /// [reconnectDelay] reconnection delay in milliseconds 81 | /// [maxReconnectAttempts] maximum number of reconnection attempts 82 | WebSocketConnectionManager({ 83 | required String serverUrl, 84 | int pingInterval = 30, 85 | int reconnectDelay = 5000, 86 | int maxReconnectAttempts = 10, 87 | }) : config = WebSocketConfig( 88 | serverUrl: serverUrl, 89 | pingInterval: pingInterval, 90 | reconnectDelay: reconnectDelay, 91 | maxReconnectAttempts: maxReconnectAttempts, 92 | ); 93 | 94 | /// Creates a new WebSocketConnectionManager instance with a custom config. 95 | /// 96 | /// [config] WebSocket configuration options 97 | WebSocketConnectionManager.withConfig(this.config); 98 | 99 | /// Adds a message handler 100 | void addMessageHandler(WebSocketMessageHandler handler) { 101 | if (!_messageHandlers.contains(handler)) { 102 | _messageHandlers.add(handler); 103 | } 104 | } 105 | 106 | /// Removes a message handler 107 | void removeMessageHandler(WebSocketMessageHandler handler) { 108 | _messageHandlers.remove(handler); 109 | } 110 | 111 | /// Initiates a WebSocket connection. 112 | /// 113 | /// If the connection is successfully established, [onOpen] is called. 114 | /// If a connection error occurs, reconnection is scheduled. 115 | Future connect() async { 116 | if (_state == WebSocketConnectionState.open || 117 | _state == WebSocketConnectionState.connecting) { 118 | return; 119 | } 120 | 121 | _updateState(WebSocketConnectionState.connecting); 122 | 123 | try { 124 | _socket = await WebSocket.connect(config.serverUrl); 125 | _updateState(WebSocketConnectionState.open); 126 | _reconnectAttempts = 0; 127 | _setupSocketListeners(); 128 | _startPingTimer(); 129 | 130 | _emitEvent( 131 | SyncEvent( 132 | type: SyncEventType.connectionEstablished, 133 | message: config.connectionEstablishedMessage, 134 | ), 135 | ); 136 | } catch (e) { 137 | _updateState(WebSocketConnectionState.error); 138 | _emitEvent( 139 | SyncEvent( 140 | type: SyncEventType.connectionFailed, 141 | message: '${config.connectionFailedMessage}: $e', 142 | data: {'error': e.toString()}, 143 | ), 144 | ); 145 | _scheduleReconnect(); 146 | } 147 | } 148 | 149 | /// Closes the WebSocket connection. 150 | Future disconnect() async { 151 | _cancelTimers(); 152 | 153 | if (_socket != null) { 154 | await _socket!.close(); 155 | _socket = null; 156 | } 157 | 158 | _updateState(WebSocketConnectionState.closed); 159 | 160 | _emitEvent( 161 | SyncEvent( 162 | type: SyncEventType.connectionClosed, 163 | message: config.connectionClosedMessage, 164 | ), 165 | ); 166 | } 167 | 168 | /// Sends a message over the WebSocket connection. 169 | /// 170 | /// [data] the data to send. This data will be converted to JSON. 171 | /// [eventType] the type of the message (optional). 172 | Future send(Map data, {String? eventType}) async { 173 | if (!isConnected || _socket == null) { 174 | throw Exception('WebSocket connection is closed'); 175 | } 176 | 177 | final message = 178 | eventType != null ? {'type': eventType, 'data': data} : data; 179 | 180 | try { 181 | _socket!.add(jsonEncode(message)); 182 | } catch (e) { 183 | _emitEvent( 184 | SyncEvent( 185 | type: SyncEventType.syncError, 186 | message: '${config.errorSendingMessage}: $e', 187 | data: {'error': e.toString(), 'data': data}, 188 | ), 189 | ); 190 | rethrow; 191 | } 192 | } 193 | 194 | /// Subscribes to a channel. 195 | /// 196 | /// [channel] the name of the channel to subscribe to. 197 | /// [parameters] channel parameters (optional). 198 | Future subscribe( 199 | String channel, { 200 | Map? parameters, 201 | }) async { 202 | await send( 203 | config.subscriptionMessageFormatter(channel, parameters), 204 | eventType: config.subscriptionMessageType, 205 | ); 206 | } 207 | 208 | /// Unsubscribes from a channel. 209 | /// 210 | /// [channel] the name of the channel to unsubscribe from. 211 | Future unsubscribe(String channel) async { 212 | await send( 213 | config.unsubscriptionMessageFormatter(channel), 214 | eventType: config.subscriptionMessageType, 215 | ); 216 | } 217 | 218 | /// Sets up the socket listeners. 219 | void _setupSocketListeners() { 220 | _socket?.listen( 221 | (dynamic data) { 222 | _handleMessage(data); 223 | }, 224 | onDone: () { 225 | _emitEvent( 226 | SyncEvent( 227 | type: SyncEventType.connectionClosed, 228 | message: config.connectionClosedMessage, 229 | ), 230 | ); 231 | _updateState(WebSocketConnectionState.closed); 232 | _scheduleReconnect(); 233 | }, 234 | onError: (error) { 235 | _emitEvent( 236 | SyncEvent( 237 | type: SyncEventType.connectionFailed, 238 | message: 'WebSocket error: $error', 239 | data: {'error': error.toString()}, 240 | ), 241 | ); 242 | _updateState(WebSocketConnectionState.error); 243 | _scheduleReconnect(); 244 | }, 245 | cancelOnError: false, 246 | ); 247 | } 248 | 249 | /// Handles incoming messages. 250 | void _handleMessage(dynamic data) { 251 | try { 252 | final Map message = 253 | data is String ? jsonDecode(data) : data; 254 | 255 | // Process "pong" messages (heartbeat check) 256 | if (message['type'] == 'pong') { 257 | return; 258 | } 259 | 260 | // Process model-related events 261 | if (message.containsKey('eventType')) { 262 | final String eventType = message['eventType']; 263 | 264 | SyncEventType? syncEventType; 265 | 266 | // Determine event type 267 | if (eventType == 'modelUpdated') { 268 | syncEventType = SyncEventType.modelUpdated; 269 | } else if (eventType == 'modelAdded') { 270 | syncEventType = SyncEventType.modelAdded; 271 | } else if (eventType == 'modelDeleted') { 272 | syncEventType = SyncEventType.modelDeleted; 273 | } else if (eventType == 'conflictDetected') { 274 | syncEventType = SyncEventType.conflictDetected; 275 | } 276 | 277 | if (syncEventType != null) { 278 | _emitEvent( 279 | SyncEvent( 280 | type: syncEventType, 281 | data: message['data'], 282 | message: message['message'] ?? '', 283 | ), 284 | ); 285 | } 286 | } 287 | 288 | // Notify all message handlers 289 | notifyListeners(message); 290 | } catch (e) { 291 | debugPrint('Failed to process WebSocket message: $e'); 292 | } 293 | } 294 | 295 | /// Updates the connection state and publishes the corresponding event. 296 | void _updateState(WebSocketConnectionState newState) { 297 | _state = newState; 298 | _stateController.add(newState); 299 | } 300 | 301 | /// Starts the ping timer. 302 | void _startPingTimer() { 303 | _cancelPingTimer(); 304 | _pingTimer = Timer.periodic(Duration(seconds: config.pingInterval), (_) { 305 | if (isConnected) { 306 | try { 307 | _socket?.add(jsonEncode(config.pingMessageFormat)); 308 | } catch (e) { 309 | debugPrint('Failed to send ping: $e'); 310 | } 311 | } 312 | }); 313 | } 314 | 315 | /// Cancels the ping timer. 316 | void _cancelPingTimer() { 317 | _pingTimer?.cancel(); 318 | _pingTimer = null; 319 | } 320 | 321 | /// Cancels the reconnection timer. 322 | void _cancelReconnectTimer() { 323 | _reconnectTimer?.cancel(); 324 | _reconnectTimer = null; 325 | } 326 | 327 | /// Cancels all timers. 328 | void _cancelTimers() { 329 | _cancelPingTimer(); 330 | _cancelReconnectTimer(); 331 | } 332 | 333 | /// Schedules a reconnection attempt. 334 | void _scheduleReconnect() { 335 | _cancelReconnectTimer(); 336 | 337 | if (_reconnectAttempts >= config.maxReconnectAttempts) { 338 | _emitEvent( 339 | SyncEvent( 340 | type: SyncEventType.connectionFailed, 341 | message: 'Maximum reconnection attempts exceeded', 342 | ), 343 | ); 344 | return; 345 | } 346 | 347 | _reconnectAttempts++; 348 | _updateState(WebSocketConnectionState.reconnecting); 349 | 350 | _emitEvent( 351 | SyncEvent( 352 | type: SyncEventType.reconnecting, 353 | message: 354 | '${config.reconnectingMessage} (Attempt: $_reconnectAttempts/${config.maxReconnectAttempts})', 355 | data: { 356 | 'attempt': _reconnectAttempts, 357 | 'maxAttempts': config.maxReconnectAttempts, 358 | }, 359 | ), 360 | ); 361 | 362 | // Exponential backoff strategy (1, 2, 4, 8, 16, ...) 363 | final delay = 364 | config.reconnectDelay * (1 << (_reconnectAttempts - 1).clamp(0, 5)); 365 | 366 | _reconnectTimer = Timer(Duration(milliseconds: delay), () { 367 | connect(); 368 | }); 369 | } 370 | 371 | /// Notifies listeners of a message. 372 | /// 373 | /// Called for every message received over the WebSocket connection. 374 | void notifyListeners(Map message) { 375 | for (final handler in _messageHandlers) { 376 | try { 377 | handler(message); 378 | } catch (e) { 379 | debugPrint('Error processing message: $e'); 380 | } 381 | } 382 | } 383 | 384 | /// Emits an event. 385 | void _emitEvent(SyncEvent event) { 386 | _eventController.add(event); 387 | } 388 | 389 | /// Cleans up resources. 390 | void dispose() { 391 | _cancelTimers(); 392 | disconnect(); 393 | _stateController.close(); 394 | _eventController.close(); 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /lib/src/services/storage_service_impl.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:path_provider/path_provider.dart'; 4 | import 'package:sqflite/sqflite.dart'; 5 | import 'package:path/path.dart' show join; 6 | import '../models/sync_model.dart'; 7 | import '../query/query.dart'; 8 | import 'storage_service.dart'; 9 | 10 | class StorageServiceImpl implements StorageService { 11 | static const String _dbName = 'offline_sync.db'; 12 | static const int _dbVersion = 1; 13 | static const String _syncTable = 'sync_items'; 14 | static const String _metaTable = 'sync_meta'; 15 | 16 | Database? _db; 17 | final Map _modelDeserializers = {}; 18 | 19 | @override 20 | Future initialize({ 21 | String? directory, 22 | DatabaseFactory? databaseFactoryCustom, 23 | }) async { 24 | if (_db != null) return; 25 | 26 | final documentsDirectory = 27 | directory ?? (await getApplicationDocumentsDirectory()).path; 28 | final path = join(documentsDirectory, _dbName); 29 | databaseFactoryCustom ??= databaseFactory; 30 | _db = await databaseFactoryCustom.openDatabase( 31 | path, 32 | options: OpenDatabaseOptions(version: _dbVersion, onCreate: _createDb), 33 | ); 34 | } 35 | 36 | Future _createDb(Database db, int version) async { 37 | await db.execute(''' 38 | CREATE TABLE $_syncTable ( 39 | id TEXT PRIMARY KEY, 40 | model_type TEXT NOT NULL, 41 | created_at INTEGER NOT NULL, 42 | updated_at INTEGER NOT NULL, 43 | is_synced INTEGER NOT NULL, 44 | sync_error TEXT, 45 | sync_attempts INTEGER NOT NULL, 46 | data TEXT NOT NULL 47 | ) 48 | '''); 49 | 50 | await db.execute(''' 51 | CREATE TABLE $_metaTable ( 52 | key TEXT PRIMARY KEY, 53 | value TEXT NOT NULL 54 | ) 55 | '''); 56 | 57 | await db.insert(_metaTable, { 58 | 'key': 'last_sync_time', 59 | 'value': '0', 60 | }, conflictAlgorithm: ConflictAlgorithm.replace); 61 | } 62 | 63 | void registerModelDeserializer( 64 | String modelType, 65 | T Function(Map json) deserializer, 66 | ) { 67 | _modelDeserializers[modelType] = deserializer; 68 | } 69 | 70 | @override 71 | Future get(String id, String modelType) async { 72 | await initialize(); 73 | 74 | final result = await _db!.query( 75 | _syncTable, 76 | where: 'id = ? AND model_type = ?', 77 | whereArgs: [id, modelType], 78 | ); 79 | 80 | if (result.isEmpty) { 81 | return null; 82 | } 83 | 84 | return _deserializeModel(result.first); 85 | } 86 | 87 | @override 88 | Future> getAll(String modelType) async { 89 | await initialize(); 90 | 91 | final result = await _db!.query( 92 | _syncTable, 93 | where: 'model_type = ?', 94 | whereArgs: [modelType], 95 | ); 96 | 97 | return result.map((row) => _deserializeModel(row)!).toList(); 98 | } 99 | 100 | @override 101 | Future> getPending(String modelType) async { 102 | await initialize(); 103 | 104 | final result = await _db!.query( 105 | _syncTable, 106 | where: 'model_type = ? AND is_synced = 0', 107 | whereArgs: [modelType], 108 | ); 109 | 110 | return result.map((row) => _deserializeModel(row)!).toList(); 111 | } 112 | 113 | @override 114 | Future> getItems( 115 | String modelType, { 116 | Map? query, 117 | }) async { 118 | await initialize(); 119 | 120 | // For simplicity in the initial implementation, just get all items of the model type 121 | // The JSON query filtering would require more sophisticated implementation 122 | final result = await _db!.query( 123 | _syncTable, 124 | where: 'model_type = ?', 125 | whereArgs: [modelType], 126 | ); 127 | 128 | // If there's no additional filtering needed, return all items 129 | if (query == null || query.isEmpty) { 130 | return result.map((row) => _deserializeModel(row)!).toList(); 131 | } 132 | 133 | // Deserialize all items first 134 | final allItems = 135 | result.map((row) => _deserializeModel(row)!).toList(); 136 | 137 | // Then filter them in memory based on the query parameters 138 | return allItems.where((item) { 139 | final itemJson = item.toJson(); 140 | 141 | // Check if all query parameters match 142 | return query.entries.every((entry) { 143 | final key = entry.key; 144 | final value = entry.value; 145 | 146 | // Skip null values 147 | if (value == null) return true; 148 | 149 | // Make sure the item has this field 150 | if (!itemJson.containsKey(key)) return false; 151 | 152 | // Check if values match 153 | if (value is List) { 154 | return value.contains(itemJson[key]); 155 | } else { 156 | return itemJson[key] == value; 157 | } 158 | }); 159 | }).toList(); 160 | } 161 | 162 | @override 163 | Future> getItemsWithQuery( 164 | String modelType, { 165 | Query? query, 166 | }) async { 167 | await initialize(); 168 | 169 | if (query == null) { 170 | // If no query is provided, return all items of this type 171 | return getAll(modelType); 172 | } 173 | 174 | try { 175 | // Try to use SQL-based filtering if possible 176 | return await _getItemsWithSqlQuery(modelType, query); 177 | } catch (e) { 178 | // Fallback to in-memory filtering if SQL approach fails 179 | return _getItemsWithInMemoryFiltering(modelType, query); 180 | } 181 | } 182 | 183 | // Gets items using direct SQL queries for better performance 184 | Future> _getItemsWithSqlQuery( 185 | String modelType, 186 | Query query, 187 | ) async { 188 | // Base query always filters by model_type 189 | String whereClause = 'model_type = ?'; 190 | List whereArgs = [modelType]; 191 | 192 | // Add query conditions if present 193 | if (query.where != null && query.where!.isNotEmpty) { 194 | final (queryWhereClause, queryArgs) = query.toSqlWhereClause(); 195 | if (queryWhereClause.isNotEmpty) { 196 | whereClause += ' AND $queryWhereClause'; 197 | whereArgs.addAll(queryArgs); 198 | } 199 | } 200 | 201 | // Prepare ordering 202 | String? orderBy; 203 | if (query.orderBy != null) { 204 | // Map field name to database column 205 | // Most fields are stored in the JSON data, but we can handle special cases 206 | String orderField; 207 | switch (query.orderBy) { 208 | case 'createdAt': 209 | orderField = 'created_at'; 210 | break; 211 | case 'updatedAt': 212 | orderField = 'updated_at'; 213 | break; 214 | default: 215 | // For data stored in JSON, we can't directly order in SQL 216 | // We'll need to use memory-based filtering instead 217 | throw UnsupportedError( 218 | 'SQL ordering not supported for field: ${query.orderBy}', 219 | ); 220 | } 221 | 222 | orderBy = '$orderField ${query.descending ? 'DESC' : 'ASC'}'; 223 | } 224 | 225 | // Execute the query 226 | final result = await _db!.query( 227 | _syncTable, 228 | where: whereClause, 229 | whereArgs: whereArgs, 230 | orderBy: orderBy, 231 | limit: query.limit, 232 | offset: query.offset, 233 | ); 234 | 235 | return result.map((row) => _deserializeModel(row)!).toList(); 236 | } 237 | 238 | // Fallback method that uses in-memory filtering 239 | Future> _getItemsWithInMemoryFiltering( 240 | String modelType, 241 | Query query, 242 | ) async { 243 | // Get all items of this type first 244 | final allItems = await getAll(modelType); 245 | 246 | // Apply the query filter 247 | return query.applyToList(allItems, (item, field) { 248 | // Extract field value from the item 249 | switch (field) { 250 | case 'id': 251 | return item.id; 252 | case 'createdAt': 253 | return item.createdAt.millisecondsSinceEpoch; 254 | case 'updatedAt': 255 | return item.updatedAt.millisecondsSinceEpoch; 256 | case 'isSynced': 257 | return item.isSynced; 258 | default: 259 | // For other fields, get from the JSON data 260 | final json = item.toJson(); 261 | return json[field]; 262 | } 263 | }); 264 | } 265 | 266 | @override 267 | Future save(T model) async { 268 | await initialize(); 269 | 270 | await _db!.insert( 271 | _syncTable, 272 | _serializeModel(model), 273 | conflictAlgorithm: ConflictAlgorithm.replace, 274 | ); 275 | } 276 | 277 | @override 278 | Future saveAll(List models) async { 279 | await initialize(); 280 | 281 | final batch = _db!.batch(); 282 | 283 | for (final model in models) { 284 | batch.insert( 285 | _syncTable, 286 | _serializeModel(model), 287 | conflictAlgorithm: ConflictAlgorithm.replace, 288 | ); 289 | } 290 | 291 | await batch.commit(noResult: true); 292 | } 293 | 294 | @override 295 | Future update(T model) async { 296 | await initialize(); 297 | 298 | await _db!.update( 299 | _syncTable, 300 | _serializeModel(model), 301 | where: 'id = ?', 302 | whereArgs: [model.id], 303 | ); 304 | } 305 | 306 | @override 307 | Future delete(String id, String modelType) async { 308 | await initialize(); 309 | 310 | await _db!.delete( 311 | _syncTable, 312 | where: 'id = ? AND model_type = ?', 313 | whereArgs: [id, modelType], 314 | ); 315 | } 316 | 317 | @override 318 | Future deleteModel(T model) async { 319 | await delete(model.id, model.modelType); 320 | } 321 | 322 | @override 323 | Future markAsSynced( 324 | String id, 325 | String modelType, 326 | ) async { 327 | await initialize(); 328 | 329 | final item = await get(id, modelType); 330 | 331 | if (item != null) { 332 | final syncedItem = item.markAsSynced(); 333 | await update(syncedItem); 334 | } 335 | } 336 | 337 | @override 338 | Future markSyncFailed( 339 | String id, 340 | String modelType, 341 | String error, 342 | ) async { 343 | await initialize(); 344 | 345 | final item = await get(id, modelType); 346 | 347 | if (item != null) { 348 | final failedItem = item.markSyncFailed(error); 349 | await update(failedItem); 350 | } 351 | } 352 | 353 | @override 354 | Future getPendingCount() async { 355 | await initialize(); 356 | 357 | final result = await _db!.rawQuery( 358 | 'SELECT COUNT(*) as count FROM $_syncTable WHERE is_synced = 0', 359 | ); 360 | 361 | return Sqflite.firstIntValue(result) ?? 0; 362 | } 363 | 364 | @override 365 | Future getLastSyncTime() async { 366 | await initialize(); 367 | 368 | final result = await _db!.query( 369 | _metaTable, 370 | where: 'key = ?', 371 | whereArgs: ['last_sync_time'], 372 | ); 373 | 374 | if (result.isEmpty) { 375 | return DateTime.fromMillisecondsSinceEpoch(0); 376 | } 377 | 378 | final timestamp = int.parse(result.first['value'] as String); 379 | return DateTime.fromMillisecondsSinceEpoch(timestamp); 380 | } 381 | 382 | @override 383 | Future setLastSyncTime(DateTime time) async { 384 | await initialize(); 385 | 386 | await _db!.update( 387 | _metaTable, 388 | {'value': time.millisecondsSinceEpoch.toString()}, 389 | where: 'key = ?', 390 | whereArgs: ['last_sync_time'], 391 | conflictAlgorithm: ConflictAlgorithm.replace, 392 | ); 393 | } 394 | 395 | @override 396 | Future clearAll() async { 397 | await initialize(); 398 | 399 | await _db!.delete(_syncTable); 400 | await _db!.delete(_metaTable); 401 | 402 | await _db!.insert(_metaTable, { 403 | 'key': 'last_sync_time', 404 | 'value': '0', 405 | }, conflictAlgorithm: ConflictAlgorithm.replace); 406 | } 407 | 408 | @override 409 | Future close() async { 410 | if (_db != null) { 411 | await _db!.close(); 412 | _db = null; 413 | } 414 | } 415 | 416 | Map _serializeModel(T model) { 417 | return { 418 | 'id': model.id, 419 | 'model_type': model.modelType, 420 | 'created_at': model.createdAt.millisecondsSinceEpoch, 421 | 'updated_at': model.updatedAt.millisecondsSinceEpoch, 422 | 'is_synced': model.isSynced ? 1 : 0, 423 | 'sync_error': model.syncError, 424 | 'sync_attempts': model.syncAttempts, 425 | 'data': jsonEncode(model.toJson()), 426 | }; 427 | } 428 | 429 | T? _deserializeModel(Map row) { 430 | final modelType = row['model_type'] as String; 431 | final deserializer = _modelDeserializers[modelType]; 432 | 433 | if (deserializer == null) { 434 | throw StateError('No deserializer registered for model type: $modelType'); 435 | } 436 | 437 | final data = jsonDecode(row['data'] as String) as Map; 438 | 439 | return deserializer(data) as T; 440 | } 441 | } 442 | -------------------------------------------------------------------------------- /lib/src/network/default_network_client.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'package:http/http.dart' as http; 4 | import 'network_client.dart'; 5 | import 'rest_request.dart'; 6 | 7 | typedef EncryptionHandler = 8 | Map Function(Map data); 9 | typedef DecryptionHandler = 10 | Map Function(Map data); 11 | 12 | /// Default implementation of the NetworkClient interface using the http package 13 | class DefaultNetworkClient implements NetworkClient { 14 | /// Base URL for all requests 15 | final String baseUrl; 16 | 17 | /// HTTP client for making requests 18 | final http.Client _client; 19 | 20 | /// Default headers to apply to all requests 21 | final Map _defaultHeaders; 22 | 23 | /// Optional encryption handler to encrypt outgoing requests 24 | EncryptionHandler? _encryptionHandler; 25 | 26 | /// Optional decryption handler to decrypt incoming responses 27 | DecryptionHandler? _decryptionHandler; 28 | 29 | /// Creates a new NetworkClient instance 30 | /// 31 | /// Parameters: 32 | /// - [baseUrl]: The base URL for all requests 33 | /// - [client]: Optional custom HTTP client 34 | /// - [defaultHeaders]: Optional default headers to include in all requests 35 | DefaultNetworkClient({ 36 | required this.baseUrl, 37 | http.Client? client, 38 | Map? defaultHeaders, 39 | }) : _client = client ?? http.Client(), 40 | _defaultHeaders = 41 | defaultHeaders ?? 42 | {'Content-Type': 'application/json', 'Accept': 'application/json'}; 43 | 44 | /// Sets the encryption handler for outgoing requests 45 | /// 46 | /// [handler] The function to encrypt data before sending 47 | void setEncryptionHandler(EncryptionHandler handler) { 48 | _encryptionHandler = handler; 49 | } 50 | 51 | /// Sets the decryption handler for incoming responses 52 | /// 53 | /// [handler] The function to decrypt data after receiving 54 | void setDecryptionHandler(DecryptionHandler handler) { 55 | _decryptionHandler = handler; 56 | } 57 | 58 | /// Builds a URL from an endpoint and query parameters 59 | /// 60 | /// Parameters: 61 | /// - [endpoint]: The API endpoint 62 | /// - [queryParams]: Optional query parameters to include in the URL 63 | /// - [requestConfig]: Optional request configuration with URL parameters 64 | /// 65 | /// Returns the full URL as a string 66 | String _buildUrl( 67 | String endpoint, [ 68 | Map? queryParams, 69 | RestRequest? requestConfig, 70 | ]) { 71 | String finalUrl; 72 | 73 | // If we have a requestConfig with a URL, use that instead of building one 74 | if (requestConfig != null && requestConfig.url.isNotEmpty) { 75 | // Process URL parameters if any 76 | finalUrl = requestConfig.getProcessedUrl(); 77 | 78 | // Handle relative vs absolute URLs 79 | if (!finalUrl.startsWith('http://') && !finalUrl.startsWith('https://')) { 80 | final cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/'; 81 | finalUrl = 82 | finalUrl.startsWith('/') 83 | ? '$cleanBaseUrl${finalUrl.substring(1)}' 84 | : '$cleanBaseUrl$finalUrl'; 85 | } 86 | } else { 87 | // Use default URL building logic 88 | final cleanEndpoint = 89 | endpoint.startsWith('/') ? endpoint.substring(1) : endpoint; 90 | final cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/'; 91 | finalUrl = '$cleanBaseUrl$cleanEndpoint'; 92 | } 93 | 94 | // Add query parameters if any 95 | if (queryParams == null || queryParams.isEmpty) { 96 | return finalUrl; 97 | } 98 | 99 | final queryString = queryParams.entries 100 | .map( 101 | (e) => 102 | '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value.toString())}', 103 | ) 104 | .join('&'); 105 | 106 | return '$finalUrl${finalUrl.contains('?') ? '&' : '?'}$queryString'; 107 | } 108 | 109 | /// Processes the request body using a RestRequest configuration 110 | /// 111 | /// This applies any transformations specified in the RestRequest: 112 | /// - Wraps the body in a topLevelKey if specified 113 | /// - Adds supplemental top-level data if specified 114 | /// 115 | /// Parameters: 116 | /// - [body]: The original request body 117 | /// - [request]: RestRequest configuration to apply 118 | /// 119 | /// Returns the transformed request body 120 | Map? _processRequestBody( 121 | Map? body, 122 | RestRequest? request, 123 | ) { 124 | if (body == null) { 125 | return request?.supplementalTopLevelData; 126 | } 127 | 128 | Map processedBody; 129 | 130 | // Wrap the body in topLevelKey if specified 131 | if (request?.topLevelKey != null) { 132 | processedBody = {request!.topLevelKey!: body}; 133 | } else { 134 | processedBody = Map.from(body); 135 | } 136 | 137 | // Add supplemental top-level data if provided 138 | if (request?.supplementalTopLevelData != null) { 139 | processedBody.addAll(request!.supplementalTopLevelData!); 140 | } 141 | 142 | return processedBody; 143 | } 144 | 145 | /// Processes the response data using a RestRequest configuration 146 | /// 147 | /// This extracts data from the specified responseDataKey if provided 148 | /// 149 | /// Parameters: 150 | /// - [data]: The original response data 151 | /// - [request]: RestRequest configuration to apply 152 | /// 153 | /// Returns the processed response data 154 | dynamic _processResponseData(dynamic data, RestRequest? request) { 155 | if (data == null || request?.responseDataKey == null) { 156 | return data; 157 | } 158 | 159 | if (data is Map && 160 | data.containsKey(request!.responseDataKey!)) { 161 | return data[request.responseDataKey!]; 162 | } 163 | 164 | return data; 165 | } 166 | 167 | /// Sends a GET request to the specified endpoint 168 | /// 169 | /// Parameters: 170 | /// - [endpoint]: The API endpoint 171 | /// - [queryParameters]: Optional query parameters to include in the URL 172 | /// - [headers]: Optional headers to include in the request 173 | /// - [requestConfig]: Optional custom request configuration 174 | /// 175 | /// Returns a [NetworkResponse] with the result of the request 176 | @override 177 | Future get( 178 | String endpoint, { 179 | Map? queryParameters, 180 | Map? headers, 181 | RestRequest? requestConfig, 182 | }) async { 183 | final url = _buildUrl(endpoint, queryParameters, requestConfig); 184 | 185 | // Apply timeout if specified 186 | final timeout = 187 | requestConfig?.timeoutMillis != null 188 | ? Duration(milliseconds: requestConfig!.timeoutMillis!) 189 | : null; 190 | 191 | // Handle retries if configured 192 | int attemptCount = 0; 193 | final maxAttempts = (requestConfig?.retryCount ?? 0) + 1; 194 | 195 | while (true) { 196 | attemptCount++; 197 | try { 198 | final response = await _client 199 | .get( 200 | Uri.parse(url), 201 | headers: { 202 | ..._defaultHeaders, 203 | ...?headers, 204 | ...?requestConfig?.headers, 205 | }, 206 | ) 207 | .timeout(timeout ?? const Duration(seconds: 30)); 208 | 209 | final rawData = _parseResponseBody(response); 210 | 211 | // First process via basic response data handling 212 | var processedData = _processResponseData(rawData, requestConfig); 213 | 214 | // Then apply custom transformer if available 215 | if (requestConfig != null) { 216 | processedData = requestConfig.transformResponse(processedData); 217 | } 218 | 219 | return NetworkResponse( 220 | statusCode: response.statusCode, 221 | data: processedData, 222 | headers: response.headers, 223 | ); 224 | } catch (e) { 225 | // If we have retries left and this is a retryable error, try again 226 | if (attemptCount < maxAttempts) { 227 | continue; 228 | } 229 | // Otherwise, rethrow 230 | rethrow; 231 | } 232 | } 233 | } 234 | 235 | /// Sends a POST request to the specified endpoint 236 | /// 237 | /// Parameters: 238 | /// - [endpoint]: The API endpoint 239 | /// - [body]: Optional request body as a map 240 | /// - [headers]: Optional headers to include in the request 241 | /// - [requestConfig]: Optional custom request configuration 242 | /// 243 | /// Returns a [NetworkResponse] with the result of the request 244 | @override 245 | Future post( 246 | String endpoint, { 247 | Map? body, 248 | Map? headers, 249 | RestRequest? requestConfig, 250 | }) async { 251 | final url = requestConfig?.url ?? _buildUrl(endpoint); 252 | final processedBody = _processRequestBody(body, requestConfig); 253 | final bodyJson = 254 | processedBody != null 255 | ? jsonEncode(_encryptIfEnabled(processedBody)) 256 | : null; 257 | 258 | final response = await _client.post( 259 | Uri.parse(url), 260 | headers: {..._defaultHeaders, ...?headers, ...?requestConfig?.headers}, 261 | body: bodyJson, 262 | ); 263 | 264 | final rawData = _parseResponseBody(response); 265 | final data = _processResponseData(rawData, requestConfig); 266 | 267 | return NetworkResponse( 268 | statusCode: response.statusCode, 269 | data: data, 270 | headers: response.headers, 271 | ); 272 | } 273 | 274 | /// Sends a PUT request to the specified endpoint 275 | /// 276 | /// Parameters: 277 | /// - [endpoint]: The API endpoint 278 | /// - [body]: Optional request body as a map 279 | /// - [headers]: Optional headers to include in the request 280 | /// - [requestConfig]: Optional custom request configuration 281 | /// 282 | /// Returns a [NetworkResponse] with the result of the request 283 | @override 284 | Future put( 285 | String endpoint, { 286 | Map? body, 287 | Map? headers, 288 | RestRequest? requestConfig, 289 | }) async { 290 | final url = requestConfig?.url ?? _buildUrl(endpoint); 291 | final processedBody = _processRequestBody(body, requestConfig); 292 | final bodyJson = 293 | processedBody != null 294 | ? jsonEncode(_encryptIfEnabled(processedBody)) 295 | : null; 296 | 297 | final response = await _client.put( 298 | Uri.parse(url), 299 | headers: {..._defaultHeaders, ...?headers, ...?requestConfig?.headers}, 300 | body: bodyJson, 301 | ); 302 | 303 | final rawData = _parseResponseBody(response); 304 | final data = _processResponseData(rawData, requestConfig); 305 | 306 | return NetworkResponse( 307 | statusCode: response.statusCode, 308 | data: data, 309 | headers: response.headers, 310 | ); 311 | } 312 | 313 | /// Sends a PATCH request to the specified endpoint 314 | /// 315 | /// Parameters: 316 | /// - [endpoint]: The API endpoint 317 | /// - [body]: Optional request body as a map 318 | /// - [headers]: Optional headers to include in the request 319 | /// - [requestConfig]: Optional custom request configuration 320 | /// 321 | /// Returns a [NetworkResponse] with the result of the request 322 | @override 323 | Future patch( 324 | String endpoint, { 325 | Map? body, 326 | Map? headers, 327 | RestRequest? requestConfig, 328 | }) async { 329 | final url = requestConfig?.url ?? _buildUrl(endpoint); 330 | final processedBody = _processRequestBody(body, requestConfig); 331 | final bodyJson = 332 | processedBody != null 333 | ? jsonEncode(_encryptIfEnabled(processedBody)) 334 | : null; 335 | 336 | final response = await _client.patch( 337 | Uri.parse(url), 338 | headers: {..._defaultHeaders, ...?headers, ...?requestConfig?.headers}, 339 | body: bodyJson, 340 | ); 341 | 342 | final rawData = _parseResponseBody(response); 343 | final data = _processResponseData(rawData, requestConfig); 344 | 345 | return NetworkResponse( 346 | statusCode: response.statusCode, 347 | data: data, 348 | headers: response.headers, 349 | ); 350 | } 351 | 352 | /// Sends a DELETE request to the specified endpoint 353 | /// 354 | /// Parameters: 355 | /// - [endpoint]: The API endpoint 356 | /// - [headers]: Optional headers to include in the request 357 | /// - [requestConfig]: Optional custom request configuration 358 | /// 359 | /// Returns a [NetworkResponse] with the result of the request 360 | @override 361 | Future delete( 362 | String endpoint, { 363 | Map? headers, 364 | RestRequest? requestConfig, 365 | }) async { 366 | final url = requestConfig?.url ?? _buildUrl(endpoint); 367 | final response = await _client.delete( 368 | Uri.parse(url), 369 | headers: {..._defaultHeaders, ...?headers, ...?requestConfig?.headers}, 370 | ); 371 | 372 | final rawData = _parseResponseBody(response); 373 | final data = _processResponseData(rawData, requestConfig); 374 | 375 | return NetworkResponse( 376 | statusCode: response.statusCode, 377 | data: data, 378 | headers: response.headers, 379 | ); 380 | } 381 | 382 | /// Parses the response body as JSON if possible 383 | /// 384 | /// Parameters: 385 | /// - [response]: The HTTP response 386 | /// 387 | /// Returns the parsed data or null if parsing failed 388 | dynamic _parseResponseBody(http.Response response) { 389 | if (response.body.isEmpty) { 390 | return null; 391 | } 392 | 393 | try { 394 | final jsonData = jsonDecode(response.body); 395 | 396 | // Apply decryption if needed 397 | if (jsonData is Map && _decryptionHandler != null) { 398 | return _decryptIfNeeded(jsonData); 399 | } 400 | return jsonData; 401 | } catch (e) { 402 | return response.body; 403 | } 404 | } 405 | 406 | /// Encrypts data if an encryption handler is set 407 | /// 408 | /// [data] The data to encrypt 409 | /// Returns the encrypted data or original data if no handler is set 410 | Map _encryptIfEnabled(Map data) { 411 | if (_encryptionHandler != null) { 412 | return _encryptionHandler!(data); 413 | } 414 | return data; 415 | } 416 | 417 | /// Decrypts data if a decryption handler is set 418 | /// 419 | /// [data] The data to decrypt 420 | /// Returns the decrypted data or original data if no handler is set 421 | Map _decryptIfNeeded(Map data) { 422 | if (_decryptionHandler != null) { 423 | return _decryptionHandler!(data); 424 | } 425 | return data; 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /example/lib/screens/todo_detail_screen.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:offline_sync_kit/offline_sync_kit.dart'; 3 | import '../models/todo.dart'; 4 | 5 | class TodoDetailScreen extends StatefulWidget { 6 | final Todo todo; 7 | 8 | const TodoDetailScreen({super.key, required this.todo}); 9 | 10 | @override 11 | State createState() => _TodoDetailScreenState(); 12 | } 13 | 14 | class _TodoDetailScreenState extends State { 15 | late TextEditingController _titleController; 16 | late TextEditingController _descriptionController; 17 | late bool _isCompleted; 18 | late int _priority; 19 | late Todo _currentTodo; 20 | bool _useDeltaSync = true; // Delta sync enabled by default 21 | 22 | // Sync strategy selection for this specific todo 23 | SaveStrategy _saveStrategy = SaveStrategy.optimisticSave; 24 | FetchStrategy _fetchStrategy = FetchStrategy.backgroundSync; 25 | DeleteStrategy _deleteStrategy = DeleteStrategy.optimisticDelete; 26 | 27 | @override 28 | void initState() { 29 | super.initState(); 30 | _currentTodo = widget.todo; 31 | _titleController = TextEditingController(text: _currentTodo.title); 32 | _descriptionController = TextEditingController( 33 | text: _currentTodo.description, 34 | ); 35 | _isCompleted = _currentTodo.isCompleted; 36 | _priority = _currentTodo.priority; 37 | 38 | // Initialize with any existing strategies from the model 39 | _saveStrategy = _currentTodo.saveStrategy ?? SaveStrategy.optimisticSave; 40 | _fetchStrategy = _currentTodo.fetchStrategy ?? FetchStrategy.backgroundSync; 41 | _deleteStrategy = 42 | _currentTodo.deleteStrategy ?? DeleteStrategy.optimisticDelete; 43 | } 44 | 45 | @override 46 | void dispose() { 47 | _titleController.dispose(); 48 | _descriptionController.dispose(); 49 | super.dispose(); 50 | } 51 | 52 | Future _saveTodo() async { 53 | try { 54 | if (_useDeltaSync) { 55 | // Update only changed fields using delta sync 56 | Todo updatedTodo = _currentTodo; 57 | 58 | // Only update fields that have changed 59 | if (_titleController.text != _currentTodo.title) { 60 | updatedTodo = updatedTodo.updateTitle(_titleController.text); 61 | } 62 | 63 | if (_descriptionController.text != _currentTodo.description) { 64 | updatedTodo = updatedTodo.updateDescription( 65 | _descriptionController.text, 66 | ); 67 | } 68 | 69 | if (_isCompleted != _currentTodo.isCompleted) { 70 | updatedTodo = updatedTodo.updateCompletionStatus(_isCompleted); 71 | } 72 | 73 | if (_priority != _currentTodo.priority) { 74 | updatedTodo = updatedTodo.updatePriority(_priority); 75 | } 76 | 77 | // Apply the selected sync strategies to this todo 78 | updatedTodo = updatedTodo.withCustomSyncStrategies( 79 | saveStrategy: _saveStrategy, 80 | fetchStrategy: _fetchStrategy, 81 | deleteStrategy: _deleteStrategy, 82 | ); 83 | 84 | if (updatedTodo.hasChanges) { 85 | // Save only changed fields 86 | await OfflineSyncManager.instance.updateModel(updatedTodo); 87 | 88 | // Sync using delta sync 89 | await OfflineSyncManager.instance.syncItemDelta(updatedTodo); 90 | 91 | if (!mounted) return; 92 | ScaffoldMessenger.of(context).showSnackBar( 93 | const SnackBar(content: Text('Changed fields saved (Delta)')), 94 | ); 95 | } else { 96 | if (!mounted) return; 97 | ScaffoldMessenger.of( 98 | context, 99 | ).showSnackBar(const SnackBar(content: Text('No fields changed'))); 100 | } 101 | } else { 102 | // Update the entire model - standard method 103 | final updatedTodo = _currentTodo.copyWith( 104 | title: _titleController.text, 105 | description: _descriptionController.text, 106 | isCompleted: _isCompleted, 107 | priority: _priority, 108 | updatedAt: DateTime.now(), 109 | isSynced: false, 110 | // Apply the selected sync strategies 111 | saveStrategy: _saveStrategy, 112 | fetchStrategy: _fetchStrategy, 113 | deleteStrategy: _deleteStrategy, 114 | ); 115 | 116 | await OfflineSyncManager.instance.updateModel(updatedTodo); 117 | 118 | if (!mounted) return; 119 | ScaffoldMessenger.of( 120 | context, 121 | ).showSnackBar(const SnackBar(content: Text('Todo saved'))); 122 | } 123 | 124 | // Pull latest data from server - using the model's fetch strategy 125 | await OfflineSyncManager.instance.pullFromServer('todo'); 126 | 127 | if (!mounted) return; 128 | Navigator.pop(context); 129 | } catch (e) { 130 | debugPrint('Error updating todo: $e'); 131 | if (!mounted) return; 132 | ScaffoldMessenger.of( 133 | context, 134 | ).showSnackBar(SnackBar(content: Text('Error: $e'))); 135 | } 136 | } 137 | 138 | Future _deleteTodo() async { 139 | try { 140 | // Create a copy with the selected delete strategy 141 | final todoToDelete = _currentTodo.withCustomSyncStrategies( 142 | deleteStrategy: _deleteStrategy, 143 | ); 144 | 145 | // Delete using OfflineSyncManager's deleteModel method instead 146 | await OfflineSyncManager.instance 147 | .deleteModel(todoToDelete.id, todoToDelete.modelType); 148 | 149 | if (!mounted) return; 150 | Navigator.pop(context); 151 | } catch (e) { 152 | debugPrint('Error deleting todo: $e'); 153 | if (!mounted) return; 154 | ScaffoldMessenger.of( 155 | context, 156 | ).showSnackBar(SnackBar(content: Text('Error: $e'))); 157 | } 158 | } 159 | 160 | // Example of finding a specific Todo using Query 161 | // This method is added to demonstrate usage 162 | Future _loadTodoWithQuery() async { 163 | try { 164 | // Method 1: Direct ID lookup using standard API 165 | final todoFromStandardApi = await OfflineSyncManager.instance 166 | .getModel(_currentTodo.id, 'todo'); 167 | 168 | if (todoFromStandardApi != null) { 169 | debugPrint( 170 | 'Found todo using standard API: ${todoFromStandardApi.title}'); 171 | } 172 | 173 | // Method 2: Using the Query API (much more powerful) 174 | // Create Query for ID lookup 175 | final query = Query.exact('id', _currentTodo.id); 176 | 177 | // Use the new Query-based API 178 | final results = 179 | await OfflineSyncManager.instance.getModelsWithQuery( 180 | 'todo', 181 | query: query, 182 | ); 183 | 184 | final todoFromQuery = results.isNotEmpty ? results.first : null; 185 | 186 | if (todoFromQuery != null) { 187 | setState(() { 188 | _currentTodo = todoFromQuery; 189 | _titleController.text = todoFromQuery.title; 190 | _descriptionController.text = todoFromQuery.description; 191 | _isCompleted = todoFromQuery.isCompleted; 192 | _priority = todoFromQuery.priority; 193 | }); 194 | if (!mounted) return; 195 | ScaffoldMessenger.of(context).showSnackBar( 196 | SnackBar(content: Text('Todo loaded with Query API')), 197 | ); 198 | } 199 | } catch (e) { 200 | debugPrint('Error loading todo with query: $e'); 201 | if (!mounted) return; 202 | ScaffoldMessenger.of(context).showSnackBar( 203 | SnackBar(content: Text('Error: $e')), 204 | ); 205 | } 206 | } 207 | 208 | @override 209 | Widget build(BuildContext context) { 210 | return Scaffold( 211 | appBar: AppBar( 212 | title: const Text('Todo Details'), 213 | actions: [ 214 | IconButton( 215 | icon: const Icon(Icons.search), 216 | tooltip: 'Load with Query', 217 | onPressed: _loadTodoWithQuery, 218 | ), 219 | IconButton( 220 | icon: const Icon(Icons.delete), 221 | onPressed: () => _showDeleteConfirmation(context), 222 | ), 223 | ], 224 | ), 225 | body: SingleChildScrollView( 226 | child: Padding( 227 | padding: const EdgeInsets.all(16.0), 228 | child: Column( 229 | crossAxisAlignment: CrossAxisAlignment.start, 230 | children: [ 231 | TextField( 232 | controller: _titleController, 233 | decoration: const InputDecoration( 234 | labelText: 'Title', 235 | border: OutlineInputBorder(), 236 | ), 237 | ), 238 | const SizedBox(height: 16), 239 | TextField( 240 | controller: _descriptionController, 241 | decoration: const InputDecoration( 242 | labelText: 'Description', 243 | border: OutlineInputBorder(), 244 | ), 245 | maxLines: 5, 246 | ), 247 | const SizedBox(height: 16), 248 | Row( 249 | children: [ 250 | const Text('Status: '), 251 | Switch( 252 | value: _isCompleted, 253 | onChanged: (value) { 254 | setState(() { 255 | _isCompleted = value; 256 | }); 257 | }, 258 | ), 259 | Text(_isCompleted ? 'Completed' : 'Pending'), 260 | ], 261 | ), 262 | const SizedBox(height: 16), 263 | Row( 264 | children: [ 265 | const Text('Priority: '), 266 | Slider( 267 | value: _priority.toDouble(), 268 | min: 1, 269 | max: 5, 270 | divisions: 4, 271 | label: _priority.toString(), 272 | onChanged: (value) { 273 | setState(() { 274 | _priority = value.toInt(); 275 | }); 276 | }, 277 | ), 278 | Text(_priority.toString()), 279 | ], 280 | ), 281 | const SizedBox(height: 16), 282 | _buildSyncOptions(), 283 | const SizedBox(height: 16), 284 | SwitchListTile( 285 | title: const Text('Delta Synchronization'), 286 | subtitle: const Text('Only send changed fields (faster)'), 287 | value: _useDeltaSync, 288 | onChanged: (value) { 289 | setState(() { 290 | _useDeltaSync = value; 291 | }); 292 | }, 293 | ), 294 | const SizedBox(height: 16), 295 | _buildSyncStatus(), 296 | ], 297 | ), 298 | ), 299 | ), 300 | floatingActionButton: FloatingActionButton( 301 | onPressed: _saveTodo, 302 | tooltip: 'Save', 303 | child: const Icon(Icons.save), 304 | ), 305 | ); 306 | } 307 | 308 | Widget _buildSyncOptions() { 309 | return Card( 310 | child: Padding( 311 | padding: const EdgeInsets.all(12.0), 312 | child: Column( 313 | crossAxisAlignment: CrossAxisAlignment.start, 314 | children: [ 315 | Text( 316 | 'Model-Level Sync Strategies', 317 | style: Theme.of(context).textTheme.titleMedium, 318 | ), 319 | const SizedBox(height: 8), 320 | 321 | // Save Strategy Selection 322 | const Text('Save Strategy:'), 323 | DropdownButton( 324 | value: _saveStrategy, 325 | isExpanded: true, 326 | onChanged: (SaveStrategy? newValue) { 327 | if (newValue != null) { 328 | setState(() { 329 | _saveStrategy = newValue; 330 | }); 331 | } 332 | }, 333 | items: SaveStrategy.values.map((SaveStrategy strategy) { 334 | return DropdownMenuItem( 335 | value: strategy, 336 | child: Text(_getSaveStrategyName(strategy)), 337 | ); 338 | }).toList(), 339 | ), 340 | 341 | const SizedBox(height: 8), 342 | 343 | // Fetch Strategy Selection 344 | const Text('Fetch Strategy:'), 345 | DropdownButton( 346 | value: _fetchStrategy, 347 | isExpanded: true, 348 | onChanged: (FetchStrategy? newValue) { 349 | if (newValue != null) { 350 | setState(() { 351 | _fetchStrategy = newValue; 352 | }); 353 | } 354 | }, 355 | items: FetchStrategy.values.map((FetchStrategy strategy) { 356 | return DropdownMenuItem( 357 | value: strategy, 358 | child: Text(_getFetchStrategyName(strategy)), 359 | ); 360 | }).toList(), 361 | ), 362 | 363 | const SizedBox(height: 8), 364 | 365 | // Delete Strategy Selection 366 | const Text('Delete Strategy:'), 367 | DropdownButton( 368 | value: _deleteStrategy, 369 | isExpanded: true, 370 | onChanged: (DeleteStrategy? newValue) { 371 | if (newValue != null) { 372 | setState(() { 373 | _deleteStrategy = newValue; 374 | }); 375 | } 376 | }, 377 | items: DeleteStrategy.values.map((DeleteStrategy strategy) { 378 | return DropdownMenuItem( 379 | value: strategy, 380 | child: Text(_getDeleteStrategyName(strategy)), 381 | ); 382 | }).toList(), 383 | ), 384 | ], 385 | ), 386 | ), 387 | ); 388 | } 389 | 390 | String _getSaveStrategyName(SaveStrategy strategy) { 391 | switch (strategy) { 392 | case SaveStrategy.optimisticSave: 393 | return 'Optimistic Save (Local First)'; 394 | case SaveStrategy.waitForRemote: 395 | return 'Wait For Remote (Server First)'; 396 | } 397 | } 398 | 399 | String _getFetchStrategyName(FetchStrategy strategy) { 400 | switch (strategy) { 401 | case FetchStrategy.backgroundSync: 402 | return 'Background Sync'; 403 | case FetchStrategy.remoteFirst: 404 | return 'Remote First'; 405 | case FetchStrategy.localWithRemoteFallback: 406 | return 'Local with Remote Fallback'; 407 | case FetchStrategy.localOnly: 408 | return 'Local Only'; 409 | } 410 | } 411 | 412 | String _getDeleteStrategyName(DeleteStrategy strategy) { 413 | switch (strategy) { 414 | case DeleteStrategy.optimisticDelete: 415 | return 'Optimistic Delete (Local First)'; 416 | case DeleteStrategy.waitForRemote: 417 | return 'Wait For Remote (Server First)'; 418 | } 419 | } 420 | 421 | Widget _buildSyncStatus() { 422 | final textStyle = Theme.of(context).textTheme.bodyMedium; 423 | 424 | return Card( 425 | child: Padding( 426 | padding: const EdgeInsets.all(16.0), 427 | child: Column( 428 | crossAxisAlignment: CrossAxisAlignment.start, 429 | children: [ 430 | Text( 431 | 'Synchronization Status', 432 | style: Theme.of(context).textTheme.titleMedium, 433 | ), 434 | const SizedBox(height: 8), 435 | Row( 436 | children: [ 437 | Icon( 438 | _currentTodo.isSynced 439 | ? Icons.check_circle 440 | : Icons.sync_problem, 441 | color: _currentTodo.isSynced ? Colors.green : Colors.orange, 442 | ), 443 | const SizedBox(width: 8), 444 | Text( 445 | _currentTodo.isSynced ? 'Synced' : 'Not Synced', 446 | style: textStyle, 447 | ), 448 | ], 449 | ), 450 | const SizedBox(height: 4), 451 | Text( 452 | 'Created: ${_formatDate(_currentTodo.createdAt)}', 453 | style: textStyle, 454 | ), 455 | Text( 456 | 'Updated: ${_formatDate(_currentTodo.updatedAt)}', 457 | style: textStyle, 458 | ), 459 | if (_currentTodo.hasChanges) 460 | Row( 461 | children: [ 462 | const Icon(Icons.edit, size: 16, color: Colors.blue), 463 | const SizedBox(width: 4), 464 | Text( 465 | 'Changed fields: ${_currentTodo.changedFields.join(", ")}', 466 | style: textStyle?.copyWith(color: Colors.blue), 467 | ), 468 | ], 469 | ), 470 | if (_currentTodo.syncError.isNotEmpty) 471 | Text( 472 | 'Error: ${_currentTodo.syncError}', 473 | style: textStyle?.copyWith(color: Colors.red), 474 | ), 475 | if (_currentTodo.syncAttempts > 0) 476 | Text( 477 | 'Sync attempts: ${_currentTodo.syncAttempts}', 478 | style: textStyle, 479 | ), 480 | if (_currentTodo.saveStrategy != null || 481 | _currentTodo.fetchStrategy != null || 482 | _currentTodo.deleteStrategy != null) 483 | const Divider(), 484 | if (_currentTodo.saveStrategy != null) 485 | Text( 486 | 'Save Strategy: ${_getSaveStrategyName(_currentTodo.saveStrategy!)}', 487 | style: textStyle?.copyWith(fontWeight: FontWeight.bold), 488 | ), 489 | if (_currentTodo.fetchStrategy != null) 490 | Text( 491 | 'Fetch Strategy: ${_getFetchStrategyName(_currentTodo.fetchStrategy!)}', 492 | style: textStyle?.copyWith(fontWeight: FontWeight.bold), 493 | ), 494 | if (_currentTodo.deleteStrategy != null) 495 | Text( 496 | 'Delete Strategy: ${_getDeleteStrategyName(_currentTodo.deleteStrategy!)}', 497 | style: textStyle?.copyWith(fontWeight: FontWeight.bold), 498 | ), 499 | ], 500 | ), 501 | ), 502 | ); 503 | } 504 | 505 | void _showDeleteConfirmation(BuildContext context) { 506 | showDialog( 507 | context: context, 508 | builder: (ctx) => AlertDialog( 509 | title: const Text('Delete Todo'), 510 | content: const Text('Are you sure you want to delete this item?'), 511 | actions: [ 512 | TextButton( 513 | onPressed: () => Navigator.of(ctx).pop(), 514 | child: const Text('Cancel'), 515 | ), 516 | TextButton( 517 | onPressed: () { 518 | Navigator.of(ctx).pop(); 519 | _deleteTodo(); 520 | }, 521 | child: const Text('Delete'), 522 | ), 523 | ], 524 | ), 525 | ); 526 | } 527 | 528 | String _formatDate(DateTime date) { 529 | return '${date.day}/${date.month}/${date.year} ${date.hour}:${date.minute}'; 530 | } 531 | } 532 | --------------------------------------------------------------------------------