Selamat datang di part terakhir dari seri tutorial Mini POS. Setelah membangun fondasi arsitektur di Part 1 dan mengimplementasikan keamanan autentikasi di Part 2, sekarang kita akan membuat aplikasi yang benar-benar tangguh dengan fitur offline-first. Ini adalah pembeda antara aplikasi amatir dan aplikasi production-grade.
Bayangkan kamu memiliki restoran dengan aplikasi POS ini. Ada beberapa skenario yang akan terjadi di dunia nyata:
Koneksi internet putus saat sibuk - Di jam makan siang, banyak pelanggan, tiba-tiba WiFi mati. Tanpa offline-first, kamu tidak bisa mencatat pesanan baru.
Event di lokasi tanpa sinyal - Kamu melayani event outdoor di taman atau gedung dengan sinyal buruk.
Gangguan dari provider - Indihome atau provider mobile sedang maintenance.
Perjalanan dengan koneksi intermittent - Food truck yang berpindah-pindah lokasi.
Dengan arsitektur offline-first, aplikasi tetap 100% fungsional dalam semua skenario ini. Data disimpan lokal dulu, lalu disinkronkan ke server ketika online.
Traditional Approach (Online-First):
User Action -> API Call -> Server Response -> Update UI
|
v (jika gagal)
Error, data hilang
Offline-First Approach (Local-First):
User Action -> Save to Local DB -> Update UI (instant)
|
v (background)
Sync to Server (when online)
|
v
Update sync status
Semua operasi tulis selalu ke database lokal terlebih dahulu. Server dianggap sebagai "backup" dan "sync target", bukan sumber utama data.
Keuntungan:
UI diupdate segera setelah data tersimpan lokal, tanpa menunggu response dari server. Jika sync gagal nanti, baru ditampilkan status error atau retry.
Contoh:
Sync tidak dilakukan per-operation (terlalu boros), tapi menggunakan strategi yang efisien:
Flutter memiliki beberapa pilihan database lokal:
| Database | Tipe | Kelebihan | Kekurangan | Cocok untuk |
|---|---|---|---|---|
| Isar | NoSQL | Super cepat, reactive queries, indexing, relasi | Web support experimental | Complex apps, POS, e-commerce |
| Drift | SQL (SQLite) | Mature, SQL power, ACID | Lebih verbose, butuh SQL knowledge | Apps dengan complex queries, reporting |
| Hive | NoSQL (key-value) | Simple, cepat, low overhead | Limited query capabilities | Simple caching, preferences |
| ObjectBox | NoSQL | Built-in sync, ACID | Komersial untuk fitur advanced | Apps yang butuh sync built-in |
Untuk aplikasi POS kita, Isar adalah pilihan terbaik karena:
Tambahkan dependency untuk Part 3:
dependencies:
flutter:
sdk: flutter
provider: ^6.1.2
intl: ^0.19.0
flutter_secure_storage: ^9.2.2
dio: ^5.7.0
jwt_decoder: ^2.0.1
# NEW: Isar Database
isar: ^3.1.0
isar_flutter_libs: ^3.1.0
# NEW: Connectivity
connectivity_plus: ^6.1.0
internet_connection_checker: ^3.0.1
# NEW: Path provider untuk lokasi database
path_provider: ^2.1.2
# NEW: UUID generator untuk local IDs
uuid: ^4.5.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
# NEW: Isar code generator
isar_generator: ^3.1.0
build_runner: ^2.4.13
flutter:
uses-material-design: trueJalankan:
flutter pub getlib/database/isar_database.dart
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import '../models/local_product.dart';
import '../models/local_transaction.dart';
import '../models/sync_queue.dart';
/// Singleton class untuk mengelola instance Isar database.
///
/// Isar menggunakan file-based storage yang terletak di app documents directory.
/// Database ini akan persist meskipun app di-restart.
class IsarDatabase {
static final IsarDatabase _instance = IsarDatabase._internal();
factory IsarDatabase() => _instance;
IsarDatabase._internal();
Isar? _isar;
/// Getter untuk mengakses instance Isar.
/// Pastikan initialize() sudah dipanggil sebelum mengakses ini.
Isar get isar {
if (_isar == null) {
throw Exception('Isar belum diinisialisasi. Panggil initialize() terlebih dahulu.');
}
return _isar!;
}
/// Inisialisasi database Isar.
///
/// Method ini harus dipanggil sekali saat app startup, sebelum menggunakan database.
/// Biasanya dipanggil di main.dart atau splash screen.
Future<void> initialize() async {
if (_isar != null) return; // Sudah diinisialisasi
try {
final dir = await getApplicationDocumentsDirectory();
_isar = await Isar.open(
[
LocalProductSchema,
LocalTransactionSchema,
SyncQueueSchema,
],
directory: dir.path,
name: 'pos_database', // Nama file database
inspector: true, // Enable inspector untuk debugging (jangan di production)
);
print('Isar database initialized at: ${dir.path}/pos_database.isar');
} catch (e) {
throw Exception('Gagal menginisialisasi Isar: $e');
}
}
/// Menutup database.
///
/// Panggil method ini saat app di-terminate untuk membersihkan resources.
/// Meskipun tidak dipanggil, Isar akan menutup sendiri saat process di-kill.
Future<void> close() async {
await _isar?.close();
_isar = null;
}
/// Cek apakah database sudah diinisialisasi
bool get isInitialized => _isar != null;
}
// Global instance untuk kemudahan akses
final isarDb = IsarDatabase();Isar menggunakan annotation-based models dengan code generation. Setiap model harus memiliki @Collection() annotation.
lib/models/local_product.dart
import 'package:isar/isar.dart';
part 'local_product.g.dart'; // File generated oleh build_runner
/// Model produk yang disimpan di database lokal Isar.
///
/// Model ini berbeda dengan Product model di Part 1 karena:
/// 1. Menggunakan annotation Isar untuk schema definition
/// 2. Memiliki field untuk sync status (isSynced, lastModified, serverId)
/// 3. Menggunakan Id Isar (int) sebagai primary key lokal
@Collection()
class LocalProduct {
/// Primary key lokal yang di-generate otomatis oleh Isar.
/// Gunakan Isar.autoIncrement untuk auto-increment ID.
Id id = Isar.autoIncrement;
/// ID dari server (nullable sampai sync pertama kali).
/// Ini digunakan untuk mapping antara data lokal dan server.
@Index(unique: true) // Index untuk pencarian cepat dan mencegah duplikat
String? serverId;
/// Nama produk
late String name;
/// Deskripsi produk
String? description;
/// Harga produk dalam satuan terkecil (rupiah tanpa desimal)
late int price;
/// Kategori produk
@Index() // Index untuk filtering berdasarkan kategori
late String category;
/// Jumlah stock tersedia
late int stock;
/// URL atau path gambar produk
String? imageUrl;
/// Timestamp kapan data terakhir dimodifikasi (dalam milliseconds)
/// Digunakan untuk conflict resolution dan delta sync
@Index() // Index untuk sorting dan query berdasarkan waktu
late int lastModified;
/// Status apakah data sudah tersinkronisasi dengan server
@Index() // Index untuk query data yang belum sync
late bool isSynced;
/// Timestamp kapan data terakhir disinkronisasi
int? lastSyncedAt;
/// Versi data untuk optimistic concurrency control
/// Jika versi di server lebih tinggi, berarti ada konflik
late int version;
/// Constructor default
LocalProduct();
/// Factory method untuk membuat dari Product model (Part 1)
factory LocalProduct.fromProduct(dynamic product) {
return LocalProduct()
..serverId = product.id
..name = product.name
..description = product.description
..price = (product.price * 100).toInt() // Konversi ke rupiah tanpa desimal
..category = product.category
..stock = product.stock
..lastModified = DateTime.now().millisecondsSinceEpoch
..isSynced = false
..version = 1;
}
/// Mengkonversi ke format untuk UI (Product model Part 1)
///
/// Ini memungkinkan kita menggunakan data lokal dengan UI yang sudah dibuat
dynamic toProduct() {
// Kita return Map yang compatible dengan Product model
return {
'id': serverId ?? id.toString(),
'name': name,
'description': description ?? '',
'price': price / 100, // Konversi kembali ke double
'category': category,
'stock': stock,
};
}
/// Helper untuk mengupdate timestamp modifikasi
void touch() {
lastModified = DateTime.now().millisecondsSinceEpoch;
isSynced = false;
version++;
}
}lib/models/local_transaction.dart
import 'package:isar/isar.dart';
import 'package:uuid/uuid.dart';
part 'local_transaction.g.dart';
/// Status transaksi dalam lifecycle-nya
enum TransactionStatus {
pending, // Baru dibuat, belum diproses
processing, // Sedang diproses (sync ke server)
completed, // Berhasil disinkronisasi
failed, // Gagal sync, akan di-retry
cancelled, // Dibatalkan oleh user
}
/// Model untuk menyimpan transaksi/penjualan di database lokal.
///
/// Setiap transaksi POS akan disimpan di sini terlebih dahulu,
/// kemudian disinkronkan ke server saat online.
@Collection()
class LocalTransaction {
/// Primary key lokal (auto-increment)
Id id = Isar.autoIncrement;
/// UUID untuk identifikasi global (digunakan untuk sync)
/// UUID ini di-generate sekali dan tidak berubah
@Index(unique: true)
late String uuid;
/// ID transaksi di server (nullable sampai sync berhasil)
@Index(unique: true)
String? serverId;
/// ID user kasir yang membuat transaksi
@Index()
late String cashierId;
/// Nama kasir (denormalized untuk tampilan offline)
late String cashierName;
/// Timestamp transaksi dibuat
@Index()
late int createdAt;
/// Total jumlah item dalam transaksi
late int itemCount;
/// Total nilai transaksi (dalam rupiah tanpa desimal)
late int totalAmount;
/// List item dalam transaksi (disimpan sebagai JSON string)
/// Format: [{"productId": "1", "name": "Nasi Goreng", "qty": 2, "price": 25000}, ...]
late String itemsJson;
/// Status transaksi
@Index()
late String status; // Simpan sebagai string enum
/// Jumlah pembayaran dari customer
late int paymentAmount;
/// Kembalian
int get changeAmount => paymentAmount - totalAmount;
/// Catatan tambahan
String? notes;
/// Timestamp terakhir dicoba sync
int? lastSyncAttempt;
/// Jumlah percobaan sync yang gagal
late int syncRetryCount;
/// Error message terakhir jika sync gagal
String? lastError;
/// Constructor
LocalTransaction() {
uuid = const Uuid().v4();
createdAt = DateTime.now().millisecondsSinceEpoch;
status = TransactionStatus.pending.name;
syncRetryCount = 0;
}
/// Helper untuk mengubah status
void setStatus(TransactionStatus newStatus) {
status = newStatus.name;
}
TransactionStatus getStatus() {
return TransactionStatus.values.firstWhere(
(e) => e.name == status,
orElse: () => TransactionStatus.pending,
);
}
/// Cek apakah transaksi perlu di-sync
bool get needsSync {
final currentStatus = getStatus();
return currentStatus == TransactionStatus.pending ||
currentStatus == TransactionStatus.failed;
}
/// Mark transaksi sedang diproses
void markProcessing() {
setStatus(TransactionStatus.processing);
lastSyncAttempt = DateTime.now().millisecondsSinceEpoch;
}
/// Mark transaksi berhasil
void markCompleted(String serverTransactionId) {
serverId = serverTransactionId;
setStatus(TransactionStatus.completed);
lastSyncAttempt = DateTime.now().millisecondsSinceEpoch;
syncRetryCount = 0;
lastError = null;
}
/// Mark transaksi gagal
void markFailed(String error) {
setStatus(TransactionStatus.failed);
lastSyncAttempt = DateTime.now().millisecondsSinceEpoch;
syncRetryCount++;
lastError = error;
}
}lib/models/sync_queue.dart
import 'package:isar/isar.dart';
part 'sync_queue.g.dart';
/// Tipe operasi yang perlu di-sync
enum SyncOperationType {
createProduct,
updateProduct,
deleteProduct,
createTransaction,
updateTransaction,
}
/// Model untuk antrian sinkronisasi.
///
/// Setiap operasi yang perlu di-sync ke server akan ditambahkan ke antrian ini.
/// Sync engine akan memproses antrian ini secara berkala atau saat online.
@Collection()
class SyncQueue {
/// Primary key lokal
Id id = Isar.autoIncrement;
/// Tipe operasi
@Index()
late String operationType; // Enum sebagai string
/// Nama koleksi/target (e.g., "products", "transactions")
@Index()
late String collection;
/// ID data lokal yang terkait
@Index()
late int localId;
/// ID data di server (jika sudah ada)
String? serverId;
/// Data yang akan di-sync (JSON string)
/// Berisi field-field yang berubah untuk delta sync
String? payload;
/// Timestamp saat operasi ditambahkan ke antrian
@Index()
late int createdAt;
/// Prioritas (lebih rendah = lebih penting, diproses duluan)
late int priority;
/// Jumlah percobaan yang sudah dilakukan
late int retryCount;
/// Timestamp percobaan terakhir
int? lastAttempt;
/// Error message terakhir
String? lastError;
/// Status antrian
@Index()
late String status; // "pending", "processing", "failed", "completed"
/// Constructor
SyncQueue() {
createdAt = DateTime.now().millisecondsSinceEpoch;
retryCount = 0;
priority = 10; // Default priority
status = 'pending';
}
SyncOperationType getOperationType() {
return SyncOperationType.values.firstWhere(
(e) => e.name == operationType,
orElse: () => SyncOperationType.createProduct,
);
}
void setOperationType(SyncOperationType type) {
operationType = type.name;
}
}Setelah membuat model dengan annotation @Collection(), jalankan code generator:
flutter pub run build_runner buildIni akan menghasilkan file .g.dart untuk setiap model yang berisi:
Jika ada perubahan pada model, jalankan:
flutter pub run build_runner build --delete-conflicting-outputslib/repositories/product_repository.dart
import 'package:isar/isar.dart';
import '../database/isar_database.dart';
import '../models/local_product.dart';
import '../models/sync_queue.dart';
/// Repository untuk mengelola produk dengan dukungan offline-first.
///
/// Repository ini menangani:
/// - CRUD operations ke database lokal Isar
/// - Queueing sync operations
/// - Reactive queries untuk auto-update UI
class ProductRepository {
Isar get _isar => isarDb.isar;
/// Stream untuk mendapatkan semua produk secara reactive.
///
/// UI akan otomatis ter-update ketika ada perubahan data.
Stream<List<LocalProduct>> watchAllProducts() {
return _isar.localProducts.where().watch(fireImmediately: true);
}
/// Stream untuk mendapatkan produk berdasarkan kategori.
Stream<List<LocalProduct>> watchByCategory(String category) {
return _isar.localProducts
.filter()
.categoryEqualTo(category)
.watch(fireImmediately: true);
}
/// Mendapatkan semua produk (one-time query).
Future<List<LocalProduct>> getAllProducts() async {
return await _isar.localProducts.where().findAll();
}
/// Mencari produk berdasarkan nama (full-text search).
Future<List<LocalProduct>> searchProducts(String query) async {
return await _isar.localProducts
.filter()
.nameContains(query, caseSensitive: false)
.or()
.descriptionContains(query, caseSensitive: false)
.findAll();
}
/// Menambah atau mengupdate produk.
///
/// Flow:
/// 1. Simpan ke database lokal
/// 2. Tambahkan ke sync queue untuk sync ke server
/// 3. Return produk yang tersimpan
Future<LocalProduct> saveProduct(LocalProduct product) async {
return await _isar.writeTxn(() async {
// Update timestamp
product.touch();
// Simpan produk
await _isar.localProducts.put(product);
// Tambahkan ke sync queue
await _addToSyncQueue(product, product.serverId == null
? SyncOperationType.createProduct
: SyncOperationType.updateProduct);
return product;
});
}
/// Menambah batch produk (untuk sync dari server).
///
/// Method ini digunakan saat menerima data dari server,
/// tidak menambahkan ke sync queue karena data sudah ada di server.
Future<void> saveProductsFromServer(List<LocalProduct> products) async {
await _isar.writeTxn(() async {
for (final product in products) {
product.isSynced = true;
product.lastSyncedAt = DateTime.now().millisecondsSinceEpoch;
await _isar.localProducts.put(product);
}
});
}
/// Menghapus produk.
Future<void> deleteProduct(int localId) async {
await _isar.writeTxn(() async {
final product = await _isar.localProducts.get(localId);
if (product != null && product.serverId != null) {
// Jika sudah sync ke server, tambahkan ke queue untuk delete di server
await _addToSyncQueue(product, SyncOperationType.deleteProduct);
}
await _isar.localProducts.delete(localId);
});
}
/// Mendapatkan produk yang belum tersinkronisasi.
Future<List<LocalProduct>> getUnsyncedProducts() async {
return await _isar.localProducts
.filter()
.isSyncedEqualTo(false)
.findAll();
}
/// Helper untuk menambahkan ke sync queue
Future<void> _addToSyncQueue(
LocalProduct product,
SyncOperationType operationType,
) async {
final queue = SyncQueue()
..setOperationType(operationType)
..collection = 'products'
..localId = product.id
..serverId = product.serverId
..payload = '{"lastModified": ${product.lastModified}, "version": ${product.version}}'
..priority = operationType == SyncOperationType.deleteProduct ? 5 : 10;
await _isar.syncQueues.put(queue);
}
/// Sinkronisasi produk dari server.
///
/// Method ini dipanggil saat app startup atau pull-to-refresh.
/// Mengimplementasikan delta sync berdasarkan lastModified.
Future<void> syncFromServer(List<Map<String, dynamic>> serverProducts) async {
await _isar.writeTxn(() async {
for (final serverData in serverProducts) {
// Cek apakah produk sudah ada di lokal
final existing = await _isar.localProducts
.filter()
.serverIdEqualTo(serverData['id'])
.findFirst();
if (existing != null) {
// Cek conflict: jika lokal lebih baru, skip (last-write-wins)
final serverModified = serverData['lastModified'] as int? ?? 0;
if (existing.lastModified > serverModified && !existing.isSynced) {
// Konflik: lokal lebih baru, biarkan lokal yang menang
continue;
}
// Update data lokal dengan data server
existing
..serverId = serverData['id']
..name = serverData['name']
..description = serverData['description']
..price = (serverData['price'] * 100).toInt()
..category = serverData['category']
..stock = serverData['stock']
..lastModified = serverModified
..isSynced = true
..lastSyncedAt = DateTime.now().millisecondsSinceEpoch
..version = serverData['version'] ?? 1;
await _isar.localProducts.put(existing);
} else {
// Produk baru dari server
final newProduct = LocalProduct()
..serverId = serverData['id']
..name = serverData['name']
..description = serverData['description']
..price = (serverData['price'] * 100).toInt()
..category = serverData['category']
..stock = serverData['stock']
..lastModified = serverData['lastModified'] ?? DateTime.now().millisecondsSinceEpoch
..isSynced = true
..lastSyncedAt = DateTime.now().millisecondsSinceEpoch
..version = serverData['version'] ?? 1;
await _isar.localProducts.put(newProduct);
}
}
});
}
}lib/repositories/transaction_repository.dart
import 'package:isar/isar.dart';
import '../database/isar_database.dart';
import '../models/local_transaction.dart';
import '../models/sync_queue.dart';
/// Repository untuk mengelola transaksi dengan dukungan offline-first.
class TransactionRepository {
Isar get _isar => isarDb.isar;
/// Stream untuk mendapatkan semua transaksi.
Stream<List<LocalTransaction>> watchAllTransactions() {
return _isar.localTransactions
.where()
.sortByCreatedAtDesc()
.watch(fireImmediately: true);
}
/// Stream untuk transaksi yang perlu di-sync.
Stream<List<LocalTransaction>> watchPendingTransactions() {
return _isar.localTransactions
.filter()
.statusEqualTo(TransactionStatus.pending.name)
.or()
.statusEqualTo(TransactionStatus.failed.name)
.watch(fireImmediately: true);
}
/// Mendapatkan transaksi berdasarkan UUID.
Future<LocalTransaction?> getByUuid(String uuid) async {
return await _isar.localTransactions
.filter()
.uuidEqualTo(uuid)
.findFirst();
}
/// Menyimpan transaksi baru.
///
/// Transaksi akan disimpan dengan status "pending" dan otomatis
/// ditambahkan ke sync queue.
Future<LocalTransaction> createTransaction({
required String cashierId,
required String cashierName,
required List<Map<String, dynamic>> items,
required int totalAmount,
required int paymentAmount,
String? notes,
}) async {
final transaction = LocalTransaction()
..cashierId = cashierId
..cashierName = cashierName
..itemCount = items.length
..totalAmount = totalAmount
..itemsJson = _encodeItems(items)
..paymentAmount = paymentAmount
..notes = notes;
return await _isar.writeTxn(() async {
await _isar.localTransactions.put(transaction);
// Tambahkan ke sync queue dengan prioritas tinggi (transaksi penting)
final queue = SyncQueue()
..setOperationType(SyncOperationType.createTransaction)
..collection = 'transactions'
..localId = transaction.id
..priority = 1; // Prioritas tinggi
await _isar.syncQueues.put(queue);
return transaction;
});
}
/// Mendapatkan semua transaksi yang perlu di-sync ke server.
Future<List<LocalTransaction>> getPendingSync() async {
return await _isar.localTransactions
.filter()
.statusEqualTo(TransactionStatus.pending.name)
.or()
.statusEqualTo(TransactionStatus.failed.name)
.and()
.syncRetryCountLessThan(5) // Max 5 retry
.findAll();
}
/// Update status transaksi setelah sync.
Future<void> markAsSynced(
int localId,
String serverId, {
String? error,
}) async {
await _isar.writeTxn(() async {
final transaction = await _isar.localTransactions.get(localId);
if (transaction != null) {
if (error != null) {
transaction.markFailed(error);
} else {
transaction.markCompleted(serverId);
}
await _isar.localTransactions.put(transaction);
}
});
}
/// Mendapatkan summary transaksi hari ini.
Future<Map<String, dynamic>> getTodaySummary() async {
final now = DateTime.now();
final startOfDay = DateTime(now.year, now.month, now.day).millisecondsSinceEpoch;
final endOfDay = DateTime(now.year, now.month, now.day, 23, 59, 59).millisecondsSinceEpoch;
final transactions = await _isar.localTransactions
.filter()
.createdAtBetween(startOfDay, endOfDay)
.and()
.statusEqualTo(TransactionStatus.completed.name)
.findAll();
final totalSales = transactions.fold<int>(0, (sum, t) => sum + t.totalAmount);
return {
'count': transactions.length,
'totalSales': totalSales,
'averageTicket': transactions.isEmpty ? 0 : totalSales / transactions.length,
};
}
String _encodeItems(List<Map<String, dynamic>> items) {
// Simplified JSON encoding
return items.toString(); // Dalam produksi, gunakan jsonEncode
}
}lib/services/sync_service.dart
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import '../database/isar_database.dart';
import '../models/sync_queue.dart';
import '../repositories/product_repository.dart';
import '../repositories/transaction_repository.dart';
/// Service untuk mengelola sinkronisasi data antara lokal dan server.
///
/// Sync engine ini akan:
/// 1. Monitoring koneksi internet
/// 2. Memproses sync queue saat online
/// 3. Melakukan periodic sync
/// 4. Menangani conflict resolution
class SyncService {
final Dio _dio;
final ProductRepository _productRepo;
final TransactionRepository _transactionRepo;
Timer? _periodicSyncTimer;
StreamSubscription<ConnectivityResult>? _connectivitySubscription;
bool _isSyncing = false;
// Controller untuk broadcast sync status
final _syncStatusController = StreamController<SyncStatus>.broadcast();
Stream<SyncStatus> get syncStatus => _syncStatusController.stream;
SyncService({
Dio? dio,
ProductRepository? productRepo,
TransactionRepository? transactionRepo,
}) : _dio = dio ?? Dio(),
_productRepo = productRepo ?? ProductRepository(),
_transactionRepo = transactionRepo ?? TransactionRepository();
/// Inisialisasi sync service.
///
/// Panggil method ini di main.dart setelah Isar diinisialisasi.
void initialize() {
// Listen untuk perubahan konektivitas
_connectivitySubscription = Connectivity()
.onConnectivityChanged
.listen(_onConnectivityChanged);
// Setup periodic sync setiap 5 menit
_periodicSyncTimer = Timer.periodic(
const Duration(minutes: 5),
(_) => _performSync(),
);
// Cek koneksi awal dan sync jika online
_checkInitialConnectivity();
}
/// Dispose resources.
void dispose() {
_periodicSyncTimer?.cancel();
_connectivitySubscription?.cancel();
_syncStatusController.close();
}
/// Cek koneksi saat app startup.
Future<void> _checkInitialConnectivity() async {
final result = await Connectivity().checkConnectivity();
if (result != ConnectivityResult.none) {
_performSync();
}
}
/// Handler untuk perubahan konektivitas.
void _onConnectivityChanged(ConnectivityResult result) {
if (result == ConnectivityResult.none) {
_syncStatusController.add(SyncStatus.offline);
} else {
_syncStatusController.add(SyncStatus.online);
// Sync segera saat kembali online
_performSync();
}
}
/// Melakukan sinkronisasi.
///
/// Method ini akan:
/// 1. Push data lokal yang belum sync ke server
/// 2. Pull data baru dari server
/// 3. Proses antrian sync
Future<void> _performSync() async {
if (_isSyncing) return; // Hindari concurrent sync
_isSyncing = true;
_syncStatusController.add(SyncStatus.syncing);
try {
// 1. Sync transaksi yang pending (prioritas tinggi)
await _syncPendingTransactions();
// 2. Sync produk yang belum sync
await _syncPendingProducts();
// 3. Pull data produk dari server (delta sync)
await _pullProductsFromServer();
_syncStatusController.add(SyncStatus.completed);
} catch (e) {
print('Sync error: $e');
_syncStatusController.add(SyncStatus.error);
} finally {
_isSyncing = false;
}
}
/// Sinkronisasi transaksi pending ke server.
Future<void> _syncPendingTransactions() async {
final pending = await _transactionRepo.getPendingSync();
for (final transaction in pending) {
try {
transaction.markProcessing();
await isarDb.isar.writeTxn(() async {
await isarDb.isar.localTransactions.put(transaction);
});
// Kirim ke server
final response = await _dio.post(
'/api/transactions',
data: {
'uuid': transaction.uuid,
'cashierId': transaction.cashierId,
'items': transaction.itemsJson,
'totalAmount': transaction.totalAmount,
'paymentAmount': transaction.paymentAmount,
'createdAt': transaction.createdAt,
},
);
if (response.statusCode == 201) {
await _transactionRepo.markAsSynced(
transaction.id,
response.data['id'],
);
}
} on DioException catch (e) {
final error = e.response?.data['message'] ?? e.message ?? 'Network error';
await _transactionRepo.markAsSynced(
transaction.id,
'',
error: error,
);
}
}
}
/// Sinkronisasi produk yang belum sync.
Future<void> _syncPendingProducts() async {
final unsynced = await _productRepo.getUnsyncedProducts();
for (final product in unsynced) {
try {
final isNew = product.serverId == null;
final response = isNew
? await _dio.post('/api/products', data: product.toProduct())
: await _dio.put(
'/api/products/${product.serverId}',
data: product.toProduct(),
);
if (response.statusCode == 200 || response.statusCode == 201) {
await isarDb.isar.writeTxn(() async {
product
..serverId = response.data['id']
..isSynced = true
..lastSyncedAt = DateTime.now().millisecondsSinceEpoch;
await isarDb.isar.localProducts.put(product);
});
}
} catch (e) {
print('Failed to sync product ${product.id}: $e');
// Retry akan dilakukan di sync berikutnya
}
}
}
/// Pull data produk dari server (delta sync).
Future<void> _pullProductsFromServer() async {
try {
// Ambil timestamp sync terakhir
final lastSync = await _getLastSyncTimestamp();
final response = await _dio.get(
'/api/products',
queryParameters: {
'since': lastSync,
'limit': 100,
},
);
if (response.statusCode == 200) {
final products = (response.data as List)
.map((json) => LocalProduct.fromJson(json)) // Perlu tambah fromJson
.toList();
await _productRepo.saveProductsFromServer(products as List<Map<String, dynamic>>);
}
} catch (e) {
print('Failed to pull products: $e');
}
}
Future<int> _getLastSyncTimestamp() async {
// Ambil dari shared preferences atau database
// Simplified: return 0 untuk sync semua data
return 0;
}
/// Trigger manual sync.
///
/// Panggil method ini dari UI (misalnya pull-to-refresh).
Future<void> syncNow() async {
await _performSync();
}
/// Cek apakah sedang sync.
bool get isSyncing => _isSyncing;
}
/// Status sinkronisasi untuk UI.
enum SyncStatus {
offline, // Tidak ada koneksi
online, // Online tapi tidak sedang sync
syncing, // Sedang proses sync
completed, // Sync berhasil
error, // Sync gagal
}lib/providers/product_provider.dart
import 'package:flutter/foundation.dart';
import '../models/local_product.dart';
import '../repositories/product_repository.dart';
/// Provider untuk mengelola state produk dengan data dari Isar database.
///
/// Provider ini menggunakan reactive queries dari Isar, sehingga UI
/// akan otomatis ter-update ketika data di database berubah.
class ProductProvider with ChangeNotifier {
final ProductRepository _repository;
List<LocalProduct> _products = [];
bool _isLoading = false;
String? _error;
Stream<List<LocalProduct>>? _productsStream;
ProductProvider({ProductRepository? repository})
: _repository = repository ?? ProductRepository() {
_initStream();
}
List<LocalProduct> get products => _products;
bool get isLoading => _isLoading;
String? get error => _error;
/// Inisialisasi stream untuk reactive updates.
void _initStream() {
_productsStream = _repository.watchAllProducts();
_productsStream!.listen(
(products) {
_products = products;
notifyListeners();
},
onError: (e) {
_error = 'Error loading products: $e';
notifyListeners();
},
);
}
/// Menambah produk baru.
Future<void> addProduct(LocalProduct product) async {
_isLoading = true;
notifyListeners();
try {
await _repository.saveProduct(product);
_error = null;
} catch (e) {
_error = 'Failed to add product: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Mencari produk.
Future<void> search(String query) async {
if (query.isEmpty) {
_initStream(); // Reset ke semua produk
return;
}
_isLoading = true;
notifyListeners();
try {
_products = await _repository.searchProducts(query);
_error = null;
} catch (e) {
_error = 'Search failed: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
/// Refresh data dari server (manual sync).
Future<void> refresh() async {
_isLoading = true;
notifyListeners();
try {
// Trigger sync dari server
// Implementasi tergantung pada SyncService
_error = null;
} catch (e) {
_error = 'Refresh failed: $e';
} finally {
_isLoading = false;
notifyListeners();
}
}
@override
void dispose() {
// Cleanup jika diperlukan
super.dispose();
}
}lib/providers/cart_provider.dart (Update dari Part 1)
import 'package:flutter/foundation.dart';
import '../models/product.dart';
import '../models/cart_item.dart';
import '../models/local_transaction.dart';
import '../repositories/transaction_repository.dart';
class CartProvider with ChangeNotifier {
final List<CartItem> _items = [];
final TransactionRepository _transactionRepo;
CartProvider({TransactionRepository? transactionRepo})
: _transactionRepo = transactionRepo ?? TransactionRepository();
List<CartItem> get items => List.unmodifiable(_items);
int get itemCount => _items.fold(0, (sum, item) => sum + item.quantity);
double get totalAmount => _items.fold(0.0, (sum, item) => sum + item.subtotal);
bool get isEmpty => _items.isEmpty;
void addToCart(Product product) {
final existingIndex = _items.indexWhere(
(item) => item.product.id == product.id,
);
if (existingIndex >= 0) {
final existingItem = _items[existingIndex];
if (existingItem.quantity < product.stock) {
existingItem.increment();
notifyListeners();
}
} else {
_items.add(CartItem(product: product));
notifyListeners();
}
}
void removeFromCart(String productId) {
final existingIndex = _items.indexWhere(
(item) => item.product.id == productId,
);
if (existingIndex >= 0) {
final existingItem = _items[existingIndex];
if (existingItem.quantity > 1) {
existingItem.decrement();
} else {
_items.removeAt(existingIndex);
}
notifyListeners();
}
}
void removeItemCompletely(String productId) {
_items.removeWhere((item) => item.product.id == productId);
notifyListeners();
}
void clearCart() {
_items.clear();
notifyListeners();
}
bool isInCart(String productId) {
return _items.any((item) => item.product.id == productId);
}
int getQuantity(String productId) {
final item = _items.firstWhere(
(item) => item.product.id == productId,
orElse: () => CartItem(
product: Product(id: '', name: '', description: '', price: 0, category: '', stock: 0),
quantity: 0,
),
);
return item.quantity;
}
/// Checkout dan simpan transaksi ke database lokal.
///
/// Transaksi akan otomatis masuk ke sync queue untuk dikirim ke server.
Future<LocalTransaction?> checkout({
required String cashierId,
required String cashierName,
required int paymentAmount,
String? notes,
}) async {
if (_items.isEmpty) return null;
try {
final items = _items.map((item) => {
'productId': item.product.id,
'name': item.product.name,
'quantity': item.quantity,
'price': item.product.price,
'subtotal': item.subtotal,
}).toList();
final transaction = await _transactionRepo.createTransaction(
cashierId: cashierId,
cashierName: cashierName,
items: items,
totalAmount: (totalAmount * 100).toInt(),
paymentAmount: paymentAmount * 100,
notes: notes,
);
clearCart();
return transaction;
} catch (e) {
throw Exception('Checkout failed: $e');
}
}
}lib/widgets/sync_status_bar.dart
import 'package:flutter/material.dart';
import '../services/sync_service.dart';
/// Widget untuk menampilkan status koneksi dan sinkronisasi.
///
/// Widget ini menunjukkan:
/// - Status online/offline
/// - Status sync (sedang sync, sync berhasil, error)
/// - Jumlah item yang pending sync
class SyncStatusBar extends StatelessWidget {
final SyncService syncService;
const SyncStatusBar({
super.key,
required this.syncService,
});
@override
Widget build(BuildContext context) {
return StreamBuilder<SyncStatus>(
stream: syncService.syncStatus,
initialData: SyncStatus.online,
builder: (context, snapshot) {
final status = snapshot.data ?? SyncStatus.online;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: _getBackgroundColor(status),
child: SafeArea(
child: Row(
children: [
Icon(
_getIcon(status),
color: _getTextColor(status),
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_getMessage(status),
style: TextStyle(
color: _getTextColor(status),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
if (status == SyncStatus.error)
TextButton(
onPressed: () => syncService.syncNow(),
child: Text(
'RETRY',
style: TextStyle(
color: _getTextColor(status),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
);
},
);
}
Color _getBackgroundColor(SyncStatus status) {
switch (status) {
case SyncStatus.offline:
return Colors.orange.shade100;
case SyncStatus.syncing:
return Colors.blue.shade100;
case SyncStatus.completed:
return Colors.green.shade100;
case SyncStatus.error:
return Colors.red.shade100;
case SyncStatus.online:
return Colors.grey.shade100;
}
}
Color _getTextColor(SyncStatus status) {
switch (status) {
case SyncStatus.offline:
return Colors.orange.shade900;
case SyncStatus.syncing:
return Colors.blue.shade900;
case SyncStatus.completed:
return Colors.green.shade900;
case SyncStatus.error:
return Colors.red.shade900;
case SyncStatus.online:
return Colors.grey.shade700;
}
}
IconData _getIcon(SyncStatus status) {
switch (status) {
case SyncStatus.offline:
return Icons.wifi_off;
case SyncStatus.syncing:
return Icons.sync;
case SyncStatus.completed:
return Icons.check_circle;
case SyncStatus.error:
return Icons.error_outline;
case SyncStatus.online:
return Icons.wifi;
}
}
String _getMessage(SyncStatus status) {
switch (status) {
case SyncStatus.offline:
return 'Offline mode - Data disimpan lokal';
case SyncStatus.syncing:
return 'Menyinkronkan data...';
case SyncStatus.completed:
return 'Data tersinkronisasi';
case SyncStatus.error:
return 'Gagal sync - Tap retry';
case SyncStatus.online:
return 'Online - Siap sync';
}
}
}lib/main.dart (Final Complete Version)
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'database/isar_database.dart';
import 'providers/auth_provider.dart';
import 'providers/cart_provider.dart';
import 'providers/product_provider.dart';
import 'services/sync_service.dart';
import 'screens/login_screen.dart';
import 'screens/home_screen.dart';
import 'widgets/sync_status_bar.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Inisialisasi Isar database
await isarDb.initialize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => CartProvider()),
ChangeNotifierProvider(create: (_) => ProductProvider()),
Provider(create: (_) => SyncService()..initialize()),
],
child: MaterialApp(
title: 'Mini POS - Offline First',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
home: const AuthWrapper(),
),
);
}
}
/// Wrapper untuk menentukan halaman berdasarkan autentikasi.
class AuthWrapper extends StatefulWidget {
const AuthWrapper({super.key});
@override
State<AuthWrapper> createState() => _AuthWrapperState();
}
class _AuthWrapperState extends State<AuthWrapper> {
bool _isInitializing = true;
@override
void initState() {
super.initState();
_initializeAuth();
}
Future<void> _initializeAuth() async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
await authProvider.initialize();
if (mounted) {
setState(() {
_isInitializing = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_isInitializing) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Memuat aplikasi...'),
],
),
),
);
}
return Consumer<AuthProvider>(
builder: (context, auth, child) {
if (auth.isAuthenticated) {
return const MainAppScreen();
} else {
return const LoginScreen();
}
},
);
}
}
/// Screen utama yang menampilkan HomeScreen dengan SyncStatusBar.
class MainAppScreen extends StatelessWidget {
const MainAppScreen({super.key});
@override
Widget build(BuildContext context) {
final syncService = Provider.of<SyncService>(context);
return Column(
children: [
SyncStatusBar(syncService: syncService),
const Expanded(child: HomeScreen()),
],
);
}
}┌─────────────────────────────────────────────────────────────────┐
│ USER ACTION │
└────────────────┬────────────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────────────────────┐
│ UI Layer (Widget) │
│ - Menerima input user │
│ - Memanggil method Provider │
└────────────────┬────────────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────────────────────┐
│ Provider Layer (Business Logic) │
│ - Validasi business rules │
│ - Memanggil Repository │
└────────────────┬────────────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────────────────────┐
│ Repository Layer │
│ - Write ke Isar Database (LOCAL-FIRST) │
│ - Add to Sync Queue │
│ - Return hasil │
└────────────────┬────────────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────────────────────┐
│ Isar Database (Local Storage) │
│ - Simpan data │
│ - Trigger reactive update │
└────────────────┬────────────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────────────────────┐
│ UI Auto-Update (Reactive Stream) │
│ - Widget rebuild dengan data baru │
└────────────────┬────────────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────────────────────┐
│ Background Sync (SyncService) │
│ - Monitor connectivity │
│ - Process Sync Queue │
│ - Push to Server (when online) │
└────────────────┬────────────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────────────────────┐
│ Server (Backend API) │
│ - Menerima data sync │
│ - Return confirmation │
└────────────────┬────────────────────────────────────────────────┘
│
v
┌─────────────────────────────────────────────────────────────────┐
│ Update Local Sync Status │
│ - Mark as synced │
│ - Update serverId jika baru │
└─────────────────────────────────────────────────────────────────┘
Dalam sistem offline-first, konflik bisa terjadi ketika:
Strategi yang kita implementasikan adalah Last-Write-Wins dengan Prioritas:
// Pseudocode untuk conflict resolution
if (localLastModified > serverLastModified) {
// Data lokal lebih baru, kirim ke server (client wins)
pushToServer(localData);
} else if (localLastModified < serverLastModified) {
// Data server lebih baru, update lokal (server wins)
updateLocal(serverData);
} else {
// Timestamp sama, bandingkan version number
if (localVersion >= serverVersion) {
clientWins();
} else {
serverWins();
}
}// Test case: Checkout saat offline
void main() {
test('Checkout should save to local DB when offline', () async {
// Arrange
final cart = CartProvider();
cart.addToCart(dummyProduct);
// Act - Simulate offline checkout
final transaction = await cart.checkout(
cashierId: '1',
cashierName: 'Test',
paymentAmount: 50000,
);
// Assert
expect(transaction, isNotNull);
expect(transaction!.getStatus(), TransactionStatus.pending);
// Verify saved in local DB
final saved = await isarDb.isar.localTransactions.get(transaction.id);
expect(saved, isNotNull);
});
}test('Pending transactions should sync when online', () async {
// Create pending transaction
final transaction = await createPendingTransaction();
// Trigger sync
await syncService.syncNow();
// Verify status updated
final updated = await isarDb.isar.localTransactions.get(transaction.id);
expect(updated!.getStatus(), TransactionStatus.completed);
expect(updated.serverId, isNotNull);
});Penyebab: Isar belum diinisialisasi sebelum query
Solusi: Pastikan isarDb.initialize() selesai sebelum app berjalan
Penyebab: SyncService tidak diinisialisasi atau connectivity listener tidak aktif
Solusi: Panggil syncService.initialize() di main.dart dan cek permission
Penyebab: Timestamp tidak sinkron antara client dan server Solusi: Gunakan server timestamp sebagai authoritative source
Di part ini kita telah membangun sistem offline-first yang lengkap:
Aplikasi POS kita sekarang memiliki karakteristik:
Struktur folder final:
lib/
├── main.dart
├── database/
│ └── isar_database.dart
├── models/
│ ├── product.dart (Part 1)
│ ├── cart_item.dart (Part 1)
│ ├── local_product.dart (+ generated)
│ ├── local_transaction.dart (+ generated)
│ └── sync_queue.dart (+ generated)
├── providers/
│ ├── auth_provider.dart (Part 2)
│ ├── cart_provider.dart (Updated)
│ └── product_provider.dart (New)
├── repositories/
│ ├── auth_repository.dart (Part 2)
│ ├── product_repository.dart (New)
│ └── transaction_repository.dart (New)
├── screens/
│ ├── login_screen.dart (Part 2)
│ ├── home_screen.dart (Part 2)
│ └── cart_screen.dart (Part 1)
├── services/
│ ├── secure_storage_service.dart (Part 2)
│ ├── api_interceptor.dart (Part 2)
│ └── sync_service.dart (New)
└── widgets/
├── product_card.dart (Part 1)
└── sync_status_bar.dart (New)
Code Generation: Jangan lupa jalankan flutter pub run build_runner build setiap kali mengubah model Isar.
Backend Integration: Kode di atas menggunakan placeholder API endpoints. Sesuaikan dengan backend-mu (Laravel, Node.js, Firebase, dll).
Testing: Test secara intensif dengan skenario:
Production: Sebelum production, matikan inspector: true di Isar initialization untuk keamanan.
Selamat! Kamu sekarang memiliki aplikasi POS Flutter yang:
Seri tutorial ini selesai. Semoga bermanfaat dan selamat mengembangkan aplikasi POS-mu!