Offline sync: indikator status sinkron di Riwayat + retry antrean otomatis

This commit is contained in:
Onie
2026-10-18 20:21:40 +07:00
parent e557a82faa
commit 903eb9fc42
4 changed files with 66 additions and 6 deletions

1
PRD.md
View File

@@ -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). - **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. - **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). - **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/`. - 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 ### v1.2.0+5 — 2026-10-17

View File

@@ -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('Kembali', formatRupiah(currentSale.change.clamp(0, double.infinity)), false),
_receiptRow('Metode', _paymentMethodLabel(currentSale.paymentMethod), false), _receiptRow('Metode', _paymentMethodLabel(currentSale.paymentMethod), false),
_receiptRow('Status', currentSale.paymentStatus == 'paid' ? 'Lunas' : 'Belum Lunas', 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) if (currentSale.splitPayment != null && currentSale.splitPayment!.isNotEmpty)
...currentSale.splitPayment!.entries.map((e) { ...currentSale.splitPayment!.entries.map((e) {
final label = e.key == 'tunai' final label = e.key == 'tunai'

View File

@@ -34,11 +34,35 @@ class OfflineQueueService {
bool get isOnline => _isOnline; bool get isOnline => _isOnline;
bool get hasPendingItems => _queue.isNotEmpty; 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 { Future<void> init() async {
await _loadQueue(); await _loadQueue();
_connectivity.onConnectivityChanged.listen(_updateConnectionStatus); _connectivity.onConnectivityChanged.listen((result) {
_updateConnectionStatus(result);
if (_isOnline && _queue.isNotEmpty) {
_processQueue().then((_) => retryNow());
}
});
await _checkConnection(); await _checkConnection();
if (_isOnline) await _processQueue(); if (_isOnline) {
await retryNow();
retryNow();
}
} }
Future<void> _loadQueue() async { Future<void> _loadQueue() async {
@@ -61,12 +85,14 @@ class OfflineQueueService {
} }
/// Coba kirim antrean sekarang. Dipanggil setiap refresh/poll sukses /// 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 { Future<void> retryNow() async {
await _checkConnection(); await _checkConnection();
if (!_isOnline || _queue.isEmpty || _syncing) return; if (!_isOnline || _queue.isEmpty || _syncing) return;
// Verifikasi server benar-benar bisa dijangkau (radio nyala belum tentu // 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 { try {
await _supabase.from('products').select('id').limit(1).timeout( await _supabase.from('products').select('id').limit(1).timeout(
const Duration(seconds: 5), const Duration(seconds: 5),
@@ -82,7 +108,7 @@ class OfflineQueueService {
void _updateConnectionStatus(List<ConnectivityResult> result) { void _updateConnectionStatus(List<ConnectivityResult> result) {
_isOnline = result.any((r) => r != ConnectivityResult.none); _isOnline = result.any((r) => r != ConnectivityResult.none);
if (_isOnline && _queue.isNotEmpty) { if (_isOnline && _queue.isNotEmpty) {
_processQueue(); _processQueue().then((_) => retryNow());
} }
} }
@@ -90,7 +116,7 @@ class OfflineQueueService {
_queue.add(transaction); _queue.add(transaction);
await _saveQueue(); await _saveQueue();
if (_isOnline) { if (_isOnline) {
await _processQueue(); await _processQueue().then((_) => retryNow());
} }
} }

View File

@@ -248,6 +248,15 @@ class AppState extends ChangeNotifier {
final OfflineQueueService _offlineQueue = OfflineQueueService(); 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 => double get subtotal =>
cart.fold(0, (sum, line) => sum + line.price * line.qty); cart.fold(0, (sum, line) => sum + line.price * line.qty);