Kasir: indikator loading produk + empty/error state + tombol Coba lagi, refresh retry, login tanpa loading global

This commit is contained in:
Onie
2026-10-18 16:30:44 +07:00
parent aba36f5c60
commit 0174ca4f64
3 changed files with 193 additions and 29 deletions

View File

@@ -191,26 +191,25 @@ class AuthGate extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Selector<AppState, bool>( return Selector<AppState, ({bool loading, bool loggedIn, bool expired})>(
selector: (context, state) => state.loading, selector: (context, state) => (
builder: (context, loading, child) { loading: state.loading,
if (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( return const Scaffold(
body: Center( body: Center(child: CircularProgressIndicator(color: kAccent)),
child: CircularProgressIndicator(color: kAccent),
),
); );
} }
return Selector<AppState, ({bool loggedIn, bool expired})>( if (a.loggedIn && !a.expired) {
selector: (context, state) => return const HomePage();
(loggedIn: state.isLoggedIn, expired: state.sessionExpired), }
builder: (context, auth, child) { return const LoginScreen();
if (auth.loggedIn && !auth.expired) {
return const HomePage();
}
return const LoginScreen();
},
);
}, },
); );
} }

View File

@@ -56,6 +56,7 @@ class _KasirScreenState extends State<KasirScreen> {
p.name.toLowerCase().contains(query) || p.name.toLowerCase().contains(query) ||
p.barcode.toLowerCase().contains(query)) p.barcode.toLowerCase().contains(query))
.toList(); .toList();
final isInitialProductsLoading = state.productsLoading && !state.dataReady;
return Column( return Column(
children: [ children: [
@@ -283,8 +284,30 @@ class _KasirScreenState extends State<KasirScreen> {
), ),
), ),
Expanded( Expanded(
child: _isGrid child: isInitialProductsLoading
? GridView.builder( ? 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), padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
gridDelegate: gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount( 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<AppState>().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,
),
),
),
),
],
),
);
},
);
}
}

View File

@@ -43,6 +43,8 @@ class AppState extends ChangeNotifier {
} }
} }
Future<void> retryProducts() => refresh();
// Client dengan service_role key untuk operasi admin (buat/edit/hapus akun kasir). // 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. // PERINGATAN: key ini bypass RLS — hanya untuk app POS di device terpercaya.
final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey); final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey);
@@ -69,6 +71,9 @@ class AppState extends ChangeNotifier {
List<Map<String, dynamic>> kasirAccounts = []; List<Map<String, dynamic>> kasirAccounts = [];
bool loading = true; bool loading = true;
String? error; String? error;
bool dataReady = false;
bool productsLoading = false;
String? productsError;
static const int _pageSize = 30; static const int _pageSize = 30;
int _visibleProductCount = _pageSize; int _visibleProductCount = _pageSize;
@@ -196,7 +201,12 @@ class AppState extends ChangeNotifier {
_salesFirstLoad = false; _salesFirstLoad = false;
} }
Future<void> 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<void> refresh({bool notifyLoading = true, int retries = 1}) async {
final authOk = await _ensureSessionAndAuth(); final authOk = await _ensureSessionAndAuth();
if (!authOk) { if (!authOk) {
// Auth mati: jangan lanjut query (pasti 401). Cukup notify agar // Auth mati: jangan lanjut query (pasti 401). Cukup notify agar
@@ -204,9 +214,34 @@ class AppState extends ChangeNotifier {
notifyListeners(); notifyListeners();
return; return;
} }
await _loadProducts();
await _loadSales(); final showProductsLoading = notifyLoading && !dataReady;
notifyListeners(); 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<void>.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(); final OfflineQueueService _offlineQueue = OfflineQueueService();
@@ -313,17 +348,20 @@ class AppState extends ChangeNotifier {
); );
userId = res.user?.id; userId = res.user?.id;
_sessionExpired = false; _sessionExpired = false;
await _loadProfile(); dataReady = false;
// Muat produk/riwayat langsung agar user tidak menunggu poll berikut. productsLoading = true;
// Gagal load tak mematikan proses login. productsError = null;
try {
await refresh();
} catch (_) {}
notifyListeners(); notifyListeners();
await _loadProfile();
await refresh();
return null; return null;
} on AuthException catch (e) { } on AuthException catch (e) {
productsLoading = false;
notifyListeners();
return e.message; return e.message;
} catch (e) { } catch (e) {
productsLoading = false;
notifyListeners();
return e.toString(); return e.toString();
} }
} }
@@ -357,6 +395,10 @@ class AppState extends ChangeNotifier {
_profile = null; _profile = null;
cart.clear(); cart.clear();
_discount = 0; _discount = 0;
products = [];
dataReady = false;
productsLoading = false;
productsError = null;
notifyListeners(); notifyListeners();
} }