Kasir: indikator loading produk + empty/error state + tombol Coba lagi, refresh retry, login tanpa loading global
This commit is contained in:
@@ -191,27 +191,26 @@ 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,
|
||||||
return const Scaffold(
|
expired: state.sessionExpired,
|
||||||
body: Center(
|
|
||||||
child: CircularProgressIndicator(color: kAccent),
|
|
||||||
),
|
),
|
||||||
|
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)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Selector<AppState, ({bool loggedIn, bool expired})>(
|
if (a.loggedIn && !a.expired) {
|
||||||
selector: (context, state) =>
|
|
||||||
(loggedIn: state.isLoggedIn, expired: state.sessionExpired),
|
|
||||||
builder: (context, auth, child) {
|
|
||||||
if (auth.loggedIn && !auth.expired) {
|
|
||||||
return const HomePage();
|
return const HomePage();
|
||||||
}
|
}
|
||||||
return const LoginScreen();
|
return const LoginScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +284,29 @@ class _KasirScreenState extends State<KasirScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _isGrid
|
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(
|
? GridView.builder(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
||||||
gridDelegate:
|
gridDelegate:
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,10 +214,35 @@ class AppState extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final showProductsLoading = notifyLoading && !dataReady;
|
||||||
|
if (showProductsLoading) {
|
||||||
|
productsLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
try {
|
||||||
await _loadProducts();
|
await _loadProducts();
|
||||||
await _loadSales();
|
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();
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user