Skip to main content

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

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

# 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
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
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
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
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
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
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
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
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
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
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

  1. Implement Conflict Resolution UI
  2. Add Push Notifications untuk sync status
  3. Implement Retry Logic dengan exponential backoff
  4. Add Analytics untuk track sync performance
  5. Implement Data Encryption untuk sensitive data


Implementation Complete! Flutter app Anda sekarang sudah offline-first ready! šŸŽ‰