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).
|
- **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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user