import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:niogu_app/core/constants/app_color.dart'; import 'package:niogu_app/core/constants/app_font_size.dart'; import 'package:niogu_app/core/utils/login_required.dart'; import 'package:niogu_app/core/widgets/custom_error_screen.dart'; import 'package:niogu_app/core/providers/app_provider.dart'; import 'package:niogu_app/core/router/app_route.dart'; import 'package:niogu_app/core/utils/currency_format.dart'; import 'package:niogu_app/core/utils/extension_format.dart'; import 'package:niogu_app/core/utils/image_service.dart'; import 'package:niogu_app/core/utils/log_message.dart'; import 'package:niogu_app/core/enums/stock_type.dart'; import 'package:niogu_app/core/system/system_setting.dart'; import 'package:niogu_app/core/widgets/custom_empty_screen.dart'; import 'package:niogu_app/core/widgets/custom_not_login.dart'; import 'package:niogu_app/core/widgets/custom_snackbar.dart'; import 'package:niogu_app/features/goods/products/domain/entities/product.dart'; import 'package:niogu_app/features/pos/domain/entities/pos.dart'; import 'package:niogu_app/features/pos/presentation/providers/pos_provider.dart'; import 'package:niogu_app/features/pos/presentation/widgets/cart_bottom_sheet.dart'; import 'package:niogu_app/features/pos/presentation/widgets/decimal_quantity.dart'; import 'package:niogu_app/features/pos/presentation/widgets/insufficient_stock_dialog.dart'; import 'package:niogu_app/features/pos/presentation/widgets/pos_shimmer.dart'; import 'package:niogu_app/features/pos/presentation/widgets/product_card.dart'; import 'package:niogu_app/features/report/transaction/presentation/providers/transaction_report_provider.dart'; import 'package:sizer/sizer.dart'; class PosScreen extends ConsumerStatefulWidget { const PosScreen({super.key}); @override ConsumerState createState() => _PosScreenState(); } class _PosScreenState extends ConsumerState { final FocusNode _searchFocusNode = FocusNode(); Color _searchIconColor = Colors.grey; final DraggableScrollableController _sheetController = DraggableScrollableController(); final GlobalKey _formKey = GlobalKey(); final TextEditingController _discountController = TextEditingController(); final TextEditingController _taxController = TextEditingController(); final TextEditingController _noteController = TextEditingController(); final TextEditingController _nameController = TextEditingController(); final TextEditingController _phoneNumberController = TextEditingController(); final TextEditingController _addressController = TextEditingController(); final TextEditingController _payController = TextEditingController(); final List _imagePathTemps = []; final ImagePicker _picker = ImagePicker(); Timer? _debounce; bool _isOtherFormVisible = false; bool _isCustomerFormVisible = false; bool _validateChange = false; double _changeAmount = 0.0; String? _imagePath; @override void initState() { // TODO: implement initState super.initState(); _searchFocusNode.addListener(() { setState(() { _searchIconColor = _searchFocusNode.hasFocus ? Colors.black : Colors.grey; }); }); } @override void dispose() { // TODO: implement dispose _searchFocusNode.dispose(); _discountController.dispose(); _taxController.dispose(); _noteController.dispose(); _nameController.dispose(); _phoneNumberController.dispose(); _addressController.dispose(); _payController.dispose(); _debounce?.cancel(); super.dispose(); } void _expandSheet() { FocusManager.instance.primaryFocus?.unfocus(); Future.delayed(const Duration(milliseconds: 100), () { if (_sheetController.isAttached) { _sheetController.animateTo( 0.9, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } else { LogMessage.log.i("Sheet controller is not attached"); } }); } void _onSearchChanged(String value) { if (_debounce?.isActive ?? false) _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 800), () { ref.read(displayProductPosBySearchProvider.notifier).state = value; }); } int _calculateProductAmount(List cartItems) { return cartItems.length; } double _calculateTotalQty(List cartItems) { return cartItems.fold(0.0, (sum, cartItem) { return sum + cartItem.quantity; }); } double _calculateShoppingAmount(List cartItems) { return cartItems.fold(0.0, (sum, cartItem) { return sum + cartItem.quantity * cartItem.sellingPrice; }); } void _discountOnChanged(String value) { final currentOutletId = ref.read(currentOutletIdProvider); final cartItemState = ref.read(cartItemsControllerProvider); final cartItems = cartItemState.values.where((cartItem) { return cartItem.currentOutletId == currentOutletId; }).toList(); final double payAmount = double.tryParse(_payController.text.trim()) ?? 0.0; if (_debounce?.isActive ?? false) _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 400), () { setState(() { _validateChange = payAmount < _calculateTotalBill(cartItems); _discountController.text = value; }); }); if (_payController.text.trim().isNotEmpty) { _calculateChange(_payController.text); } } void _taxOnChanged(String value) { final currentOutletId = ref.read(currentOutletIdProvider); final cartItemState = ref.read(cartItemsControllerProvider); final cartItems = cartItemState.values.where((cartItem) { return cartItem.currentOutletId == currentOutletId; }).toList(); final double payAmount = double.tryParse(_payController.text.trim()) ?? 0.0; if (_debounce?.isActive ?? false) _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 400), () { setState(() { _validateChange = payAmount < _calculateTotalBill(cartItems); _taxController.text = value; }); }); if (_payController.text.trim().isNotEmpty) { _calculateChange(_payController.text); } } void _calculateChange(String value) { final currentOutletId = ref.read(currentOutletIdProvider); final cartItemState = ref.read(cartItemsControllerProvider); final cartItems = cartItemState.values.where((cartItem) { return cartItem.currentOutletId == currentOutletId; }).toList(); final double payAmount = double.tryParse(value) ?? 0.0; final double totalBill = _calculateTotalBill(cartItems); _validateChange = payAmount < totalBill; _payController.text = value; if (payAmount >= totalBill && cartItems.isNotEmpty) { _changeAmount = payAmount - totalBill; } } double _calculateTotalBill(List cartItems) { final double discount = double.tryParse(_discountController.text.trim()) ?? 0.0; final double tax = double.tryParse(_taxController.text.trim()) ?? 0.0; final double totalBiil = _calculateShoppingAmount(cartItems); return totalBiil - discount + tax; } void _handleDecrement(DisplayProductPos product) { final cartItemsNotifier = ref.read(cartItemsControllerProvider.notifier); cartItemsNotifier.decrement(product.productVariantId); if (_payController.text.trim().isNotEmpty) { _calculateChange(_payController.text); } } Future _handleIncrement(DisplayProductPos product) async { final cartItemsNotifier = ref.read(cartItemsControllerProvider.notifier); if (product.stockType == StockType.fixed) { if (product.remainingStock == 0) { CustomSnackbar.showError(context, "Stok habis"); return; } if (cartItemsNotifier.quantityOf(product.productVariantId) >= product.remainingStock) { CustomSnackbar.showError(context, "Stok tidak cukup"); return; } } await cartItemsNotifier.increment(product); if (_payController.text.isNotEmpty) { _calculateChange(_payController.text); } } void _handleDeleteItem(CartItems cartItem) { final cartItemsNotifier = ref.read(cartItemsControllerProvider.notifier); cartItemsNotifier.delete(cartItem.id); if (_payController.text.trim().isNotEmpty) { _calculateChange(_payController.text); } } void _clear() { _discountController.clear(); _taxController.clear(); _noteController.clear(); _nameController.clear(); _phoneNumberController.clear(); _addressController.clear(); _payController.clear(); } void _showInsufficientStockDialog({ required String id, required String name, required double currentStock, required double totalNeeded, required double missingAmount, required String unit, required String triggerProductName, required int otherProductsCount, }) { showDialog( context: context, builder: (context) { return InsufficientStockDialog( materialName: name, currentStock: currentStock.toStringWithoutTrailingZero(), totalNeeded: totalNeeded.toStringWithoutTrailingZero(), missingAmount: missingAmount.toStringWithoutTrailingZero(), unit: unit, triggerProductName: triggerProductName, otherProductsCount: otherProductsCount, onAddStockPressed: () { context.pushNamed(AppRoute.stockInScreen); }, onEditUsagePressed: () { context.pushNamed( AppRoute.editRawMaterialScreen, pathParameters: {"id": id}, ); }, ); }, ); } Future _showPickerOptions() async { final bool isTablet = 100.w >= 600; showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(2.5.w)), ), constraints: const BoxConstraints(maxWidth: double.infinity), builder: (BuildContext context) { return SafeArea( child: Container( width: 100.w, padding: EdgeInsets.symmetric(vertical: 2.h, horizontal: 5.w), clipBehavior: Clip.hardEdge, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(6.w)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: Icon(Icons.photo_library, size: 6.w), title: Text( 'Galeri', style: TextStyle( fontSize: isTablet ? AppFontSize.medium.sp : AppFontSize.small.sp, ), ), onTap: () async { await _getImage(ImageSource.gallery); context.pop(); }, ), SizedBox(height: 2.h), ListTile( leading: Icon(Icons.photo_camera, size: 6.w), title: Text( 'Kamera', style: TextStyle( fontSize: isTablet ? AppFontSize.medium.sp : AppFontSize.small.sp, ), ), onTap: () async { await _getImage(ImageSource.camera); context.pop(); }, ), ], ), ), ); }, ); } Future _getImage(ImageSource ImageSource) async { try { final XFile? pickedFile = await _picker.pickImage( source: ImageSource, imageQuality: 70, ); if (pickedFile != null) { File tempFile = File(pickedFile.path); String? imagePath = await ImageService.saveImageToLocalDirectory( tempFile, "payment_proof_orders", ); if (imagePath != null) { _imagePathTemps.add(imagePath); setState(() { _imagePath = imagePath; }); } } } catch (e, st) { LogMessage.log.w(e.toString(), error: e, stackTrace: st); CustomSnackbar.showWarning(context, "Akses ditolak"); } } Future _removeImage() async { setState(() { _imagePath = null; }); } Future _cleanUpImages() async { for (final path in _imagePathTemps) { if (path != null) { await ImageService.deleteLocalImage(path); } } } Future _addSale() async { final cartItemState = ref.read(cartItemsControllerProvider); final currentOutletId = await SystemSetting.getCurrentOutletId(); final cartItems = cartItemState.values.where((cartItem) { return cartItem.currentOutletId == currentOutletId; }).toList(); if (cartItems.isEmpty) { CustomSnackbar.showError(context, "Tambah minimal 1 produk"); return; } final Map> usedByProducts = {}; final Map requiredMaterials = {}; final Map materialNames = {}; final Map currentStocks = {}; final Map materialUnits = {}; for (final cartItem in cartItems) { final rawMaterials = await ref .read(posRepositoryProvider) .getProductUseMaterials(cartItem.id, cartItem.quantity); for (final rawMaterial in rawMaterials) { if (!usedByProducts.containsKey(rawMaterial.rawMaterialId)) { usedByProducts[rawMaterial.rawMaterialId] = {}; } usedByProducts[rawMaterial.rawMaterialId]!.add( cartItem.variantName ?? cartItem.name, ); final double needForThisItem = rawMaterial.productQuantity * rawMaterial.quantity; if (requiredMaterials.containsKey(rawMaterial.rawMaterialId)) { requiredMaterials[rawMaterial.rawMaterialId] = requiredMaterials[rawMaterial.rawMaterialId]! + needForThisItem; } else { requiredMaterials[rawMaterial.rawMaterialId] = needForThisItem; } cartItem.variantName ?? cartItem.name; materialNames[rawMaterial.rawMaterialId] = rawMaterial.name; currentStocks[rawMaterial.rawMaterialId] = rawMaterial.stock; materialUnits[rawMaterial.rawMaterialId] = rawMaterial.unit; } } for (final entry in requiredMaterials.entries) { final String id = entry.key; final double totalNeeded = entry.value; final double currentStock = currentStocks[id] ?? 0.0; final String name = materialNames[id] ?? 'Bahan Baku'; final String unit = materialUnits[id] ?? ''; final products = usedByProducts[id]!.toList(); final String triggerProductName = products.first; final int otherProductsCount = products.length - 1; if (currentStock < totalNeeded) { final double missingAmount = totalNeeded - currentStock; _showInsufficientStockDialog( id: id, name: name, currentStock: currentStock, totalNeeded: totalNeeded, missingAmount: missingAmount, unit: unit, triggerProductName: triggerProductName, otherProductsCount: otherProductsCount, ); return; } } if (!_formKey.currentState!.validate()) return; try { if (_discountController.text.isNotEmpty) { final double discount = double.parse(_discountController.text.trim()); if (discount <= 0) { CustomSnackbar.showError(context, "Diskon harus lebih dari 0"); return; } } } catch (e) { CustomSnackbar.showError(context, "Nominal diskon tidak valid"); return; } try { if (_taxController.text.isNotEmpty) { final double tax = double.parse(_taxController.text.trim()); if (tax <= 0) { CustomSnackbar.showError(context, "Pajak harus lebih dari 0"); return; } } } catch (e) { CustomSnackbar.showError(context, "Nominal pajak tidak valid"); return; } try { if (_payController.text.isNotEmpty && _validateChange) { final double payAmount = double.parse(_payController.text.trim()); if (payAmount <= 0) { CustomSnackbar.showError( context, "Nominal pembayaran harus lebih dari 0", ); return; } final double totalBill = _calculateTotalBill(cartItems); if (totalBill > payAmount) { CustomSnackbar.showError( context, "Kurang ${CurrencyFormat.formatToIdr((totalBill - payAmount), 0)}", ); return; } } } catch (e) { CustomSnackbar.showError(context, "Nominal pembayaran tidak valid"); return; } CustomerInformation? customer; final Map customerAddressSnapshot = {}; final bool customerInfoIsNotEmpty = _nameController.text.isNotEmpty || _phoneNumberController.text.isNotEmpty || _addressController.text.isNotEmpty; if (customerInfoIsNotEmpty) { if (_nameController.text.isEmpty) { CustomSnackbar.showError(context, "Nama pelanggan belum diisi"); return; } final name = _nameController.text.trim(); final phoneNumber = _phoneNumberController.text.trim(); final address = _addressController.text.trim(); customer = CustomerInformation( name: name, phoneNumber: phoneNumber, address: address, ); if (address.isNotEmpty) { customerAddressSnapshot.putIfAbsent("full_address", () => address); } } final selectedCustomerState = ref.read(selectedCustomerProvider); SelectedCustomer? selectedCustomer; if (selectedCustomerState?.outletId == currentOutletId) { selectedCustomer = selectedCustomerState; } if (selectedCustomer != null && selectedCustomer.address.isNotEmpty) { customerAddressSnapshot.putIfAbsent( "full_address", () => selectedCustomer!.address, ); } String? customerId; String? customerName; String? customerPhoneNumber; if (selectedCustomer != null) { customerId = selectedCustomer.id; customerName = selectedCustomer.name; customerPhoneNumber = selectedCustomer.phoneNumber; } else if (customer != null) { customerId = customer.id; customerName = customer.name; customerPhoneNumber = customer.phoneNumber; } final newSale = NewSale( customerId: customerId, customerNameSnapshot: customerName, customerPhoneNumberSnapshot: customerPhoneNumber, totalOrder: _calculateShoppingAmount(cartItems), otherInformation: OtherInformation( discount: double.tryParse(_discountController.text.trim()) ?? 0.0, tax: double.tryParse(_taxController.text.trim()) ?? 0.0, note: _noteController.text.trim(), ), totalAmount: _calculateTotalBill(cartItems), amountPaid: double.tryParse(_payController.text.trim()) ?? 0.0, changeAmount: _changeAmount, paymentProofPath: _imagePath, customerAddressSnapshot: customerAddressSnapshot, ); final itemSales = cartItems.map((item) { return ItemSale( orderId: newSale.localId, outletInventoryId: item.outletInventoryId, productVariantId: item.id, stockType: item.stockType, stock: item.remainingStock, currentSold: item.currentSold, quantity: item.quantity, costPrice: item.costPrice, productImageSnapshot: item.imagePath, productNameSnapshot: item.name, productVariantNameSnapshot: item.variantName, sellingPriceSnapshot: item.sellingPrice, subtotal: item.quantity * item.sellingPrice, ); }).toList(); try { await ref .read(posControllerProvider.notifier) .addSale(customer, newSale, itemSales); if (!mounted) return; CustomSnackbar.showSuccess(context, "Penjualan baru berhasil dibuat"); _clear(); for (final path in _imagePathTemps) { if (path != null && _imagePath != null && path != _imagePath) { await ImageService.deleteLocalImage(path); } } setState(() { _imagePath = null; _imagePathTemps.clear(); }); ref.invalidate(selectedCustomerProvider); ref.invalidate(cartItemsControllerProvider); final orderDetail = await ref .read(transactionReportRepositoryProvider) .getOrderDetail(newSale.localId); context.pushNamed( AppRoute.transactionReportOrderDetailScreen, extra: orderDetail, ); } catch (e, st) { LogMessage.log.e(e.toString(), error: e, stackTrace: st); CustomSnackbar.showError(context, "Ups, terjadi kesalahan"); } } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final bool isTablet = 100.w >= 600; final bool isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; const greyColor = Color(0xFFF5F5F5); final isLoggedIn = ref.watch(currentStatusLoginProvider); final currentUserName = ref.watch(currentUserNameProvider); final currentOutletId = ref.watch(currentOutletIdProvider); final currentOutletName = ref.watch(currentOutletNameProvider); final currentUserRole = ref.watch(currentUserRoleProvider); final productState = isLoggedIn ? ref.watch(filteredDisplayProductPosProvider) : AsyncValue>.data([]); final productEmptyState = isLoggedIn ? ref.watch(displayProductPosEmptyProvider) : ProductEmpty.empty_database; final selectedCustomerState = ref.watch(selectedCustomerProvider); final selectedCustomer = selectedCustomerState?.outletId == currentOutletId ? selectedCustomerState : null; final posControllerState = ref.watch(posControllerProvider); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; await _cleanUpImages(); context.goNamed(AppRoute.homeScreen); WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { _imagePath = null; }); }); }, child: SafeArea( top: false, bottom: true, right: false, left: false, child: Scaffold( backgroundColor: Colors.grey[50], resizeToAvoidBottomInset: false, appBar: AppBar( backgroundColor: Colors.white, surfaceTintColor: Colors.white, elevation: 0, toolbarHeight: 10.h, titleSpacing: 0, automaticallyImplyLeading: false, title: Padding( padding: EdgeInsets.symmetric(horizontal: 4.w), child: Row( children: [ IconButton( onPressed: () async { await _cleanUpImages(); context.goNamed(AppRoute.homeScreen); WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { _imagePath = null; }); }); }, icon: Icon( Icons.close_rounded, color: AppColor.primaryColor, size: 7.w, ), padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), SizedBox(width: 3.w), Expanded( child: Container( height: isTablet && isLandscape ? 12.h : 6.h, padding: isTablet ? EdgeInsets.symmetric( vertical: isLandscape ? 2.h : 1.h, horizontal: 2.w, ) : EdgeInsets.zero, decoration: BoxDecoration( color: greyColor, borderRadius: BorderRadius.circular(2.5.w), ), child: TextField( focusNode: _searchFocusNode, onChanged: (value) => _onSearchChanged(value), textAlignVertical: TextAlignVertical.center, style: TextStyle( fontSize: isTablet ? AppFontSize.medium.sp : AppFontSize.small.sp, ), decoration: InputDecoration( hintText: "Cari produk...", hintStyle: TextStyle( color: _searchIconColor, fontSize: isTablet ? AppFontSize.medium.sp : AppFontSize.small.sp, ), prefixIcon: Icon( Icons.search, color: _searchIconColor, size: 5.w, ), border: InputBorder.none, contentPadding: EdgeInsets.zero, isDense: true, ), ), ), ), SizedBox(width: 3.w), /** if (isTablet) ...[ IconButton( tooltip: isLandscape ? "Ubah ke Potrait" : "Ubah ke Landscape", icon: Icon( isLandscape ? Icons.phone_iphone_rounded : Icons.tablet_mac_rounded, size: 5.w, color: AppColor.primaryColor, ), onPressed: () { if (isLandscape) { SystemChrome.setPreferredOrientations([ DeviceOrientation.portraitUp, ]); } else { SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); } }, ), SizedBox(width: 3.w), ], */ /** Notification Stack( children: [ IconButton( onPressed: () {}, icon: Icon( Icons.notifications_outlined, color: AppColor.primaryColor, size: 7.w, ), padding: EdgeInsets.zero, ), Positioned( right: isTablet ? 10 : 100.w > 360 ? 12.5 : 15, top: isTablet ? 10 : 100.w > 360 ? 12.5 : 15, child: Container( padding: EdgeInsets.all(0.5.w), decoration: const BoxDecoration( color: Colors.red, shape: BoxShape.circle, ), constraints: BoxConstraints( minWidth: 2.5.w, minHeight: 2.5.w, ), ), ), ], ), */ ], ), ), ), body: productState.when( data: (products) { final cartItemsNotifier = ref.watch( cartItemsControllerProvider.notifier, ); final cartItemState = ref.watch(cartItemsControllerProvider); final cartItems = cartItemState.values.where((cartItem) { return cartItem.currentOutletId == currentOutletId; }).toList(); return Stack( children: [ !isLoggedIn ? const CustomNotLogin() : Padding( padding: EdgeInsets.only( left: 4.w, right: 4.w, top: 2.h, bottom: 20.h, ), child: switch (productEmptyState) { ProductEmpty.loading => const SizedBox(), ProductEmpty.empty_database => CustomEmptyScreen( title: "Tidak Ada Produk", body: "Kamu belum memiliki produk", textButton: "Buat Produk Pertamamu", onPressed: () => context.pushNamed( AppRoute.addProductScreen, ), ), ProductEmpty.empty_search_result => const CustomEmptyScreen( body: "Produk Tidak Ditemukan", ), ProductEmpty.has_data => GridView.builder( itemCount: products.length, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, childAspectRatio: 0.75, crossAxisSpacing: 4.w, mainAxisSpacing: 2.h, ), itemBuilder: (context, index) { final product = products[index]; final bool isInCart = cartItemsNotifier .isInCart(product.productVariantId); final double quantity = cartItemsNotifier .quantityOf(product.productVariantId); return ProductCard( product: product, isInCart: isInCart, quantity: quantity, onPressed: () async => await _handleIncrement(product), onDecrementTap: () => _handleDecrement(product), onIncrementTap: () async => await _handleIncrement(product), ); }, ), }, ), CartBottomSheet( isLoggedIn: isLoggedIn, sheetController: _sheetController, formKey: _formKey, currentUserName: currentUserName, currentOutletName: currentOutletName, currentUserRole: currentUserRole, cartItems: cartItems, shoppingAmount: _calculateShoppingAmount(cartItems), productAmount: _calculateProductAmount(cartItems), totalQty: _calculateTotalQty(cartItems), discountController: _discountController, taxController: _taxController, noteController: _noteController, nameController: _nameController, phoneNumberController: _phoneNumberController, addressController: _addressController, validateChange: _validateChange, totalBill: _calculateTotalBill(cartItems), onViewOrderPressed: _expandSheet, onOtherFormTap: () { setState(() { _isOtherFormVisible = !_isOtherFormVisible; }); }, onCustomerFormTap: () { setState(() { _isCustomerFormVisible = !_isCustomerFormVisible; }); }, isOtherFormVisible: _isOtherFormVisible, isCustomerFormVisible: _isCustomerFormVisible, selectedCustomer: selectedCustomer, onTapCloseSelectedCustomer: selectedCustomer == null ? null : () { ref .read(selectedCustomerProvider.notifier) .state = null; }, payController: _payController, calculateChange: (value) { if (_debounce?.isActive ?? false) _debounce?.cancel(); _debounce = Timer( const Duration(milliseconds: 400), () { final currentOutletId = ref.read( currentOutletIdProvider, ); final cartItemState = ref.read( cartItemsControllerProvider, ); final cartItems = cartItemState.values.where(( cartItem, ) { return cartItem.currentOutletId == currentOutletId; }).toList(); final double payAmount = double.tryParse(value) ?? 0.0; final double totalBill = _calculateTotalBill( cartItems, ); setState(() { _validateChange = payAmount < totalBill; _payController.text = value; if (payAmount >= totalBill && cartItems.isNotEmpty) { _changeAmount = payAmount - totalBill; } }); }, ); }, changeAmount: _changeAmount, imagePath: _imagePath, onDecrementTap: (cartItem) { final product = products.firstWhere( (p) => p.productVariantId == cartItem.id, ); _handleDecrement(product); }, onIncrementTap: (cartItem) async { final product = products.firstWhere( (p) => p.productVariantId == cartItem.id, ); await _handleIncrement(product); }, onEditPressed: (cartItem) { final String name = cartItem.name; final String variantName = cartItem.variantName ?? '-'; final stockType = cartItem.stockType; final String remainingStock = cartItem.remainingStock .toStringWithoutTrailingZero(); final String unit = cartItem.unit; final String initialQuantity = cartItem.quantity .toStringWithoutTrailingZero(); final String sellingPrice = CurrencyFormat.formatToIdr( cartItem.sellingPrice, 0, ); showDialog( context: context, builder: (_) => DecimalQuantity( name: name, variantName: variantName, stockType: stockType, remainingStock: remainingStock, unit: unit, initialQuantity: initialQuantity, sellingPrice: sellingPrice, onPressed: (value) { try { final double stock = double.parse( remainingStock, ); final double quantity = double.parse(value); if (stockType == StockType.fixed && quantity > stock) { CustomSnackbar.showError( context, "Stok tidak cukup", ); return; } final cartItemsNotifier = ref.read( cartItemsControllerProvider.notifier, ); cartItemsNotifier.customQuantity( cartItem.id, quantity, ); if (_payController.text.trim().isNotEmpty) { _calculateChange(_payController.text); } } catch (e) { CustomSnackbar.showError( context, "Stok tidak valid", ); } }, ), ); }, onDeletePressed: _handleDeleteItem, discountOnChanged: _discountOnChanged, taxOnChanged: _taxOnChanged, onTapImageAdd: !isLoggedIn ? () => LoginRequired.showLoginRequired(context) : _showPickerOptions, onTapImageRemove: _removeImage, onProccessPressed: !isLoggedIn || posControllerState.isLoading ? null : _addSale, ), ], ); }, error: (error, stackTrace) { return CustomErrorScreen( message: "Ups, terjadi kesalahan", onRefresh: () {}, ); }, loading: () => const PosShimmer(), ), ), ), ); }, ); } }