import 'dart:async'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/product.dart'; import '../models/sale.dart'; import '../models/user_profile.dart'; import '../services/offline_queue_service.dart'; import '../utils/format.dart'; import '../config.dart'; class CartLine { final Product product; int qty; bool isReseller; CartLine({ required this.product, this.qty = 1, this.isReseller = false, }); double get price => isReseller ? product.priceReseller : product.price; } class AppState extends ChangeNotifier { final SupabaseClient _supabase = Supabase.instance.client; // Stream notifikasi transaksi baru (dipakai UI untuk banner+suara+getar). // Diisi setiap createSale sukses DAN setiap realtime INSERT dari HP lain. final StreamController newSaleNotifications = StreamController.broadcast(); final Set _notifiedSaleIds = {}; // Jumlah transaksi baru yang belum dilihat admin (badge di tab Riwayat). int unseenSalesCount = 0; void markSalesSeen() { if (unseenSalesCount != 0) { unseenSalesCount = 0; notifyListeners(); } } // 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); static const Duration _timeout = Duration(seconds: 30); static const String _currentStoreId = '00000000-0000-0000-0000-000000000000'; Future _withTimeout(Future future, [String? operation]) async { try { return await future.timeout( _timeout, onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'), ); } on TimeoutException catch (e) { debugPrint('Supabase timeout: $e'); rethrow; } } List products = []; List sales = []; List cart = []; List> kasirAccounts = []; bool loading = true; String? error; static const int _pageSize = 30; int _visibleProductCount = _pageSize; int _visibleSalesCount = _pageSize; List get displayedProducts => products.take(_visibleProductCount).toList(); List get displayedSales => sales.take(_visibleSalesCount).toList(); int get visibleProductCount => _visibleProductCount; int get visibleSalesCount => _visibleSalesCount; int get pageSize => _pageSize; bool get hasMoreProducts => _visibleProductCount < products.length; bool get hasMoreSales => _visibleSalesCount < sales.length; void resetPagination() { _visibleProductCount = _pageSize; _visibleSalesCount = _pageSize; notifyListeners(); } void loadMoreProducts() { if (!hasMoreProducts) return; _visibleProductCount = (_visibleProductCount + _pageSize).clamp(0, products.length); notifyListeners(); } void loadMoreSales() { if (!hasMoreSales) return; _visibleSalesCount = (_visibleSalesCount + _pageSize).clamp(0, sales.length); notifyListeners(); } String customerType = 'umum'; bool resellerEnabled = true; String? userId; String? userRole; UserProfile? _profile; /// True jika session auth mati total (refresh token pun kedaluwarsa). /// UI (kasir) memakai ini untuk menampilkan info "sesi habis" + /// tombol login ulang, bukan list kosong yang membingungkan. bool _sessionExpired = false; bool get sessionExpired => _sessionExpired; final Map _profileNames = {}; double _discount = 0; bool get isAdmin => userRole == 'admin'; bool get isKasir => userRole == 'kasir'; bool get isLoggedIn => userId != null; String getProfileName(String? uid) { if (uid == null) return 'Tidak diketahui'; if (uid == userId) return _profile?.fullName ?? 'Admin'; return _profileNames[uid] ?? 'Kasir'; } AppState() { _init(); } Timer? _pollTimer; static const Duration _pollInterval = Duration(minutes: 1); DateTime? _lastManualRefresh; void recordManualRefresh() { _lastManualRefresh = DateTime.now(); } void startPolling() { stopPolling(); _pollTimer = Timer.periodic(_pollInterval, (_) => _poll()); } void stopPolling() { _pollTimer?.cancel(); _pollTimer = null; } Future _poll() async { if (_lastManualRefresh != null) { final elapsed = DateTime.now().difference(_lastManualRefresh!); if (elapsed < const Duration(seconds: 15)) return; } await refresh(); _checkNewSales(); } bool _salesFirstLoad = true; /// Deteksi transaksi baru dari polling/refresh dan kirim ke stream /// notifikasi. Load pertama dilewati agar tidak spam saat start. void _checkNewSales() { // Hanya admin yang perlu notifikasi — kasir tidak (mereka yang // transaksi, jadi sudah tahu). if (userRole != 'admin') { for (final s in sales) { _notifiedSaleIds.add(s.id); } _salesFirstLoad = false; return; } final now = DateTime.now(); bool newSeen = false; for (final s in sales) { if (!_notifiedSaleIds.contains(s.id)) { _notifiedSaleIds.add(s.id); // Lewati transaksi lama (>5 menit) agar tidak spam notif saat start. // Lewati juga transaksi sendiri — admin tahu kalau dia yang jualan. if (!_salesFirstLoad && s.userId != userId && now.difference(s.date).inMinutes.abs() <= 5) { newSeen = true; unseenSalesCount++; if (!newSaleNotifications.isClosed) { newSaleNotifications.add(s); } } } } if (newSeen) notifyListeners(); _salesFirstLoad = false; } Future refresh() async { await _ensureSessionAndAuth(); await _loadProducts(); await _loadSales(); notifyListeners(); } final OfflineQueueService _offlineQueue = OfflineQueueService(); double get subtotal => cart.fold(0, (sum, line) => sum + line.price * line.qty); double get discount => _discount; double get total => (subtotal - _discount).clamp(0, double.infinity); bool get canCheckout => cart.isNotEmpty && total > 0; /* ---------- Init & Realtime ---------- */ Future _init() async { try { final authOk = await _ensureSessionAndAuth(); if (!authOk) { // Session mati total (refresh token ikut kadaluwarsa). Jangan lanjut: // _loadProducts() akan timeout 30s + 401, bakal menjebak loading walau // produk sendiri masih ada. Tandai sessionExpired, jalankan polling // saja (real-time masih bisa update), dan biarkan UI tampil // "login ulang". _sessionExpired = true; startPolling(); error = 'Sesi auth belum tersedia, silakan login ulang.'; } else { await _loadProducts(); await _loadSales(); await loadKasirAccounts(); await _loadProfileNames(); await _loadSettings(); _subscribe(); await _seedAdminIfEmpty(); await _offlineQueue.init(); startPolling(); } } catch (e) { error = e.toString(); } loading = false; notifyListeners(); } Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); resellerEnabled = prefs.getBool('reseller_enabled') ?? true; notifyListeners(); } Future setResellerEnabled(bool value) async { resellerEnabled = value; final prefs = await SharedPreferences.getInstance(); await prefs.setBool('reseller_enabled', value); if (!value) { setCustomerType('umum'); for (final line in cart) { if (line.isReseller) toggleLineMode(line.product.id); } } notifyListeners(); } Future _loadProfile() async { if (userId == null) return; try { final data = await _supabase .from('profiles') .select() .eq('id', userId!) .maybeSingle(); if (data != null) { _profile = UserProfile.fromJson(data); userRole = _profile!.role; } } catch (_) {} } Future _seedAdminIfEmpty() async { try { final count = await _withTimeout( _admin.from('profiles').select().count(), 'Check admin profiles', ); if (count.count == 0) { await _withTimeout( _admin.auth.admin.createUser( AdminUserAttributes(email: 'admin@posgrosir.local', password: 'admin123'), ), 'Create default admin', ); } } catch (_) {} } Future login(String email, String password) async { try { final res = await _withTimeout( _supabase.auth.signInWithPassword( email: email, password: password, ), 'Login', ); userId = res.user?.id; await _loadProfile(); notifyListeners(); return null; } on AuthException catch (e) { return e.message; } catch (e) { return e.toString(); } } // Reserved for future self-service signup flow. // Currently unused per PRD §5.3: all devices must be pre-provisioned by admin. Future signup(String email, String password) async { try { final res = await _withTimeout( _supabase.auth.signUp( email: email, password: password, ), 'Signup', ); userId = res.user?.id; await _loadProfile(); notifyListeners(); return null; } on AuthException catch (e) { return e.message; } catch (e) { return e.toString(); } } Future logout() async { await _withTimeout(_supabase.auth.signOut(), 'Logout'); userId = null; userRole = null; _profile = null; cart.clear(); _discount = 0; notifyListeners(); } Future createKasir(String email, String password, String? name) async { try { final res = await _withTimeout( _admin.auth.admin.createUser( AdminUserAttributes(email: email, password: password, emailConfirm: true), ), 'Create kasir user', ); final newUserId = res.user?.id; if (newUserId != null) { await _withTimeout( _admin.from('profiles').upsert({ 'id': newUserId, 'role': 'kasir', 'full_name': name, }), 'Create kasir profile', ); await loadKasirAccounts(); } return null; } on AuthException catch (e) { return e.message; } catch (e) { return e.toString(); } } Future deleteKasir(String id) async { try { await _withTimeout( _admin.from('profiles').delete().eq('id', id), 'Delete kasir profile', ); await _withTimeout( _admin.auth.admin.deleteUser(id), 'Delete kasir user', ); await loadKasirAccounts(); } catch (_) {} } Future updateKasir(String id, String email, String? name, String? password) async { try { final updateData = { 'full_name': name, }; if (password != null && password.isNotEmpty) { await _withTimeout( _admin.auth.admin.updateUserById( id, attributes: AdminUserAttributes(password: password), ), 'Update kasir password', ); } await _withTimeout( _admin.from('profiles').update(updateData).eq('id', id), 'Update kasir profile', ); await loadKasirAccounts(); return null; } on AuthException catch (e) { return e.message; } catch (e) { return e.toString(); } } Future loadKasirAccounts() async { try { final rows = await _withTimeout( _admin .from('profiles') .select() .eq('role', 'kasir'), 'Load kasir accounts', ); Map emails = {}; try { final usersRes = await _withTimeout( _admin.auth.admin.listUsers(), 'List auth users', ); for (final u in usersRes) { if (u.email != null && u.email!.isNotEmpty) { emails[u.id] = u.email!; } } } catch (_) {} kasirAccounts = rows.map((r) { final map = Map.from(r); if ((map['email'] == null || map['email'].toString().isEmpty) && emails.containsKey(map['id'])) { map['email'] = emails[map['id']]; } if ((map['full_name'] == null || map['full_name'].toString().isEmpty) && map['email'] != null && map['email'].toString().isNotEmpty) { map['full_name'] = map['email'].toString().split('@').first; } return map; }).toList(); notifyListeners(); } catch (_) {} } Future _loadProfileNames() async { try { final rows = await _withTimeout( _admin.from('profiles').select('id,full_name,email'), 'Load profile names', ); _profileNames.clear(); for (final r in rows) { final map = Map.from(r); final name = (map['full_name']?.toString().trim().isEmpty == true ? map['email']?.toString().split('@').first : map['full_name']?.toString().trim()) ?? ''; if (name.isNotEmpty) { _profileNames[map['id'] as String] = name; } } } catch (_) {} } void _subscribe() { _supabase.auth.onAuthStateChange.listen((event) { if (event.session != null) { userId = event.session!.user.id; _sessionExpired = false; _loadProfile(); } else { // Sesi mati total (logout / refresh token ikut kedaluwarsa): // tandai agar UI bisa menampilkan pesan "sesi habis", bukan diam. userId = null; userRole = null; _profile = null; _sessionExpired = true; } notifyListeners(); }); _supabase.from('products').stream(primaryKey: ['id']).listen( (rows) { products = rows.map((e) => Product.fromJson(e)).toList(); notifyListeners(); }, onError: (e) { debugPrint('REALTIME products stream error: $e'); }, ); _supabase.from('sales').stream(primaryKey: ['id']).listen( (rows) { sales = rows.map((e) => Sale.fromJson(e)).toList(); sales.sort((a, b) => b.date.compareTo(a.date)); notifyListeners(); }, onError: (e) { debugPrint('REALTIME sales stream error: $e'); }, ); } Future _loadProducts() async { try { final rows = await _withTimeout( _supabase.from('products').select().eq('store_id', _currentStoreId).order('name'), 'Load products', ); final loaded = rows.map((e) => Product.fromJson(e)).toList(); // Hasil kosong dari server JANGAN menimpa data lama — itu tanda // query gagal diam-diam (misal JWT expired), bukan toko kehabisan produk. if (loaded.isNotEmpty) { products = loaded; } else if (products.isEmpty) { _seed(); } } catch (e) { // Jangan crash refresh: produk lama tetap ditampilkan, error tercatat di log. debugPrint('Gagal load products: $e'); } } /// Cek langkah demi langkah sampai session valid atau mati total. /// Stop segera kalau auth mati, jangan tunggu timeout 30s per request. Future _ensureSessionAndAuth() async { final session = _supabase.auth.currentSession; if (session != null && !session.isExpired) { return true; } // Session kadaluwarsa: coba refresh token dulu. try { final res = await _supabase.auth.refreshSession(); if (res.session != null && !res.session!.isExpired) { return true; } } catch (_) {} _sessionExpired = true; return false; } Future _loadSales() async { final rows = await _withTimeout( _supabase .from('sales') .select() .eq('store_id', _currentStoreId) .order('created_at', ascending: false), 'Load sales', ); sales = rows.map((e) => Sale.fromJson(e)).toList(); sales.sort((a, b) => b.date.compareTo(a.date)); } Future _seed() async { final seeds = [ Product(id: uid(), name: 'Beras 5kg', price: 58000, priceReseller: 55000, stock: 50), Product(id: uid(), name: 'Minyak 2L', price: 32000, priceReseller: 30000, stock: 40), Product(id: uid(), name: 'Gula 1kg', price: 14000, priceReseller: 13000, stock: 60), Product(id: uid(), name: 'Teh Botol', price: 3600, priceReseller: 3200, stock: 120), Product(id: uid(), name: 'Sabun Cuci', price: 8500, priceReseller: 7800, stock: 35), Product(id: uid(), name: 'Indomie Dus', price: 95000, priceReseller: 90000, stock: 25), ]; await _withTimeout( _supabase.from('products').insert(seeds.map((e) => e.toJson()..['store_id'] = _currentStoreId).toList()), 'Seed products', ); products = seeds; notifyListeners(); } /* ---------- Tipe Pembeli ---------- */ void setCustomerType(String type) { customerType = type; final reseller = type == 'reseller'; for (final c in cart) { c.isReseller = reseller; } notifyListeners(); } /* ---------- Produk ---------- */ Future addProduct(String name, String barcode, double price, double priceReseller, int stock, int minStock, double hargaPokok, {String? imagePath}) async { try { final trimmed = barcode.trim(); if (trimmed.isNotEmpty && products.any((p) => p.barcode.trim().toLowerCase() == trimmed.toLowerCase())) { return 'Barcode sudah digunakan oleh produk lain'; } final product = Product( id: uid(), name: name, barcode: trimmed, price: price, priceReseller: priceReseller, stock: stock, minStock: minStock, hargaPokok: hargaPokok, imagePath: imagePath, ); await _withTimeout( _supabase.from('products').insert(product.toJson()..['store_id'] = _currentStoreId), 'Add product', ); } catch (e) { return e.toString(); } try { await _loadProducts(); } catch (_) {} notifyListeners(); return null; } Future updateProduct(String id, String name, String barcode, double price, double priceReseller, int stock, int minStock, double hargaPokok, {String? imagePath}) async { try { final trimmed = barcode.trim(); if (trimmed.isNotEmpty && products.any((p) => p.id != id && p.barcode.trim().toLowerCase() == trimmed.toLowerCase())) { return 'Barcode sudah digunakan oleh produk lain'; } await _withTimeout( _supabase.from('products').update({ 'name': name, 'barcode': trimmed, 'price': price, 'price_reseller': priceReseller, 'stock': stock, 'min_stock': minStock, 'harga_pokok': hargaPokok, 'store_id': _currentStoreId, 'image_path': imagePath, }).eq('id', id), 'Update product', ); } catch (e) { return e.toString(); } try { await _loadProducts(); } catch (_) {} notifyListeners(); return null; } Future deleteProduct(String id) async { try { cart.removeWhere((c) => c.product.id == id); await _withTimeout( _supabase.from('products').delete().eq('id', id).eq('store_id', _currentStoreId), 'Delete product', ); } catch (e) { return e.toString(); } try { await _loadProducts(); } catch (_) {} notifyListeners(); return null; } /* ---------- Cart ---------- */ void addToCart(Product product) { if (product.stock <= 0) return; final line = cart.firstWhere( (c) => c.product.id == product.id, orElse: () => CartLine(product: product), ); if (cart.contains(line)) { if (line.qty < product.stock) line.qty++; } else { line.isReseller = customerType == 'reseller'; cart.add(line); } notifyListeners(); } void changeQty(String productId, int delta) { final line = cart.firstWhere((c) => c.product.id == productId); line.qty += delta; if (line.qty <= 0) { cart.remove(line); } else if (line.qty > line.product.stock) { line.qty = line.product.stock; } notifyListeners(); } void toggleLineMode(String productId) { final line = cart.firstWhere((c) => c.product.id == productId); line.isReseller = !line.isReseller; notifyListeners(); } void setDiscount(double value) { _discount = value < 0 ? 0 : value.clamp(0, subtotal); notifyListeners(); } void clearCart() { cart.clear(); _discount = 0; notifyListeners(); } /* ---------- Checkout ---------- */ Future checkout(double paid, {String paymentMethod = 'tunai', Map? splitPayment, String paymentStatus = 'paid', double dueAmount = 0.0, String? customerPhone}) async { final totalCost = cart.fold(0.0, (sum, c) => sum + (c.product.hargaPokok * c.qty)); final sale = Sale( id: uid(), date: DateTime.now(), userId: userId, items: cart .map((c) => SaleItem( productId: c.product.id, name: c.product.name, price: c.price, qty: c.qty, isReseller: c.isReseller, )) .toList(), subtotal: subtotal, discount: _discount, total: total, paid: paid, change: paymentStatus == 'paid' ? (paid - total).clamp(0, double.infinity) : 0, totalCost: totalCost, paymentMethod: paymentMethod, paymentStatus: paymentStatus, dueAmount: dueAmount, customerPhone: customerPhone, splitPayment: splitPayment, ); try { await _withTimeout( _supabase.from('sales').insert(sale.toJson()..['store_id'] = _currentStoreId), 'Create sale', ); for (final line in cart) { if (line.product.id.startsWith('manual-')) continue; try { final current = products.firstWhere((p) => p.id == line.product.id); await _withTimeout( _supabase .from('products') .update({'stock': current.stock - line.qty}) .eq('id', line.product.id) .eq('store_id', _currentStoreId), 'Update stock', ); } catch (_) {} } await _loadProducts(); await _loadSales(); cart.clear(); _discount = 0; notifyListeners(); // Tandai ID transaksi lokal agar tidak di-notify lagi via polling // (admin yang jualan sendiri tidak perlu notif transaksinya sendiri). _notifiedSaleIds.add(sale.id); return sale; } catch (e) { await _offlineQueue.addToQueue({ 'sale': sale.toJson(), 'items': cart.map((c) { final lineTotalCost = c.product.hargaPokok * c.qty; return { 'product_id': c.product.id, 'qty': c.qty, 'price': c.price, 'total_cost': lineTotalCost, }; }).toList(), }); cart.clear(); _discount = 0; notifyListeners(); rethrow; } } /* ---------- Retur / Pembatalan ---------- */ Future cancelSale(String saleId) async { try { final sale = sales.firstWhere((s) => s.id == saleId); await _withTimeout( _supabase.from('sales').update({'is_cancelled': true}).eq('id', saleId).eq('store_id', _currentStoreId), 'Cancel sale', ); for (final item in sale.items) { final product = products.firstWhere( (p) => p.id == item.productId, orElse: () => Product(id: '', name: '', price: 0, stock: 0, hargaPokok: 0), ); if (product.id.isNotEmpty) { await _withTimeout( _supabase .from('products') .update({'stock': product.stock + item.qty}) .eq('id', product.id) .eq('store_id', _currentStoreId), 'Restore stock', ); } } await _loadProducts(); await _loadSales(); notifyListeners(); return null; } on PostgrestException catch (e) { return e.message; } catch (e) { return e.toString(); } } Future recordPayment(String saleId, double amount) async { try { final sale = sales.firstWhere((s) => s.id == saleId); final newPaid = sale.paid + amount; final newDue = (sale.dueAmount - amount).clamp(0, double.infinity); final newStatus = newDue <= 0 ? 'paid' : 'partial'; await _withTimeout( _supabase.from('sales').update({ 'paid': newPaid, 'due_amount': newDue, 'payment_status': newStatus, 'change': (newPaid - sale.total).clamp(0, double.infinity), }).eq('id', saleId).eq('store_id', _currentStoreId), 'Record payment', ); await _loadSales(); notifyListeners(); return null; } on PostgrestException catch (e) { return e.message; } catch (e) { return e.toString(); } } }