1184 lines
41 KiB
Dart
1184 lines
41 KiB
Dart
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<PosScreen> createState() => _PosScreenState();
|
|
}
|
|
|
|
class _PosScreenState extends ConsumerState<PosScreen> {
|
|
final FocusNode _searchFocusNode = FocusNode();
|
|
|
|
Color _searchIconColor = Colors.grey;
|
|
|
|
final DraggableScrollableController _sheetController =
|
|
DraggableScrollableController();
|
|
|
|
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
|
|
|
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<String?> _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> cartItems) {
|
|
return cartItems.length;
|
|
}
|
|
|
|
double _calculateTotalQty(List<CartItems> cartItems) {
|
|
return cartItems.fold(0.0, (sum, cartItem) {
|
|
return sum + cartItem.quantity;
|
|
});
|
|
}
|
|
|
|
double _calculateShoppingAmount(List<CartItems> 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> 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<void> _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<void> _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<void> _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<void> _removeImage() async {
|
|
setState(() {
|
|
_imagePath = null;
|
|
});
|
|
}
|
|
|
|
Future<void> _cleanUpImages() async {
|
|
for (final path in _imagePathTemps) {
|
|
if (path != null) {
|
|
await ImageService.deleteLocalImage(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _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<String, Set<String>> usedByProducts = {};
|
|
|
|
final Map<String, double> requiredMaterials = {};
|
|
|
|
final Map<String, String> materialNames = {};
|
|
|
|
final Map<String, double> currentStocks = {};
|
|
|
|
final Map<String, String> 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<String, dynamic> 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<List<DisplayProductPos>>.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(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|