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
Widget build(BuildContext context) {
return Selector<AppState, bool>(
selector: (context, state) => state.loading,
builder: (context, loading, child) {
if (loading) {
return Selector<AppState, ({bool loading, bool loggedIn, bool expired})>(
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<AppState, ({bool loggedIn, bool expired})>(
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();
},
);
}

View File

@@ -56,6 +56,7 @@ class _KasirScreenState extends State<KasirScreen> {
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<KasirScreen> {
),
),
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<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).
// 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<Map<String, dynamic>> 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<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();
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<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();
@@ -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();
}