Offline sync: indikator status sinkron di Riwayat + retry antrean otomatis
This commit is contained in:
1
PRD.md
1
PRD.md
@@ -14,6 +14,7 @@
|
||||
- **Auth flow lengkap (2026-10-18):** `login_screen.dart` + `AuthGate` (main.dart) sudah ada & ter-wiring. Dilengkapi: `AuthGate` sekarang route ke `LoginScreen` saat `state.sessionExpired` true (bukan cuma `!isLoggedIn`) — saat session mati user dipaksa login ulang, bukan nyangkut di HomePage dengan sesi mati. `_ensureSessionAndAuth()` membedakan auth benar-benar mati (`AuthException`/refresh balikin session tak valid → `sessionExpired=true`) vs network blip (error jaringan → TIDAK tandai sessionExpired, jadi tak salah-logout saat internet putus sesaat). `refresh()` stop query saat auth mati (hindari 401 sia-sia + cukup notify agar redirect). `login()` reset `sessionExpired` + langsung muat produk/riwayat (tak tunggu poll 1 menit).
|
||||
- **Kasir: indikator loading produk + empty/error state (2026-10-18):** setelah login user sempat melihat daftar kosong lalu produk muncul beberapa saat kemudian. Sebab otentik: `AuthGate → HomePage` render duluan, `refresh() → _loadProducts()` jalan belakangan tanpa indikator. Perbaikan kini: `AppState.productsLoading/dataReady/productsError` + `retryProducts()`; Kasir menampilkan skeleton daftar `_ProductsLoading` khusus saat muat awal, banner `Gagal memuat produk` + tombol `Coba lagi` saat error, dan empty-state `Belum ada produk`/`Tidak ditemukan` sesuai kasus. `refresh()` diberi retry 1x + try-catch internal sehingga `_loadSales()` tidak men-stop loop; LoginScreen tetap dipakai (tanpa loading global) agar pesan error/login tidak hilang.
|
||||
- **Offline queue: transaksi offline kini disinkron ulang otomatis (2026-10-18):** laporan user — transaksi saat data mati tidak masuk walau data sudah nyala. Sebab: sync antrean hanya dipicu event `connectivity_plus` (radio nyala ≠ server reachable) dan polling tidak menyentuh antrean. Perbaikan: `OfflineQueueService.retryNow()` (re-check koneksi + ping ringan Supabase 5 dtk sebelum proses antrean) dipanggil setiap `refresh()` sukses; log `OfflineQueue gagal sinkron item <id>: <error>`; tambah izin Android `INTERNET` + `ACCESS_NETWORK_STATE` (sebelumnya tidak ada di manifest, deteksi konektivitas tidak andal).
|
||||
- **Indikator status sinkron di Riwayat (2026-10-18):** transaksi yang masih dalam antrean offline ditandai badge `Menunggu sinkron` (ikon jam pasir) di daftar Riwayat + baris `Sinkronisasi: Belum terkirim ke server` di detail transaksi. `AppState.pendingSaleIds`, `isSalePending()`, `pendingOfflineCount` dipakai UI untuk menandai status.
|
||||
- Fix Gitea ROOT_URL (infra): link download APK sebelumnya mengarah ke IP Tailscale lama → fixed ke `https://git.gosambi.uk/`.
|
||||
|
||||
### v1.2.0+5 — 2026-10-17
|
||||
|
||||
@@ -469,6 +469,28 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.isSalePending(sale.id)) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.hourglass_empty,
|
||||
size: 10,
|
||||
color: kMuted,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Menunggu sinkron',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: kMuted,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -633,6 +655,8 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
_receiptRow('Kembali', formatRupiah(currentSale.change.clamp(0, double.infinity)), false),
|
||||
_receiptRow('Metode', _paymentMethodLabel(currentSale.paymentMethod), false),
|
||||
_receiptRow('Status', currentSale.paymentStatus == 'paid' ? 'Lunas' : 'Belum Lunas', false),
|
||||
if (state.isSalePending(currentSale.id))
|
||||
_receiptRow('Sinkronisasi', 'Belum terkirim ke server', false),
|
||||
if (currentSale.splitPayment != null && currentSale.splitPayment!.isNotEmpty)
|
||||
...currentSale.splitPayment!.entries.map((e) {
|
||||
final label = e.key == 'tunai'
|
||||
|
||||
@@ -34,11 +34,35 @@ class OfflineQueueService {
|
||||
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(_updateConnectionStatus);
|
||||
_connectivity.onConnectivityChanged.listen((result) {
|
||||
_updateConnectionStatus(result);
|
||||
if (_isOnline && _queue.isNotEmpty) {
|
||||
_processQueue().then((_) => retryNow());
|
||||
}
|
||||
});
|
||||
await _checkConnection();
|
||||
if (_isOnline) await _processQueue();
|
||||
if (_isOnline) {
|
||||
await retryNow();
|
||||
retryNow();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
@@ -61,12 +85,14 @@ class OfflineQueueService {
|
||||
}
|
||||
|
||||
/// Coba kirim antrean sekarang. Dipanggil setiap refresh/poll sukses
|
||||
/// (server terbukti reachable), bukan hanya saat event connectivity berubah.
|
||||
/// (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).
|
||||
// berarti Supabase reachable, misal sinyal buruk). Ping ringan 5 detik.
|
||||
try {
|
||||
await _supabase.from('products').select('id').limit(1).timeout(
|
||||
const Duration(seconds: 5),
|
||||
@@ -82,7 +108,7 @@ class OfflineQueueService {
|
||||
void _updateConnectionStatus(List<ConnectivityResult> result) {
|
||||
_isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||
if (_isOnline && _queue.isNotEmpty) {
|
||||
_processQueue();
|
||||
_processQueue().then((_) => retryNow());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +116,7 @@ class OfflineQueueService {
|
||||
_queue.add(transaction);
|
||||
await _saveQueue();
|
||||
if (_isOnline) {
|
||||
await _processQueue();
|
||||
await _processQueue().then((_) => retryNow());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -248,6 +248,15 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
final OfflineQueueService _offlineQueue = OfflineQueueService();
|
||||
|
||||
/// ID transaksi yang masih dalam antrean offline (belum terkirim ke server).
|
||||
Set<String> get pendingSaleIds => _offlineQueue.pendingSaleIds;
|
||||
|
||||
/// True jika transaksi dengan [saleId] masih menunggu sinkronisasi.
|
||||
bool isSalePending(String? saleId) => _offlineQueue.isPending(saleId);
|
||||
|
||||
/// Jumlah transaksi offline yang belum tersinkron (badge di Riwayat).
|
||||
int get pendingOfflineCount => _offlineQueue.pendingSaleIds.length;
|
||||
|
||||
double get subtotal =>
|
||||
cart.fold(0, (sum, line) => sum + line.price * line.qty);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user