diff --git a/PRD.md b/PRD.md index 679c1b2..c353f70 100644 --- a/PRD.md +++ b/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 : `; 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 diff --git a/lib/screens/riwayat_screen.dart b/lib/screens/riwayat_screen.dart index 420a123..3515772 100644 --- a/lib/screens/riwayat_screen.dart +++ b/lib/screens/riwayat_screen.dart @@ -469,6 +469,28 @@ class _RiwayatScreenState extends State { ), ), ], + 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 { _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' diff --git a/lib/services/offline_queue_service.dart b/lib/services/offline_queue_service.dart index 503573f..d881490 100644 --- a/lib/services/offline_queue_service.dart +++ b/lib/services/offline_queue_service.dart @@ -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 get pendingSaleIds { + return _queue + .map((item) => item['sale']?['id'] as String?) + .whereType() + .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 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 _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 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 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()); } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index be5e8ea..c4a0953 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -248,6 +248,15 @@ class AppState extends ChangeNotifier { final OfflineQueueService _offlineQueue = OfflineQueueService(); + /// ID transaksi yang masih dalam antrean offline (belum terkirim ke server). + Set 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);