Files
app-grosir/lib/services/offline_queue_service.dart

208 lines
6.6 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class OfflineQueueService {
static final OfflineQueueService _instance = OfflineQueueService._internal();
factory OfflineQueueService() => _instance;
OfflineQueueService._internal();
final SupabaseClient _supabase = Supabase.instance.client;
final Connectivity _connectivity = Connectivity();
List<Map<String, dynamic>> _queue = [];
bool _isOnline = true;
bool _syncing = false;
static const Duration _timeout = Duration(seconds: 30);
Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async {
try {
return await future.timeout(
_timeout,
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
);
} on TimeoutException catch (e) {
debugPrint('OfflineQueue timeout: $e');
rethrow;
}
}
List<Map<String, dynamic>> get queue => List.unmodifiable(_queue);
bool get isOnline => _isOnline;
bool get hasPendingItems => _queue.isNotEmpty;
/// ID transaksi (sale.id) yang masih menunggu sinkronisasi ke server.
/// Dipakai UI untuk menandai transaksi offline di Riwayat.
Set<String> get pendingSaleIds {
return _queue
.map((item) => item['sale']?['id'] as String?)
.whereType<String>()
.toSet();
}
/// Cek apakah transaksi dengan [saleId] masih dalam antrean offline
/// (belum terkirim ke server).
bool isPending(String? saleId) {
if (saleId == null) return false;
return _queue.any((item) => item['sale']?['id'] == saleId);
}
Future<void> init() async {
await _loadQueue();
_connectivity.onConnectivityChanged.listen((result) {
_updateConnectionStatus(result);
if (_isOnline && _queue.isNotEmpty) {
_processQueue().then((_) => retryNow());
}
});
await _checkConnection();
if (_isOnline) {
await retryNow();
retryNow();
}
}
Future<void> _loadQueue() async {
final prefs = await SharedPreferences.getInstance();
final queueJson = prefs.getString('offline_queue');
if (queueJson != null) {
final List<dynamic> decoded = json.decode(queueJson);
_queue = decoded.cast<Map<String, dynamic>>();
}
}
Future<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('offline_queue', json.encode(_queue));
}
Future<void> _checkConnection() async {
final result = await _connectivity.checkConnectivity();
_updateConnectionStatus(result);
}
/// Coba kirim antrean sekarang. Dipanggil setiap refresh/poll sukses
/// (server terbukti reachable), bukan hanya saat event connectivity
/// berubah. Dipanggil juga dari `init()` agar offline queue dikosongkan
/// pas app nyala, dan setiap kali server terbukti reachable lagi.
Future<void> retryNow() async {
await _checkConnection();
if (!_isOnline || _queue.isEmpty || _syncing) return;
// Verifikasi server benar-benar bisa dijangkau (radio nyala belum tentu
// berarti Supabase reachable, misal sinyal buruk). Ping ringan 5 detik.
try {
await _supabase.from('products').select('id').limit(1).timeout(
const Duration(seconds: 5),
onTimeout: () => throw TimeoutException('Ping server timeout'),
);
} catch (e) {
debugPrint('OfflineQueue skip sync: server tidak terjangkau: $e');
return;
}
await _processQueue();
}
void _updateConnectionStatus(List<ConnectivityResult> result) {
_isOnline = result.any((r) => r != ConnectivityResult.none);
if (_isOnline && _queue.isNotEmpty) {
_processQueue().then((_) => retryNow());
}
}
Future<void> addToQueue(Map<String, dynamic> transaction) async {
_queue.add(transaction);
await _saveQueue();
if (_isOnline) {
await _processQueue().then((_) => retryNow());
}
}
Future<void> _processQueue() async {
if (_syncing || !_isOnline || _queue.isEmpty) return;
_syncing = true;
while (_queue.isNotEmpty && _isOnline) {
final item = _queue.first;
try {
await _processTransaction(item);
_queue.removeAt(0);
await _saveQueue();
} catch (e) {
debugPrint('OfflineQueue gagal sinkron item ${item['sale']?['id']}: $e');
_syncing = false;
return;
}
}
_syncing = false;
}
Future<void> _processTransaction(Map<String, dynamic> transaction) async {
final sale = transaction['sale'] as Map<String, dynamic>;
final items = (transaction['items'] as List?) ?? [];
final saleId = sale['id'] as String;
final storeId = sale['store_id'] as String?;
final existing = await _withTimeout(
_supabase
.from('sales')
.select('id, total_cost')
.eq('id', saleId)
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000')
.maybeSingle(),
'Check existing sale',
);
if (existing == null) {
await _withTimeout(
_supabase.from('sales').insert(sale),
'Insert queued sale',
);
} else {
final existingTotalCost = (existing['total_cost'] as num?)?.toDouble() ?? 0;
if (existingTotalCost <= 0 && sale['total_cost'] != null) {
await _withTimeout(
_supabase
.from('sales')
.update({'total_cost': sale['total_cost']})
.eq('id', saleId)
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000'),
'Update sale total_cost',
);
}
}
for (final it in items) {
final productId = it['product_id'] as String;
final qty = (it['qty'] as num).toInt();
final product = await _withTimeout(
_supabase
.from('products')
.select()
.eq('id', productId)
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000')
.maybeSingle(),
'Load product for stock update',
);
if (product != null) {
final currentStock = (product['stock'] as num).toInt();
final newStock = (currentStock - qty).clamp(0, 1 << 62);
await _withTimeout(
_supabase
.from('products')
.update({'stock': newStock})
.eq('id', productId)
.eq('store_id', storeId ?? '00000000-0000-0000-0000-000000000000'),
'Update product stock',
);
}
}
}
Future<void> clearQueue() async {
_queue.clear();
await _saveQueue();
}
}