From 0174ca4f6443277b254b885645c71bbe361b024e Mon Sep 17 00:00:00 2001 From: Onie Date: Sun, 18 Oct 2026 16:30:44 +0700 Subject: [PATCH] Kasir: indikator loading produk + empty/error state + tombol Coba lagi, refresh retry, login tanpa loading global --- lib/main.dart | 33 +++++---- lib/screens/kasir_screen.dart | 127 +++++++++++++++++++++++++++++++++- lib/state/app_state.dart | 62 ++++++++++++++--- 3 files changed, 193 insertions(+), 29 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 0b8e5ba..5ab0207 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -191,26 +191,25 @@ class AuthGate extends StatelessWidget { @override Widget build(BuildContext context) { - return Selector( - selector: (context, state) => state.loading, - builder: (context, loading, child) { - if (loading) { + return Selector( + selector: (context, state) => ( + loading: state.loading, + loggedIn: state.isLoggedIn, + expired: state.sessionExpired, + ), + builder: (context, a, child) { + // Loading global hanya untuk init aplikasi. Proses login dihandle + // tombol LoginScreen, status data produk dihandle spinner khusus + // Kasir agar layar tidak melepas LoginScreen saat gagal login. + if (a.loading) { return const Scaffold( - body: Center( - child: CircularProgressIndicator(color: kAccent), - ), + body: Center(child: CircularProgressIndicator(color: kAccent)), ); } - return Selector( - selector: (context, state) => - (loggedIn: state.isLoggedIn, expired: state.sessionExpired), - builder: (context, auth, child) { - if (auth.loggedIn && !auth.expired) { - return const HomePage(); - } - return const LoginScreen(); - }, - ); + if (a.loggedIn && !a.expired) { + return const HomePage(); + } + return const LoginScreen(); }, ); } diff --git a/lib/screens/kasir_screen.dart b/lib/screens/kasir_screen.dart index fc901db..8f107c5 100644 --- a/lib/screens/kasir_screen.dart +++ b/lib/screens/kasir_screen.dart @@ -56,6 +56,7 @@ class _KasirScreenState extends State { p.name.toLowerCase().contains(query) || p.barcode.toLowerCase().contains(query)) .toList(); + final isInitialProductsLoading = state.productsLoading && !state.dataReady; return Column( children: [ @@ -283,8 +284,30 @@ class _KasirScreenState extends State { ), ), Expanded( - child: _isGrid - ? GridView.builder( + child: isInitialProductsLoading + ? const _ProductsLoading() + : products.isEmpty + ? state.productsError != null + ? _EmptyState( + icon: Icons.cloud_off_outlined, + title: 'Gagal memuat produk', + subtitle: + 'Periksa koneksi lalu coba lagi. Detail: ${state.productsError}', + actionLabel: 'Coba lagi', + ) + : _EmptyState( + icon: state.products.isEmpty + ? Icons.inventory_2_outlined + : Icons.search_off, + title: state.products.isEmpty + ? 'Belum ada produk' + : 'Tidak ditemukan', + subtitle: state.products.isEmpty + ? 'Tambahkan produk lewat tab Produk.' + : 'Coba kata kunci lain.', + ) + : _isGrid + ? GridView.builder( padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( @@ -1762,3 +1785,103 @@ class _QtyButton extends StatelessWidget { ); } } + +class _EmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final String? actionLabel; + const _EmptyState({ + required this.icon, + required this.title, + required this.subtitle, + this.actionLabel, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 56, color: kMuted), + const SizedBox(height: 16), + Text( + title, + style: const TextStyle( + color: kText, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + subtitle, + style: const TextStyle(color: kMuted, fontSize: 13), + textAlign: TextAlign.center, + ), + if (actionLabel != null) ...[ + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => context.read().retryProducts(), + icon: const Icon(Icons.refresh), + label: Text(actionLabel!), + ), + ], + ], + ), + ), + ); + } +} + +class _ProductsLoading extends StatelessWidget { + const _ProductsLoading(); + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), + itemCount: 6, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, i) { + return Container( + height: 72, + decoration: BoxDecoration( + color: kSurface2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: kBorder), + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: kBorder, + borderRadius: BorderRadius.circular(8), + ), + ), + const Expanded( + child: Center( + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: kAccent, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 28b59ec..f6b83f0 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -43,6 +43,8 @@ class AppState extends ChangeNotifier { } } + Future retryProducts() => refresh(); + // Client dengan service_role key untuk operasi admin (buat/edit/hapus akun kasir). // PERINGATAN: key ini bypass RLS — hanya untuk app POS di device terpercaya. final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey); @@ -69,6 +71,9 @@ class AppState extends ChangeNotifier { List> kasirAccounts = []; bool loading = true; String? error; + bool dataReady = false; + bool productsLoading = false; + String? productsError; static const int _pageSize = 30; int _visibleProductCount = _pageSize; @@ -196,7 +201,12 @@ class AppState extends ChangeNotifier { _salesFirstLoad = false; } - Future refresh() async { + /// Refresh produk + riwayat dengan retry terbatas untuk kegagalan sementara. + /// Nyalakan indikator loading hanya saat data pertama belum siap, supaya + /// Kasir bisa menampilkan status memuat/gagal/kosong dengan jelas. + /// Nonaktifkan indikator apa pun yang menyebabkan masalah refresh berulang. + /// Kirim sinyal hanya pada pemuatan awal; polling latar tidak mengganggu. + Future refresh({bool notifyLoading = true, int retries = 1}) async { final authOk = await _ensureSessionAndAuth(); if (!authOk) { // Auth mati: jangan lanjut query (pasti 401). Cukup notify agar @@ -204,9 +214,34 @@ class AppState extends ChangeNotifier { notifyListeners(); return; } - await _loadProducts(); - await _loadSales(); - notifyListeners(); + + final showProductsLoading = notifyLoading && !dataReady; + if (showProductsLoading) { + productsLoading = true; + notifyListeners(); + } + try { + await _loadProducts(); + await _loadSales(); + dataReady = true; + } catch (e) { + productsError ??= e.toString(); + if (retries > 0) { + // Coba sekali lagi untuk kegagalan sementara (misal koneksi putus). + productsError = null; + await Future.delayed(const Duration(seconds: 1)); + await refresh(notifyLoading: notifyLoading, retries: retries - 1); + return; + } + debugPrint('Gagal refresh: $e'); + } finally { + if (showProductsLoading) { + productsLoading = false; + } + if (notifyLoading) { + notifyListeners(); + } + } } final OfflineQueueService _offlineQueue = OfflineQueueService(); @@ -313,17 +348,20 @@ class AppState extends ChangeNotifier { ); userId = res.user?.id; _sessionExpired = false; - await _loadProfile(); - // Muat produk/riwayat langsung agar user tidak menunggu poll berikut. - // Gagal load tak mematikan proses login. - try { - await refresh(); - } catch (_) {} + dataReady = false; + productsLoading = true; + productsError = null; notifyListeners(); + await _loadProfile(); + await refresh(); return null; } on AuthException catch (e) { + productsLoading = false; + notifyListeners(); return e.message; } catch (e) { + productsLoading = false; + notifyListeners(); return e.toString(); } } @@ -357,6 +395,10 @@ class AppState extends ChangeNotifier { _profile = null; cart.clear(); _discount = 0; + products = []; + dataReady = false; + productsLoading = false; + productsError = null; notifyListeners(); }