Offline-First Implementation Guide
Panduan lengkap step-by-step untuk implementasi offline-first architecture di Flutter menggunakan Isar Database.šÆ Prerequisites
Sebelum mulai, pastikan Anda sudah familiar dengan:- ā Flutter & Dart basics
- ā State management (BLoC/Provider/Riverpod)
- ā REST API integration
- ā Local database concepts
š¦ Step 1: Setup Dependencies
pubspec.yaml
Copy
name: mstore_mobile
description: MStore POS Mobile App
dependencies:
flutter:
sdk: flutter
# Local Database
isar: ^3.1.0+1
isar_flutter_libs: ^3.1.0+1
path_provider: ^2.1.1
# Networking
dio: ^5.4.0
retrofit: ^4.0.3
# Utilities
uuid: ^4.2.1
connectivity_plus: ^5.0.2
# State Management (pilih salah satu)
flutter_bloc: ^8.1.3
# atau
provider: ^6.1.1
# atau
riverpod: ^2.4.9
dev_dependencies:
flutter_test:
sdk: flutter
# Code Generation
isar_generator: ^3.1.0+1
build_runner: ^2.4.7
retrofit_generator: ^8.0.4
# Testing
mockito: ^5.4.4
Generate Code
Copy
# Install dependencies
flutter pub get
# Generate Isar collections
flutter pub run build_runner build --delete-conflicting-outputs
šļø Step 2: Define Isar Collections
Transaction Collection
File:lib/data/local/models/transaction_local.dart
Copy
import 'package:isar/isar.dart';
part 'transaction_local.g.dart';
@collection
class TransactionLocal {
Id id = Isar.autoIncrement;
// Offline identifiers
@Index(unique: true)
late String offlineId;
@Index(unique: true)
late String offlineReference;
late String deviceId;
late DateTime createdAtDevice;
// Transaction data
late String branchCode;
late String paymentMethod;
late int idUserApps;
// Amounts
late double subtotal;
late double discountTotal;
late double taxTotal;
late double grandTotal;
// Status
late String status;
late String paymentStatus;
@enumerated
late SyncStatus syncStatus;
// Server data (after sync)
int? serverId;
String? transactionCode;
DateTime? syncedAt;
String? syncError;
// Timestamps
late DateTime createdAt;
DateTime? updatedAt;
// Items (embedded)
List<TransactionItemLocal> items = [];
}
@embedded
class TransactionItemLocal {
late int productId;
late String productName;
late int quantity;
late double unitPrice;
late double discount;
late double tax;
late double subtotal;
String? notes;
}
enum SyncStatus {
pending, // Belum sync
syncing, // Sedang sync
synced, // Sudah sync berhasil
failed // Sync gagal
}
Sync Queue Collection
File:lib/data/local/models/sync_queue_local.dart
Copy
import 'package:isar/isar.dart';
part 'sync_queue_local.g.dart';
@collection
class SyncQueueLocal {
Id id = Isar.autoIncrement;
@Index()
late String offlineId;
@enumerated
late SyncType type;
@enumerated
late SyncStatus status;
late String payloadJson;
late int retryCount;
String? lastError;
late DateTime createdAt;
DateTime? lastAttemptAt;
}
enum SyncType {
createTransaction,
updateTransaction,
voidTransaction,
createPayment,
createVoid
}
š§ Step 3: Initialize Isar Database
Database Service
File:lib/data/local/database_service.dart
Copy
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import 'models/transaction_local.dart';
import 'models/sync_queue_local.dart';
class DatabaseService {
static Isar? _isar;
static Future<Isar> initialize() async {
if (_isar != null) return _isar!;
final dir = await getApplicationDocumentsDirectory();
_isar = await Isar.open(
[
TransactionLocalSchema,
SyncQueueLocalSchema,
],
directory: dir.path,
name: 'mstore_offline_db',
inspector: true, // Enable Isar Inspector for debugging
);
print('[DatabaseService] Isar initialized at: ${dir.path}');
return _isar!;
}
static Isar get instance {
if (_isar == null) {
throw Exception('Database not initialized. Call initialize() first.');
}
return _isar!;
}
static Future<void> close() async {
await _isar?.close();
_isar = null;
}
}
Initialize in main.dart
File:lib/main.dart
Copy
import 'package:flutter/material.dart';
import 'data/local/database_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Isar Database
await DatabaseService.initialize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'MStore POS',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
š Step 4: Setup API Client
API Models
File:lib/data/remote/models/batch_sync_request.dart
Copy
import 'package:json_annotation/json_annotation.dart';
part 'batch_sync_request.g.dart';
@JsonSerializable()
class BatchSyncRequest {
final List<OfflineTransactionDto> transactions;
final List<OfflinePaymentDto> payments;
final List<OfflineVoidDto> voids;
@JsonKey(name: 'device_id')
final String deviceId;
@JsonKey(name: 'synced_at')
final DateTime syncedAt;
BatchSyncRequest({
required this.transactions,
required this.payments,
required this.voids,
required this.deviceId,
required this.syncedAt,
});
factory BatchSyncRequest.fromJson(Map<String, dynamic> json) =>
_$BatchSyncRequestFromJson(json);
Map<String, dynamic> toJson() => _$BatchSyncRequestToJson(this);
}
@JsonSerializable()
class OfflineTransactionDto {
@JsonKey(name: 'offline_id')
final String offlineId;
@JsonKey(name: 'offline_reference')
final String offlineReference;
@JsonKey(name: 'device_id')
final String deviceId;
@JsonKey(name: 'created_at_device')
final DateTime createdAtDevice;
@JsonKey(name: 'branch_code')
final String branchCode;
@JsonKey(name: 'payment_method')
final String paymentMethod;
@JsonKey(name: 'id_user_apps')
final int idUserApps;
final double subtotal;
@JsonKey(name: 'discount_total')
final double discountTotal;
@JsonKey(name: 'tax_total')
final double taxTotal;
@JsonKey(name: 'grand_total')
final double grandTotal;
final String status;
@JsonKey(name: 'payment_status')
final String paymentStatus;
OfflineTransactionDto({
required this.offlineId,
required this.offlineReference,
required this.deviceId,
required this.createdAtDevice,
required this.branchCode,
required this.paymentMethod,
required this.idUserApps,
required this.subtotal,
required this.discountTotal,
required this.taxTotal,
required this.grandTotal,
required this.status,
required this.paymentStatus,
});
factory OfflineTransactionDto.fromJson(Map<String, dynamic> json) =>
_$OfflineTransactionDtoFromJson(json);
Map<String, dynamic> toJson() => _$OfflineTransactionDtoToJson(this);
}
API Service (Retrofit)
File:lib/data/remote/api_service.dart
Copy
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
import 'models/batch_sync_request.dart';
import 'models/batch_sync_response.dart';
part 'api_service.g.dart';
@RestApi(baseUrl: 'http://localhost:3002/api/v1')
abstract class ApiService {
factory ApiService(Dio dio, {String baseUrl}) = _ApiService;
@POST('/pos/offline/batch-sync')
Future<BatchSyncResponse> batchSync(
@Body() BatchSyncRequest request,
);
@POST('/pos/offline/conflicts/{conflictId}/resolve')
Future<ConflictResolutionResponse> resolveConflict(
@Path('conflictId') String conflictId,
@Body() Map<String, String> strategy,
);
}
š¾ Step 5: Create Transaction Service
Transaction Service
File:lib/domain/services/transaction_service.dart
Copy
import 'package:uuid/uuid.dart';
import 'package:isar/isar.dart';
import '../../data/local/database_service.dart';
import '../../data/local/models/transaction_local.dart';
class TransactionService {
final Isar _isar = DatabaseService.instance;
final _uuid = const Uuid();
/// Create transaction offline-first
Future<TransactionLocal> createTransaction({
required String branchCode,
required String paymentMethod,
required int idUserApps,
required List<TransactionItemData> items,
required double grandTotal,
}) async {
// 1. Generate offline identifiers
final offlineId = _uuid.v4();
final deviceId = await _getDeviceId();
final sequence = await _getNextSequence();
final date = DateTime.now();
final dateStr = '${date.year}${date.month.toString().padLeft(2, '0')}${date.day.toString().padLeft(2, '0')}';
final offlineReference = '$deviceId-$dateStr-${sequence.toString().padLeft(3, '0')}';
// 2. Calculate totals
double subtotal = 0;
double taxTotal = 0;
for (var item in items) {
subtotal += item.subtotal;
taxTotal += item.tax;
}
// 3. Create transaction object
final transaction = TransactionLocal()
..offlineId = offlineId
..offlineReference = offlineReference
..deviceId = deviceId
..createdAtDevice = date
..branchCode = branchCode
..paymentMethod = paymentMethod
..idUserApps = idUserApps
..subtotal = subtotal
..discountTotal = 0
..taxTotal = taxTotal
..grandTotal = grandTotal
..status = 'paid'
..paymentStatus = 'paid'
..syncStatus = SyncStatus.pending
..createdAt = date
..items = items.map((item) => TransactionItemLocal()
..productId = item.productId
..productName = item.productName
..quantity = item.quantity
..unitPrice = item.unitPrice
..discount = item.discount
..tax = item.tax
..subtotal = item.subtotal
..notes = item.notes
).toList();
// 4. Save to Isar (OFFLINE FIRST)
await _isar.writeTxn(() async {
await _isar.transactionLocals.put(transaction);
});
print('[TransactionService] Transaction created: ${transaction.offlineReference}');
// 5. Add to sync queue
await _addToSyncQueue(transaction);
// 6. Trigger background sync (non-blocking)
_triggerBackgroundSync();
// 7. Return immediately
return transaction;
}
/// Get device ID (from SharedPreferences or generate new)
Future<String> _getDeviceId() async {
// TODO: Implement proper device ID management
return 'DEVICE001';
}
/// Get next sequence number for today
Future<int> _getNextSequence() async {
final today = DateTime.now();
final startOfDay = DateTime(today.year, today.month, today.day);
final endOfDay = startOfDay.add(const Duration(days: 1));
final count = await _isar.transactionLocals
.filter()
.createdAtBetween(startOfDay, endOfDay)
.count();
return count + 1;
}
/// Add transaction to sync queue
Future<void> _addToSyncQueue(TransactionLocal transaction) async {
final queueItem = SyncQueueLocal()
..offlineId = transaction.offlineId
..type = SyncType.createTransaction
..status = SyncStatus.pending
..payloadJson = '' // Will be serialized during sync
..retryCount = 0
..createdAt = DateTime.now();
await _isar.writeTxn(() async {
await _isar.syncQueueLocals.put(queueItem);
});
}
/// Trigger background sync (non-blocking)
void _triggerBackgroundSync() {
// This will be handled by SyncService
print('[TransactionService] Background sync triggered');
}
}
class TransactionItemData {
final int productId;
final String productName;
final int quantity;
final double unitPrice;
final double discount;
final double tax;
final double subtotal;
final String? notes;
TransactionItemData({
required this.productId,
required this.productName,
required this.quantity,
required this.unitPrice,
this.discount = 0,
this.tax = 0,
required this.subtotal,
this.notes,
});
}
š Step 6: Implement Background Sync
Sync Service
File:lib/domain/services/sync_service.dart
Copy
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:isar/isar.dart';
import '../../data/local/database_service.dart';
import '../../data/local/models/transaction_local.dart';
import '../../data/local/models/sync_queue_local.dart';
import '../../data/remote/api_service.dart';
import '../../data/remote/models/batch_sync_request.dart';
class SyncService {
final Isar _isar = DatabaseService.instance;
final ApiService _apiService;
final Connectivity _connectivity = Connectivity();
Timer? _syncTimer;
bool _isSyncing = false;
SyncService(this._apiService);
/// Start periodic sync (every 30 seconds)
void startPeriodicSync() {
_syncTimer = Timer.periodic(const Duration(seconds: 30), (_) {
sync();
});
// Also sync when connectivity restored
_connectivity.onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) {
print('[SyncService] Connectivity restored, triggering sync');
sync();
}
});
print('[SyncService] Periodic sync started');
}
/// Stop periodic sync
void stopPeriodicSync() {
_syncTimer?.cancel();
_syncTimer = null;
print('[SyncService] Periodic sync stopped');
}
/// Manual sync
Future<void> sync() async {
if (_isSyncing) {
print('[SyncService] Sync already in progress, skipping');
return;
}
// Check connectivity
final connectivity = await _connectivity.checkConnectivity();
if (connectivity == ConnectivityResult.none) {
print('[SyncService] Offline, skipping sync');
return;
}
_isSyncing = true;
try {
// Get pending transactions
final pendingTransactions = await _isar.transactionLocals
.filter()
.syncStatusEqualTo(SyncStatus.pending)
.limit(50)
.findAll();
if (pendingTransactions.isEmpty) {
print('[SyncService] No pending transactions');
return;
}
print('[SyncService] Syncing ${pendingTransactions.length} transactions');
// Prepare batch sync request
final request = BatchSyncRequest(
transactions: pendingTransactions.map((tx) => OfflineTransactionDto(
offlineId: tx.offlineId,
offlineReference: tx.offlineReference,
deviceId: tx.deviceId,
createdAtDevice: tx.createdAtDevice,
branchCode: tx.branchCode,
paymentMethod: tx.paymentMethod,
idUserApps: tx.idUserApps,
subtotal: tx.subtotal,
discountTotal: tx.discountTotal,
taxTotal: tx.taxTotal,
grandTotal: tx.grandTotal,
status: tx.status,
paymentStatus: tx.paymentStatus,
)).toList(),
payments: [],
voids: [],
deviceId: await _getDeviceId(),
syncedAt: DateTime.now(),
);
// Call batch sync API
final response = await _apiService.batchSync(request);
// Process results
await _isar.writeTxn(() async {
for (final result in response.results) {
final tx = pendingTransactions.firstWhere(
(t) => t.offlineId == result.offlineId,
);
if (result.success) {
tx.syncStatus = SyncStatus.synced;
tx.serverId = result.serverId;
tx.transactionCode = result.transactionCode;
tx.syncedAt = DateTime.now();
tx.syncError = null;
} else {
tx.syncStatus = SyncStatus.failed;
tx.syncError = result.error;
}
await _isar.transactionLocals.put(tx);
}
});
print('[SyncService] Sync completed: ${response.successCount} success, ${response.failedCount} failed');
// Handle conflicts
if (response.conflicts.isNotEmpty) {
print('[SyncService] Conflicts detected: ${response.conflicts.length}');
_showConflictNotification(response.conflicts);
}
} catch (e) {
print('[SyncService] Sync error: $e');
} finally {
_isSyncing = false;
}
}
Future<String> _getDeviceId() async {
return 'DEVICE001';
}
void _showConflictNotification(List<dynamic> conflicts) {
// TODO: Show notification to user
print('[SyncService] Please resolve conflicts in settings');
}
}
šØ Step 7: Create UI Components
Transaction List Screen
File:lib/presentation/screens/transaction_list_screen.dart
Copy
import 'package:flutter/material.dart';
import 'package:isar/isar.dart';
import '../../data/local/database_service.dart';
import '../../data/local/models/transaction_local.dart';
class TransactionListScreen extends StatelessWidget {
const TransactionListScreen({super.key});
@override
Widget build(BuildContext context) {
final isar = DatabaseService.instance;
return Scaffold(
appBar: AppBar(
title: const Text('Transactions'),
actions: [
IconButton(
icon: const Icon(Icons.sync),
onPressed: () {
// Trigger manual sync
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Syncing...')),
);
},
),
],
),
body: StreamBuilder<List<TransactionLocal>>(
stream: isar.transactionLocals
.where()
.sortByCreatedAtDesc()
.watch(fireImmediately: true),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final transactions = snapshot.data!;
if (transactions.isEmpty) {
return const Center(
child: Text('No transactions yet'),
);
}
return ListView.builder(
itemCount: transactions.length,
itemBuilder: (context, index) {
final tx = transactions[index];
return TransactionTile(transaction: tx);
},
);
},
),
);
}
}
class TransactionTile extends StatelessWidget {
final TransactionLocal transaction;
const TransactionTile({super.key, required this.transaction});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: _buildSyncStatusIcon(),
title: Text(
transaction.transactionCode ?? transaction.offlineReference,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Rp ${transaction.grandTotal.toStringAsFixed(0)}'),
Text(
transaction.status.toUpperCase(),
style: TextStyle(
color: transaction.status == 'paid' ? Colors.green : Colors.orange,
fontSize: 12,
),
),
],
),
trailing: _buildSyncStatusBadge(),
),
);
}
Widget _buildSyncStatusIcon() {
switch (transaction.syncStatus) {
case SyncStatus.pending:
return const Icon(Icons.cloud_upload, color: Colors.orange);
case SyncStatus.syncing:
return const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
);
case SyncStatus.synced:
return const Icon(Icons.cloud_done, color: Colors.green);
case SyncStatus.failed:
return const Icon(Icons.cloud_off, color: Colors.red);
}
}
Widget _buildSyncStatusBadge() {
String text;
Color color;
switch (transaction.syncStatus) {
case SyncStatus.pending:
text = 'Pending';
color = Colors.orange;
break;
case SyncStatus.syncing:
text = 'Syncing';
color = Colors.blue;
break;
case SyncStatus.synced:
text = 'Synced';
color = Colors.green;
break;
case SyncStatus.failed:
text = 'Failed';
color = Colors.red;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color),
),
child: Text(
text,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
);
}
}
ā Step 8: Testing
Unit Test
File:test/services/transaction_service_test.dart
Copy
import 'package:flutter_test/flutter_test.dart';
import 'package:isar/isar.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
void main() {
group('TransactionService', () {
late Isar isar;
late TransactionService service;
setUp(() async {
// Setup test database
isar = await Isar.open(
[TransactionLocalSchema],
directory: '',
name: 'test_db',
);
service = TransactionService();
});
tearDown(() async {
await isar.close(deleteFromDisk: true);
});
test('should create transaction offline', () async {
// Arrange
final items = [
TransactionItemData(
productId: 1,
productName: 'Product A',
quantity: 2,
unitPrice: 15000,
subtotal: 30000,
),
];
// Act
final transaction = await service.createTransaction(
branchCode: 'BRN-001',
paymentMethod: 'CASH',
idUserApps: 1,
items: items,
grandTotal: 30000,
);
// Assert
expect(transaction.offlineId, isNotEmpty);
expect(transaction.offlineReference, isNotEmpty);
expect(transaction.syncStatus, SyncStatus.pending);
expect(transaction.grandTotal, 30000);
});
});
}
š Summary
Anda telah berhasil mengimplementasikan offline-first architecture dengan: ā Isar Database untuk local storage ā Transaction Service untuk create transactions offline ā Sync Service untuk background sync ā API Integration dengan Retrofit ā UI Components dengan real-time updates ā Testing setupš Next Steps
- Implement Conflict Resolution UI
- Add Push Notifications untuk sync status
- Implement Retry Logic dengan exponential backoff
- Add Analytics untuk track sync performance
- Implement Data Encryption untuk sensitive data
š Related Documentation
Backend Implementation
Complete backend guide
API Reference
Full API documentation
Isar Documentation
Official Isar docs
Flutter Best Practices
Flutter coding standards
Implementation Complete! Flutter app Anda sekarang sudah offline-first ready! š