first commit

This commit is contained in:
2026-02-07 15:57:09 +07:00
commit 157096f164
1153 changed files with 415766 additions and 0 deletions

View File

@@ -0,0 +1,382 @@
// --- Nested Helper Models ---
import 'package:mobile_pos/Screens/product%20racks/model/product_racks_model.dart';
import 'package:mobile_pos/Screens/shelfs/model/shelf_list_model.dart';
import 'package:mobile_pos/Screens/warehouse/warehouse_model/warehouse_list_model.dart';
class Vat {
final int? id;
final num? rate; // Changed to num
Vat({this.id, this.rate});
factory Vat.fromJson(Map<String, dynamic> json) {
return Vat(
id: json['id'],
rate: json['rate'],
);
}
}
class Unit {
final int? id;
final String? unitName;
Unit({this.id, this.unitName});
factory Unit.fromJson(Map<String, dynamic> json) {
return Unit(
id: json['id'],
unitName: json['unitName'],
);
}
}
class Brand {
final int? id;
final String? brandName;
Brand({this.id, this.brandName});
factory Brand.fromJson(Map<String, dynamic> json) {
return Brand(
id: json['id'],
brandName: json['brandName'],
);
}
}
class Category {
final int? id;
final String? categoryName;
Category({this.id, this.categoryName});
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
id: json['id'],
categoryName: json['categoryName'],
);
}
}
class ProductModel {
final int? id;
final String? name;
ProductModel({this.id, this.name});
factory ProductModel.fromJson(Map<String, dynamic> json) {
return ProductModel(
id: json['id'],
name: json['name'],
);
}
}
class WarrantyGuaranteeInfo {
final String? warrantyDuration;
final String? warrantyUnit;
final String? guaranteeDuration;
final String? guaranteeUnit;
WarrantyGuaranteeInfo({
this.warrantyDuration,
this.warrantyUnit,
this.guaranteeDuration,
this.guaranteeUnit,
});
factory WarrantyGuaranteeInfo.fromJson(Map<String, dynamic> json) {
return WarrantyGuaranteeInfo(
warrantyDuration: json['warranty_duration'],
warrantyUnit: json['warranty_unit'],
guaranteeDuration: json['guarantee_duration'],
guaranteeUnit: json['guarantee_unit'],
);
}
}
/// Represents a specific stock/batch of a product, potentially with variants.
class Stock {
final int? id;
final int? businessId;
final int? branchId;
final int? warehouseId;
final int? productId;
final String? batchNo;
final num? productStock; // Changed to num
final num? productPurchasePrice;
final num? profitPercent; // Changed to num
final num? productSalePrice;
final num? productWholeSalePrice;
final num? productDealerPrice;
final String? serialNumbers;
// Variation data is an array of maps
final List<Map<String, dynamic>>? variationData;
final String? variantName;
final String? mfgDate;
final String? expireDate;
final String? createdAt;
final String? updatedAt;
final String? deletedAt;
final Product? product;
final WarehouseData? warehouse;
Stock({
this.id,
this.businessId,
this.branchId,
this.warehouseId,
this.productId,
this.batchNo,
this.productStock,
this.productPurchasePrice,
this.profitPercent,
this.productSalePrice,
this.productWholeSalePrice,
this.productDealerPrice,
this.serialNumbers,
this.variationData,
this.variantName,
this.mfgDate,
this.expireDate,
this.createdAt,
this.updatedAt,
this.deletedAt,
this.product,
this.warehouse,
});
factory Stock.fromJson(Map<String, dynamic> json) {
return Stock(
id: json['id'],
businessId: json['business_id'],
branchId: json['branch_id'],
warehouseId: json['warehouse_id'],
productId: json['product_id'],
batchNo: json['batch_no'],
productStock: json['productStock'],
productPurchasePrice: json['productPurchasePrice'],
profitPercent: json['profit_percent'],
productSalePrice: json['productSalePrice'],
productWholeSalePrice: json['productWholeSalePrice'],
productDealerPrice: json['productDealerPrice'],
serialNumbers: json['serial_numbers'],
variationData: (json['variation_data'] as List?)?.cast<Map<String, dynamic>>(),
variantName: json['variant_name'],
mfgDate: json['mfg_date'],
expireDate: json['expire_date'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
deletedAt: json['deleted_at'],
product: json['product'] != null ? Product.fromJson(json['product']) : null,
warehouse: json['warehouse'] != null ? WarehouseData.fromJson(json['warehouse']) : null,
);
}
}
/// Represents a component product within a 'combo' product.
class ComboProductComponent {
final int? id;
final int? productId;
final int? stockId;
final num? purchasePrice;
final num? quantity; // Changed to num
final Stock? stock;
ComboProductComponent({
this.id,
this.productId,
this.stockId,
this.purchasePrice,
this.quantity,
this.stock,
});
factory ComboProductComponent.fromJson(Map<String, dynamic> json) {
return ComboProductComponent(
id: json['id'],
productId: json['product_id'],
stockId: json['stock_id'],
purchasePrice: json['purchase_price'],
quantity: json['quantity'],
stock: json['stock'] != null ? Stock.fromJson(json['stock']) : null,
);
}
}
// --- Main Product Model ---
/// Represents a single product entity.
class Product {
final int? id;
final String? productName;
final int? businessId;
final int? rackId;
final int? shelfId;
final int? unitId;
final int? brandId;
final int? categoryId;
final String? productCode;
final WarrantyGuaranteeInfo? warrantyGuaranteeInfo;
// variation_ids is a List<String> or null
final List<String>? variationIds;
final String? productPicture;
final String? productType;
final num? productDealerPrice;
final num? totalLossProfit;
final num? productPurchasePrice;
final num? totalSaleAmount;
final num? productSalePrice;
final num? saleCount;
final num? purchaseCount;
final num? productWholeSalePrice;
final num? productStock; // Changed to num
final String? expireDate;
final num? alertQty; // Changed to num
final num? profitPercent; // Changed to num
final num? vatAmount;
final String? vatType;
final String? size;
final String? type;
final String? color;
final String? weight;
final String? capacity;
final String? productManufacturer;
final dynamic meta; // Use 'dynamic' for unstructured JSON
final String? createdAt;
final String? updatedAt;
final int? vatId;
final int? modelId;
final int? warehouseId;
final num? stocksSumProductStock; // Changed to num
// Relationships (Nested Objects/Lists)
final Unit? unit;
final Vat? vat;
final Brand? brand;
final Category? category;
final ProductModel? productModel;
final List<Stock>? stocks;
final RackData? rack;
final ShelfData? shelf;
final List<ComboProductComponent>? comboProducts;
Product({
this.id,
this.productName,
this.businessId,
this.rackId,
this.shelfId,
this.unitId,
this.brandId,
this.categoryId,
this.productCode,
this.totalLossProfit,
this.warrantyGuaranteeInfo,
this.variationIds,
this.productPicture,
this.productType,
this.productDealerPrice,
this.saleCount,
this.purchaseCount,
this.productPurchasePrice,
this.productSalePrice,
this.productWholeSalePrice,
this.totalSaleAmount,
this.productStock,
this.expireDate,
this.alertQty,
this.profitPercent,
this.vatAmount,
this.vatType,
this.size,
this.type,
this.color,
this.weight,
this.capacity,
this.productManufacturer,
this.meta,
this.createdAt,
this.updatedAt,
this.vatId,
this.modelId,
this.warehouseId,
this.stocksSumProductStock,
this.unit,
this.vat,
this.brand,
this.category,
this.productModel,
this.stocks,
this.comboProducts,
this.rack,
this.shelf,
});
factory Product.fromJson(Map<String, dynamic> json) {
// Helper function to safely map lists, returning null if the source is null
List<T>? _mapList<T>(List? list, T Function(Map<String, dynamic>) fromJson) {
return list?.map((i) => fromJson(i as Map<String, dynamic>)).toList();
}
return Product(
id: json['id'],
productName: json['productName'],
businessId: json['business_id'],
rackId: json['rack_id'],
shelfId: json['shelf_id'],
unitId: json['unit_id'],
brandId: json['brand_id'],
categoryId: json['category_id'],
productCode: json['productCode'],
warrantyGuaranteeInfo: json['warranty_guarantee_info'] != null
? WarrantyGuaranteeInfo.fromJson(json['warranty_guarantee_info'])
: null,
variationIds: (json['variation_ids'] as List?)?.cast<String>(),
productPicture: json['productPicture'],
totalLossProfit: json['total_profit_loss'],
productType: json['product_type'],
productDealerPrice: json['productDealerPrice'],
totalSaleAmount: json['total_sale_amount'],
saleCount: json['sale_details_sum_quantities'],
purchaseCount: json['purchase_details_sum_quantities'],
productPurchasePrice: json['productPurchasePrice'],
productSalePrice: json['productSalePrice'],
productWholeSalePrice: json['productWholeSalePrice'],
productStock: json['productStock'],
expireDate: json['expire_date'],
alertQty: json['alert_qty'],
profitPercent: json['profit_percent'],
vatAmount: json['vat_amount'],
vatType: json['vat_type'],
size: json['size'],
type: json['type'],
color: json['color'],
weight: json['weight'],
capacity: json['capacity'],
productManufacturer: json['productManufacturer'],
meta: json['meta'],
createdAt: json['created_at'],
updatedAt: json['updated_at'],
vatId: json['vat_id'],
modelId: json['model_id'],
warehouseId: json['warehouse_id'],
stocksSumProductStock: json['stocks_sum_product_stock'],
// Nested Relationships
unit: json['unit'] != null ? Unit.fromJson(json['unit']) : null,
shelf: json['shelf'] != null ? ShelfData.fromJson(json['shelf']) : null,
rack: json['rack'] != null ? RackData.fromJson(json['rack']) : null,
vat: json['vat'] != null ? Vat.fromJson(json['vat']) : null,
brand: json['brand'] != null ? Brand.fromJson(json['brand']) : null,
category: json['category'] != null ? Category.fromJson(json['category']) : null,
productModel: json['product_model'] != null ? ProductModel.fromJson(json['product_model']) : null,
// Lists of Nested Objects
stocks: _mapList<Stock>(json['stocks'] as List?, Stock.fromJson),
comboProducts: _mapList<ComboProductComponent>(json['combo_products'] as List?, ComboProductComponent.fromJson),
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:mobile_pos/Screens/Products/Model/product_model.dart';
class ProductListResponse {
final double totalStockValue;
final List<Product> products;
ProductListResponse({
required this.totalStockValue,
required this.products,
});
factory ProductListResponse.fromJson(Map<String, dynamic> json) {
return ProductListResponse(
totalStockValue: (json['total_stock_value'] as num).toDouble(),
products: (json['data'] as List).map((item) => Product.fromJson(item)).toList(),
);
}
}

View File

@@ -0,0 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_pos/Screens/Products/Model/product_model.dart';
import 'package:mobile_pos/Screens/Products/Model/product_total_stock_model.dart';
import '../Repo/product_repo.dart';
ProductRepo productRepo = ProductRepo();
final productListProvider = FutureProvider<ProductListResponse>((ref) async {
final response = await productRepo.fetchProducts();
return response;
});

View File

@@ -0,0 +1,448 @@
//ignore_for_file: file_names, unused_element, unused_local_variable
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:mobile_pos/Provider/product_provider.dart';
import 'package:mobile_pos/Screens/Products/Model/product_total_stock_model.dart';
import 'package:mobile_pos/service/check_user_role_permission_provider.dart';
import 'package:nb_utils/nb_utils.dart';
import '../../../Const/api_config.dart';
import '../../../Repository/constant_functions.dart';
import '../../../constant.dart';
import '../../../core/constant_variables/local_data_saving_keys.dart';
import '../../../http_client/custome_http_client.dart';
import '../../../http_client/customer_http_client_get.dart';
import '../../Purchase/Repo/purchase_repo.dart';
import '../Model/product_model.dart';
import '../add product/modle/create_product_model.dart';
class ProductRepo {
// ==============================================================================
// NEW CREATE PRODUCT FUNCTION
// ==============================================================================
Future<bool> createProduct({required CreateProductModel data, required BuildContext context, required WidgetRef ref}) async {
return _submitProductData(data: data, isUpdate: false, context: context, ref: ref);
}
// ==============================================================================
// NEW UPDATE PRODUCT FUNCTION
// ==============================================================================
Future<bool> updateProduct({required CreateProductModel data, required BuildContext context, required WidgetRef ref}) async {
return _submitProductData(data: data, isUpdate: true, context: context, ref: ref);
}
/// Shared Logic for Create and Update to avoid code duplication
Future<bool> _submitProductData({required CreateProductModel data, required bool isUpdate, required BuildContext context, required WidgetRef ref}) async {
EasyLoading.show(status: isUpdate ? 'Updating Product...' : 'Creating Product...');
final url = Uri.parse(isUpdate ? '${APIConfig.url}/products/${data.productId}' : '${APIConfig.url}/products');
var request = http.MultipartRequest('POST', url);
request.headers.addAll({
'Accept': 'application/json',
'Authorization': await getAuthToken(),
});
// Helper to safely add simple string fields
void addField(String key, dynamic value) {
if (value != null && value.toString().isNotEmpty && value.toString() != 'null') {
request.fields[key] = value.toString();
}
}
// --- 1. Standard Fields ---
if (isUpdate) addField('_method', 'put');
addField('productName', data.name);
addField('category_id', data.categoryId);
addField('unit_id', data.unitId);
addField('productCode', data.productCode);
addField('brand_id', data.brandId);
addField('model_id', data.modelId);
addField('rack_id', data.rackId);
addField('shelf_id', data.shelfId);
addField('alert_qty', data.alertQty);
// Serial logic (1 or 0)
// addField('has_serial', (data.hasSerial == '1' || data.hasSerial == 'true') ? '1' : '0');
addField('product_type', data.productType); // single, variant, combo
addField('vat_type', data.vatType);
addField('vat_id', data.vatId);
// Optional: vat_amount if backend calculates it or needs it
if (data.vatAmount != null) addField('vat_amount', data.vatAmount);
// Extra info
addField('productManufacturer', data.productManufacturer);
addField('productDiscount', data.productDiscount);
// --- 2. Complex Fields (JSON Encoded) ---
// A. STOCKS
// This handles Single (1 item in list) and Variant (multiple items in list)
if (data.stocks != null && data.stocks!.isNotEmpty) {
// Convert list of StockDataModel to List of Maps
List<Map<String, dynamic>> stockListJson = data.stocks!.map((stock) => stock.toJson()).toList();
// Encode to JSON String
request.fields['stocks'] = jsonEncode(stockListJson);
}
// B. VARIATION IDs (Only for variant type)
if (data.productType?.toLowerCase() == 'variant' && (data.variationIds?.isNotEmpty ?? false)) {
request.fields['variation_ids'] = jsonEncode(data.variationIds);
}
// C. COMBO PRODUCTS (Only for combo type)
if (data.productType?.toLowerCase() == 'combo' && (data.comboProducts?.isNotEmpty ?? false)) {
request.fields['combo_products'] = jsonEncode(data.comboProducts);
addField('profit_percent', data.comboProfitPercent);
addField('productSalePrice', data.comboProductSalePrice);
}
// D. WARRANTY & GUARANTEE
Map<String, String> warrantyInfo = {};
if (data.warrantyDuration != null && data.warrantyDuration!.isNotEmpty) {
warrantyInfo['warranty_duration'] = data.warrantyDuration!;
warrantyInfo['warranty_unit'] = data.warrantyPeriod ?? 'days';
}
if (data.guaranteeDuration != null && data.guaranteeDuration!.isNotEmpty) {
warrantyInfo['guarantee_duration'] = data.guaranteeDuration!;
warrantyInfo['guarantee_unit'] = data.guaranteePeriod ?? 'days';
}
if (warrantyInfo.isNotEmpty) {
request.fields['warranty_guarantee_info'] = jsonEncode(warrantyInfo);
}
// --- 3. File Upload ---
if (data.image != null) {
request.files.add(await http.MultipartFile.fromPath(
'productPicture',
data.image!.path,
filename: data.image!.path.split('/').last,
));
}
// --- Debugging Logs ---
print('URL: $url');
print('--- Fields ---');
request.fields.forEach((key, value) {
print('$key: $value');
});
print('--- Fields ---');
print(request.fields);
// --- 4. Execute ---
try {
// var response = await request.send();
CustomHttpClient customHttpClient = CustomHttpClient(client: http.Client(), ref: ref, context: context);
print('Product image: ${data.image?.path}');
final response = await customHttpClient.uploadFile(
url: url,
file: data.image,
fileFieldName: 'productPicture',
fields: request.fields,
);
var responseData = await http.Response.fromStream(response);
EasyLoading.dismiss();
print("Response Status: ${response.statusCode}");
print("Response Body: ${responseData.body}");
if (response.statusCode == 200 || response.statusCode == 201) {
try {
var body = jsonDecode(responseData.body);
EasyLoading.showSuccess(body['message'] ?? (isUpdate ? 'Updated successfully!' : 'Created successfully!'));
return true;
} catch (e) {
// If JSON parsing fails but status is 200
EasyLoading.showSuccess(isUpdate ? 'Product updated!' : 'Product created!');
return true;
}
} else {
try {
var body = jsonDecode(responseData.body);
EasyLoading.showError(body['message'] ?? 'Failed to process product');
} catch (e) {
EasyLoading.showError('Failed with status: ${response.statusCode}');
}
return false;
}
} catch (e) {
EasyLoading.dismiss();
EasyLoading.showError('Network Error: ${e.toString()}');
print(e.toString());
return false;
}
}
Future<String?> generateProductCode() async {
final uri = Uri.parse('${APIConfig.url}/product/generate-code');
CustomHttpClientGet clientGet = CustomHttpClientGet(client: http.Client());
try {
final response = await clientGet.get(url: uri);
if (response.statusCode == 200) {
final jsonResponse = json.decode(response.body);
return jsonResponse['data'].toString();
} else {
return null;
}
} catch (e) {
return null;
}
}
Future<List<Product>> fetchAllProducts() async {
CustomHttpClientGet clientGet = CustomHttpClientGet(client: http.Client());
final uri = Uri.parse('${APIConfig.url}/products');
final response = await clientGet.get(url: uri);
if (response.statusCode == 200) {
final parsedData = jsonDecode(response.body) as Map<String, dynamic>;
final partyList = parsedData['data'] as List<dynamic>;
return partyList.map((category) => Product.fromJson(category)).toList();
// Parse into Party objects
} else {
throw Exception('Failed to fetch Products');
}
}
Future<ProductListResponse> fetchProducts() async {
CustomHttpClientGet clientGet = CustomHttpClientGet(client: http.Client());
final uri = Uri.parse('${APIConfig.url}/products');
final response = await clientGet.get(url: uri);
if (response.statusCode == 200) {
final parsedData = jsonDecode(response.body);
return ProductListResponse.fromJson(parsedData);
} else {
throw Exception('Failed to fetch products');
}
}
// Fetch Product Details
Future<Product> fetchProductDetails({required String productID}) async {
CustomHttpClientGet clientGet = CustomHttpClientGet(client: http.Client());
final url = Uri.parse('${APIConfig.url}/products/$productID');
try {
var response = await clientGet.get(url: url);
EasyLoading.dismiss();
print(response.statusCode);
print(response.body);
if (response.statusCode == 200) {
var jsonData = jsonDecode(response.body);
return Product.fromJson(jsonData['data']);
} else {
var data = jsonDecode(response.body);
EasyLoading.showError(data['message'] ?? 'Failed to fetch details');
throw Exception(data['message'] ?? 'Failed to fetch details');
}
} catch (e) {
// Hide loading indicator and show error
EasyLoading.dismiss();
EasyLoading.showError('Error: ${e.toString()}');
throw Exception('Error: ${e.toString()}');
}
}
Future<void> deleteProduct({
required String id,
required BuildContext context,
required WidgetRef ref,
}) async {
final String apiUrl = '${APIConfig.url}/products/$id';
try {
CustomHttpClient customHttpClient = CustomHttpClient(
ref: ref,
context: context,
client: http.Client(),
);
final response = await customHttpClient.delete(
url: Uri.parse(apiUrl),
permission: Permit.productsDelete.value,
);
EasyLoading.dismiss();
// 👇 Print full response info
print('Delete Product Response:');
print('Status Code: ${response.statusCode}');
print('Body: ${response.body}');
print('Headers: ${response.headers}');
if (response.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Product deleted successfully')),
);
ref.refresh(productProvider);
} else {
final parsedData = jsonDecode(response.body);
final errorMessage = parsedData['error'].toString().replaceFirst('Exception: ', '');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
backgroundColor: kMainColor,
),
);
}
} catch (e) {
print('rrrr');
EasyLoading.dismiss();
print('Exception during product delete: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
Future<bool> addStock({required String id, required String qty}) async {
final url = Uri.parse('${APIConfig.url}/stocks');
String token = await getAuthToken() ?? '';
final headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token,
};
final requestBody = jsonEncode({
"stock_id": id,
"productStock": qty,
});
try {
final response = await http.post(url, headers: headers, body: requestBody);
if (response.statusCode == 200) {
return true;
} else {
final data = jsonDecode(response.body);
EasyLoading.showError(data['message'] ?? 'Unknown error');
return false;
}
} catch (e) {
EasyLoading.showError('Error: ${e.toString()}');
return false;
}
}
Future<bool> updateVariation({required CartProductModelPurchase data}) async {
EasyLoading.show(status: 'Updating Product...');
final url = Uri.parse('${APIConfig.url}/stocks/${data.variantName}');
var request = http.MultipartRequest('POST', url);
request.headers.addAll({
'Accept': 'application/json',
'Authorization': await getAuthToken(),
});
void addField(String key, dynamic value) {
if (value != null && value.toString().isNotEmpty && value.toString() != 'null') {
request.fields[key] = value.toString();
}
}
// Add standard fields
addField('_method', 'put');
addField('batch_no', data.batchNumber);
addField('productStock', data.quantities);
addField('productPurchasePrice', data.productPurchasePrice);
addField('profit_percent', data.profitPercent);
addField('productSalePrice', data.productSalePrice);
addField('productWholeSalePrice', data.productWholeSalePrice);
addField('productDealerPrice', data.productDealerPrice);
addField('mfg_date', data.mfgDate);
addField('expire_date', data.expireDate);
print('--- Product Data Fields ---');
print('Total fields: ${request.fields.length}');
print(data.mfgDate);
request.fields.forEach((key, value) {
print('$key: $value');
});
try {
var response = await request.send();
var responseData = await http.Response.fromStream(response);
print('Response Status Code: ${response.statusCode}');
print('Response Body: ${responseData.body}');
EasyLoading.dismiss();
if (response.statusCode == 200) {
try {
var body = jsonDecode(responseData.body);
EasyLoading.showSuccess(body['message'] ?? 'Product update successfully!');
return true;
} catch (e) {
EasyLoading.showSuccess('Product update successfully!');
return true;
}
} else {
try {
var body = jsonDecode(responseData.body);
EasyLoading.showError(body['message'] ?? 'Failed to update product');
print('Error Response: ${responseData.body}');
} catch (e) {
EasyLoading.showError('Failed to update product. Status: ${response.statusCode}');
print('Error Response (non-JSON): ${responseData.body}');
}
return false;
}
} catch (e) {
EasyLoading.dismiss();
EasyLoading.showError('Network Error: ${e.toString()}');
print('Network Error: ${e.toString()}');
return false;
}
}
Future<bool> deleteStock({required String id}) async {
EasyLoading.show(status: 'Processing');
final prefs = await SharedPreferences.getInstance();
String token = prefs.getString(LocalDataBaseSavingKey.tokenKey) ?? '';
final url = Uri.parse('${APIConfig.url}/stocks/$id');
final headers = {
'Accept': 'application/json',
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
try {
var response = await http.delete(
url,
headers: headers,
);
EasyLoading.dismiss();
print(response.statusCode);
if (response.statusCode == 200) {
return true;
} else {
var data = jsonDecode(response.body);
EasyLoading.showError(data['message'] ?? 'Failed to delete');
print(data['message']);
return false;
}
} catch (e) {
EasyLoading.dismiss();
EasyLoading.showError('Error: ${e.toString()}');
print(e.toString());
return false;
}
}
}

View File

@@ -0,0 +1,154 @@
// ignore_for_file: file_names, unused_element, unused_local_variable
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../../Const/api_config.dart';
import '../../../Repository/constant_functions.dart';
import '../../../http_client/custome_http_client.dart';
import '../../../http_client/customer_http_client_get.dart';
import '../../product_unit/model/unit_model.dart';
import '../../product_unit/provider/product_unit_provider.dart';
class UnitsRepo {
Future<List<Unit>> fetchAllUnits() async {
CustomHttpClientGet clientGet = CustomHttpClientGet(client: http.Client());
final uri = Uri.parse('${APIConfig.url}/units');
try {
final response = await clientGet.get(url: uri);
if (response.statusCode == 200) {
final parsedData = jsonDecode(response.body) as Map<String, dynamic>;
final categoryList = parsedData['data'] as List<dynamic>;
return categoryList.map((unit) => Unit.fromJson(unit)).toList();
} else {
throw Exception('Failed to fetch units: ${response.statusCode}');
}
} catch (error) {
rethrow;
}
}
Future<void> addUnit({
required WidgetRef ref,
required BuildContext context,
required String name,
}) async {
final uri = Uri.parse('${APIConfig.url}/units');
try {
CustomHttpClient customHttpClient = CustomHttpClient(client: http.Client(), context: context, ref: ref);
var responseData = await customHttpClient.post(
url: uri,
body: {
'unitName': name,
},
// addContentTypeInHeader: true,
);
final parsedData = jsonDecode(responseData.body);
if (responseData.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Added successful!')));
var data1 = ref.refresh(unitsProvider);
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Unit creation failed: ${parsedData['message']}')));
}
} catch (error) {
// Handle unexpected errors gracefully
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('An error occurred: $error')));
}
}
Future<num?> addUnitForBulk({
required String name,
}) async {
final uri = Uri.parse('${APIConfig.url}/units');
try {
var responseData = await http.post(uri, headers: {
"Accept": 'application/json',
'Authorization': await getAuthToken(),
}, body: {
'unitName': name,
});
final parsedData = jsonDecode(responseData.body);
if (responseData.statusCode == 200) {
return parsedData['data']['id'];
} else {
return null;
}
} catch (error) {
return null;
}
}
///_______Edit_Add_________________________________________
Future<void> editUnit({
required WidgetRef ref,
required BuildContext context,
required num id,
required String name,
}) async {
final uri = Uri.parse('${APIConfig.url}/units/$id');
try {
CustomHttpClient customHttpClient = CustomHttpClient(client: http.Client(), context: context, ref: ref);
var responseData = await customHttpClient.post(
url: uri,
body: {
'unitName': name,
'_method': 'put',
},
);
final parsedData = jsonDecode(responseData.body);
if (responseData.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('update successful!')));
var data1 = ref.refresh(unitsProvider);
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Unit creation failed: ${parsedData['message']}')));
}
} catch (error) {
// Handle unexpected errors gracefully
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('An error occurred: $error')));
}
}
///_________delete_unit________________________
Future<bool> deleteUnit({required BuildContext context, required num unitId, required WidgetRef ref}) async {
final String apiUrl = '${APIConfig.url}/units/$unitId'; // Replace with your API URL
try {
CustomHttpClient customHttpClient = CustomHttpClient(ref: ref, context: context, client: http.Client());
final response = await customHttpClient.delete(
url: Uri.parse(apiUrl),
);
if (response.statusCode == 200) {
final responseData = json.decode(response.body);
final String message = responseData['message'];
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
return true;
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete unit.')),
);
return false;
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('An error occurred.')),
);
return false;
}
}
}

View File

@@ -0,0 +1,217 @@
import 'dart:ui';
import 'package:mobile_pos/generated/l10n.dart' as lang;
import 'package:flutter/material.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:mobile_pos/Screens/Products/Widgets/selected_button.dart';
import 'package:mobile_pos/constant.dart';
class AcnooMultiSelectDropdown<T> extends StatefulWidget {
AcnooMultiSelectDropdown({
super.key,
this.decoration,
this.menuItemStyleData,
this.buttonStyleData,
this.iconStyleData,
this.dropdownStyleData,
required this.items,
this.values,
this.onChanged,
required this.labelText,
}) : assert(
items.isEmpty ||
values == null ||
items.where((item) {
return values.contains(item.value);
}).length ==
values.length,
"There should be exactly one item with [AcnooMultiSelectDropdown]'s value in the items list. "
'Either zero or 2 or more [MultiSelectDropdownMenuItem]s were detected with the same value',
);
final List<MultiSelectDropdownMenuItem<T?>> items;
final List<T?>? values;
final void Function(List<T>? values)? onChanged;
final InputDecoration? decoration;
final MenuItemStyleData? menuItemStyleData;
final ButtonStyleData? buttonStyleData;
final IconStyleData? iconStyleData;
final DropdownStyleData? dropdownStyleData;
final String labelText;
@override
State<AcnooMultiSelectDropdown<T>> createState() => _AcnooMultiSelectDropdownState<T>();
}
class _AcnooMultiSelectDropdownState<T> extends State<AcnooMultiSelectDropdown<T>> {
bool isOpen = false;
void listenMenuChange(bool value) {
setState(() {
isOpen = value;
if (!value) {
widget.onChanged?.call(
selectedItems.map((e) => e.value!).toList(),
);
}
});
}
late List<MultiSelectDropdownMenuItem<T?>> selectedItems;
@override
void initState() {
super.initState();
selectedItems = widget.items.where((element) => widget.values?.contains(element.value) ?? false).toList();
}
@override
Widget build(BuildContext context) {
final _theme = Theme.of(context);
return DropdownButtonFormField2<T>(
decoration: (widget.decoration ?? const InputDecoration()).copyWith(
labelText: widget.labelText,
hintText: '',
),
menuItemStyleData: widget.menuItemStyleData ?? const MenuItemStyleData(),
buttonStyleData: widget.buttonStyleData ?? const ButtonStyleData(),
iconStyleData: widget.iconStyleData ?? const IconStyleData(),
dropdownStyleData: widget.dropdownStyleData ?? const DropdownStyleData(),
onMenuStateChange: listenMenuChange,
customButton: _buildCustomButton(context),
items: widget.items.map((item) {
return DropdownMenuItem<T>(
value: item.value,
enabled: false,
child: _buildMenuItem(context, item, _theme),
);
}).toList(),
onChanged: (_) {},
);
}
// --------------- CHANGE IS HERE ---------------- //
Widget _buildCustomButton(BuildContext context) {
const _itemPadding = EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
);
if (selectedItems.isEmpty) {
final _iconWidget = widget.iconStyleData?.icon ?? Icon(Icons.keyboard_arrow_down_outlined);
return Padding(
padding: _itemPadding,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.decoration?.hintText ?? lang.S.of(context).selectItems,
style: widget.decoration?.hintStyle,
),
_iconWidget,
],
),
);
}
return ScrollConfiguration(
behavior: const ScrollBehavior().copyWith(
dragDevices: {
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.touch,
},
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: selectedItems.reversed.map((item) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: SelectedItemButton(
padding: _itemPadding,
labelText: item.labelText,
onTap: () {
// 1. Remove item from local state
setState(() {
selectedItems.remove(item);
});
// 2. Trigger the onChanged callback immediately
widget.onChanged?.call(
selectedItems.map((e) => e.value!).toList(),
);
},
),
);
}).toList(),
),
),
);
}
// ----------------------------------------------- //
Widget _buildMenuItem(
BuildContext context,
MultiSelectDropdownMenuItem<T?> item,
ThemeData _theme,
) {
return StatefulBuilder(
builder: (context, itemState) {
final _isSelected = selectedItems.contains(item);
return InkWell(
onTap: () {
_isSelected ? selectedItems.remove(item) : selectedItems.add(item);
widget.onChanged?.call(
selectedItems.map((e) => e.value!).toList(),
);
setState(() {});
itemState(() {});
},
child: Container(
constraints: const BoxConstraints(minHeight: 48),
alignment: AlignmentDirectional.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(item.labelText),
),
if (_isSelected)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle,
color: kMainColor,
),
const SizedBox(width: 8),
],
),
],
),
),
);
},
);
}
}
class MultiSelectDropdownMenuItem<T> {
MultiSelectDropdownMenuItem({
required this.labelText,
this.value,
});
final String labelText;
final T? value;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MultiSelectDropdownMenuItem<T> &&
runtimeType == other.runtimeType &&
labelText == other.labelText &&
value == other.value;
@override
int get hashCode => labelText.hashCode ^ value.hashCode;
}

View File

@@ -0,0 +1,228 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart';
import 'package:mobile_pos/constant.dart';
class AcnooDropdownStyle {
AcnooDropdownStyle(this.context);
final BuildContext context;
// Theme
ThemeData get _theme => Theme.of(context);
bool get _isDark => _theme.brightness == Brightness.dark;
// Button Style
ButtonStyleData get buttonStyle => const ButtonStyleData(width: 0);
// Dropdown Style
AcnooDropdownStyleData get dropdownStyle {
return AcnooDropdownStyleData(
maxHeight: 300,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: _theme.colorScheme.primaryContainer,
),
);
}
// Icon Style
AcnooDropdownIconData get iconStyle {
return AcnooDropdownIconData(
icon: Icon(
Icons.keyboard_arrow_down,
color: _isDark ? Colors.white : kMainColor,
),
);
}
// Menu Style
AcnooDropdownMenuItemStyleData get menuItemStyle {
return AcnooDropdownMenuItemStyleData(
overlayColor: WidgetStateProperty.all<Color>(
kMainColor.withValues(alpha: 0.25),
),
selectedMenuItemBuilder: (context, child) => DecoratedBox(
decoration: BoxDecoration(
color: kMainColor.withValues(alpha: 0.125),
),
child: child,
),
);
}
MenuItemStyleData get multiSelectMenuItemStyle {
return MenuItemStyleData(
overlayColor: WidgetStateProperty.all<Color>(
kMainColor.withValues(alpha: 0.25),
),
selectedMenuItemBuilder: (context, child) => DecoratedBox(
decoration: BoxDecoration(
color: kMainColor.withValues(alpha: 0.125),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
child,
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle,
color: kMainColor,
),
const SizedBox(width: 8),
],
),
],
),
),
);
}
// Text Style
TextStyle? get textStyle => _theme.textTheme.bodyLarge;
/*
DropdownMenuItem<T> firstItem<T>({
required String title,
required String actionTitle,
void Function()? onTap,
T? value,
bool enabled = false,
}) {
return DropdownMenuItem(
value: value,
enabled: enabled,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: AcnooTextStyle.kSubtitleText.copyWith(
fontSize: 16,
color: AcnooAppColors.k03,
),
),
Text.rich(
TextSpan(
text: actionTitle,
recognizer: TapGestureRecognizer()..onTap = onTap,
),
style: AcnooTextStyle.kSubtitleText.copyWith(
fontSize: 14,
color: AcnooAppColors.kPrimary,
),
),
],
),
);
}
*/
}
@immutable
class AcnooDropdownStyleData extends DropdownStyleData {
const AcnooDropdownStyleData({
super.maxHeight,
super.width,
super.padding,
super.scrollPadding,
super.decoration,
super.elevation,
super.direction,
super.offset,
super.isOverButton,
super.useSafeArea,
super.isFullScreen,
super.useRootNavigator,
super.scrollbarTheme,
super.openInterval,
});
AcnooDropdownStyleData copyWith({
double? maxHeight,
double? width,
EdgeInsetsGeometry? padding,
EdgeInsetsGeometry? scrollPadding,
BoxDecoration? decoration,
int? elevation,
DropdownDirection? direction,
Offset? offset,
bool? isOverButton,
bool? useSafeArea,
bool? isFullScreen,
bool? useRootNavigator,
ScrollbarThemeData? scrollbarTheme,
Interval? openInterval,
}) {
return AcnooDropdownStyleData(
maxHeight: maxHeight ?? this.maxHeight,
width: width ?? this.width,
padding: padding ?? this.padding,
scrollPadding: scrollPadding ?? this.scrollPadding,
decoration: decoration ?? this.decoration,
elevation: elevation ?? this.elevation,
direction: direction ?? this.direction,
offset: offset ?? this.offset,
isOverButton: isOverButton ?? this.isOverButton,
useSafeArea: useSafeArea ?? this.useSafeArea,
isFullScreen: isFullScreen ?? this.useRootNavigator,
useRootNavigator: useRootNavigator ?? this.useRootNavigator,
scrollbarTheme: scrollbarTheme ?? this.scrollbarTheme,
openInterval: openInterval ?? this.openInterval,
);
}
}
@immutable
class AcnooDropdownIconData extends IconStyleData {
const AcnooDropdownIconData({
super.icon,
super.iconDisabledColor,
super.iconEnabledColor,
super.iconSize,
super.openMenuIcon,
});
AcnooDropdownIconData copyWith({
Widget? icon,
Color? iconDisabledColor,
Color? iconEnabledColor,
double? iconSize,
Widget? openMenuIcon,
}) {
return AcnooDropdownIconData(
icon: icon ?? this.icon,
iconDisabledColor: iconDisabledColor ?? this.iconDisabledColor,
iconEnabledColor: iconEnabledColor ?? this.iconEnabledColor,
iconSize: iconSize ?? this.iconSize,
openMenuIcon: openMenuIcon ?? this.openMenuIcon,
);
}
}
@immutable
class AcnooDropdownMenuItemStyleData extends MenuItemStyleData {
const AcnooDropdownMenuItemStyleData({
super.customHeights,
super.height,
super.overlayColor,
super.padding,
super.selectedMenuItemBuilder,
});
AcnooDropdownMenuItemStyleData copyWith({
List<double>? customHeights,
double? height,
Color? overlayColor,
EdgeInsetsGeometry? padding,
Widget Function(BuildContext, Widget)? selectedMenuItemBuilder,
}) {
return AcnooDropdownMenuItemStyleData(
customHeights: customHeights ?? this.customHeights,
height: height ?? this.height,
overlayColor: overlayColor != null ? WidgetStateProperty.all<Color?>(overlayColor) : this.overlayColor,
selectedMenuItemBuilder: selectedMenuItemBuilder ?? this.selectedMenuItemBuilder,
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:mobile_pos/constant.dart';
class SelectedItemButton extends StatelessWidget {
const SelectedItemButton({
super.key,
required this.labelText,
this.padding,
this.onTap,
this.showCloseButton = true,
});
final String labelText;
final EdgeInsetsGeometry? padding;
final void Function()? onTap;
final bool showCloseButton;
@override
Widget build(BuildContext context) {
final _theme = Theme.of(context);
return Container(
padding: padding ??
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: kDarkWhite,
borderRadius: BorderRadius.circular(4),
),
child: Text.rich(
TextSpan(
text: labelText,
style: _theme.textTheme.titleSmall?.copyWith(
color: kTitleColor,
),
children: [
if (showCloseButton)
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: InkWell(
onTap: onTap,
child: const Icon(
Icons.close,
size: 12,
color: Colors.black,
),
),
),
),
],
),
style: TextStyle(color: _theme.colorScheme.onPrimary),
),
);
}
}

View File

@@ -0,0 +1,37 @@
// 🐦 Flutter imports:
import 'package:flutter/material.dart';
import 'package:mobile_pos/generated/l10n.dart' as lang;
class TextFieldLabelWrapper extends StatelessWidget {
const TextFieldLabelWrapper({
super.key,
this.labelText,
this.label,
this.labelStyle,
required this.inputField,
});
final String? labelText;
final Widget? label;
final TextStyle? labelStyle;
final Widget inputField;
@override
Widget build(BuildContext context) {
final _theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Label
if (label == null)
Text(
labelText ?? lang.S.of(context).enterLabelText,
style: labelStyle ?? _theme.inputDecorationTheme.floatingLabelStyle,
)
else
label!,
const SizedBox(height: 8),
inputField,
],
);
}
}

View File

@@ -0,0 +1,724 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:mobile_pos/Screens/Products/Model/product_model.dart';
import 'package:mobile_pos/core/theme/_app_colors.dart';
import 'package:mobile_pos/generated/l10n.dart' as l;
import 'package:mobile_pos/currency.dart';
import '../../../Provider/product_provider.dart';
import '../../../constant.dart';
import 'combo_product_form.dart';
class AddOrEditComboItem extends ConsumerStatefulWidget {
final ComboItem? existingItem;
final Function(ComboItem) onSubmit;
const AddOrEditComboItem({
super.key,
this.existingItem,
required this.onSubmit,
});
@override
ConsumerState<AddOrEditComboItem> createState() => _AddOrEditComboItemPopupState();
}
class _AddOrEditComboItemPopupState extends ConsumerState<AddOrEditComboItem> {
Product? selectedProduct;
Stock? selectedStock;
final TextEditingController searchController = TextEditingController();
final TextEditingController qtyController = TextEditingController();
final TextEditingController unitController = TextEditingController();
final TextEditingController priceController = TextEditingController();
final TextEditingController totalController = TextEditingController();
@override
void initState() {
super.initState();
if (widget.existingItem != null) {
final item = widget.existingItem!;
selectedProduct = item.product;
selectedStock = item.stockData;
if (item.product.productType == 'variant' && selectedStock != null) {
searchController.text = "${item.product.productName} - ${selectedStock?.variantName}";
} else {
searchController.text = item.product.productName ?? '';
}
qtyController.text = item.quantity.toString();
unitController.text = item.product.unit?.unitName ?? 'Pcs';
priceController.text = (item.manualPurchasePrice ?? selectedStock?.productPurchasePrice ?? 0).toString();
_calculateTotal();
}
// if (widget.existingItem != null) {
// // Load existing data for Edit Mode
// final item = widget.existingItem!;
// selectedProduct = item.product;
// selectedStock = item.stockData;
// searchController.text = item.product.productName ?? '';
// qtyController.text = item.quantity.toString();
// unitController.text = item.product.unit?.unitName ?? 'Pcs';
// priceController.text = item.purchasePrice.toString();
// _calculateTotal();
// } else {
// // Add Mode Defaults
// qtyController.text = '1';
// unitController.text = 'Pcs';
// }
}
void _calculateTotal() {
double qty = double.tryParse(qtyController.text) ?? 0;
double price = double.tryParse(priceController.text) ?? 0;
totalController.text = (qty * price).toStringAsFixed(2);
}
late var _searchController = TextEditingController();
// Product? selectedCustomer;
@override
Widget build(BuildContext context) {
final productListAsync = ref.watch(productProvider);
final _theme = Theme.of(context);
final _lang = l.S.of(context);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text(
widget.existingItem == null ? _lang.addProduct : _lang.editProduct,
style: _theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
centerTitle: false,
elevation: 0,
automaticallyImplyLeading: false,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
actions: [
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(
Icons.close,
size: 22,
color: kPeraColor,
),
),
],
bottom: PreferredSize(
preferredSize: Size.fromHeight(1.0),
child: Divider(height: 1, thickness: 1, color: kBottomBorder),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.existingItem == null) ...[
// --------------use typehead---------------------
productListAsync.when(
data: (products) {
// Filter out combos
final filteredProducts = products.where((p) => p.productType != 'combo').toList();
return TypeAheadField<Map<String, dynamic>>(
emptyBuilder: (context) => Padding(
padding: const EdgeInsets.all(12),
child: Text(_lang.noItemFound),
),
builder: (context, controller, focusNode) {
_searchController = controller;
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
prefixIcon: Icon(AntDesign.search_outline, color: kGreyTextColor),
hintText: selectedProduct != null ? selectedProduct?.productName : _lang.searchProduct,
suffixIcon: IconButton(
onPressed: () {
controller.clear();
selectedProduct = null;
selectedStock = null;
setState(() {});
},
icon: Icon(Icons.close, color: kSubPeraColor),
),
),
);
},
suggestionsCallback: (pattern) {
final query = pattern.toLowerCase().trim();
final List<Map<String, dynamic>> suggestions = [];
for (var product in filteredProducts) {
// Skip combo products (already filtered above)
if (product.productType != 'variant') {
final productName = (product.productName ?? '').toLowerCase();
if (query.isEmpty || productName.contains(query)) {
suggestions.add({'type': 'single', 'product': product});
}
continue;
}
// Variant product
bool headerAdded = false;
final parentName = (product.productName ?? '').toLowerCase();
for (var s in product.stocks ?? []) {
final variantName = (s.variantName ?? '').toLowerCase();
// Combine parent name + variant name for searching
final combinedName = '$parentName $variantName';
if (query.isEmpty || combinedName.contains(query)) {
if (!headerAdded) {
suggestions.add({'type': 'header', 'product': product});
headerAdded = true;
}
suggestions.add({
'type': 'variant',
'product': product,
'stock': s,
});
}
}
}
return suggestions;
},
// suggestionsCallback: (pattern) {
// final query = pattern.toLowerCase().trim();
// final List<Map<String, dynamic>> suggestions = [];
//
// for (var product in filteredProducts) {
// if (product.productType != 'variant') {
// // Single product is selectable
// final productName = (product.productName ?? '').toLowerCase();
// if (query.isEmpty || productName.contains(query)) {
// suggestions.add({'type': 'single', 'product': product});
// }
// continue;
// }
//
// // Variant parent is only a header
// bool headerAdded = false;
//
// // Check if parent name matches
// final productName = (product.productName ?? '').toLowerCase();
// if (query.isEmpty || productName.contains(query)) {
// suggestions.add({'type': 'header', 'product': product});
// headerAdded = true;
// }
//
// // Check variant names
// for (var s in product.stocks ?? []) {
// final variantName = (s.variantName ?? '').toLowerCase();
// if (query.isEmpty || variantName.contains(query)) {
// if (!headerAdded) {
// suggestions.add({'type': 'header', 'product': product});
// headerAdded = true;
// }
// suggestions.add({
// 'type': 'variant',
// 'product': product,
// 'stock': s,
// });
// }
// }
// }
//
// return suggestions;
// },
itemBuilder: (context, suggestion) {
final type = suggestion['type'] as String;
if (type == 'header') {
final p = suggestion['product'] as Product;
return InkWell(
onTap: () {
// Just close the suggestion box without selecting anything
FocusScope.of(context).unfocus();
},
child: ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 10),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
leading: Icon(Icons.circle, color: Colors.black, size: 10),
title: Text(
p.productName ?? '',
style: _theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
);
}
if (type == 'variant') {
final product = suggestion['product'] as Product;
final stock = suggestion['stock'] as Stock;
return ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 10),
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
leading: Icon(Icons.subdirectory_arrow_right, color: Colors.grey, size: 18),
title: Text("${product.productName} (${stock.variantName ?? 'n/a'})"),
subtitle: Text(
'${_lang.stock}: ${stock.productStock}, ${_lang.price}: $currency${stock.productPurchasePrice}, ${_lang.batch}: ${stock.batchNo}'),
);
}
// single product
final product = suggestion['product'] as Product;
return ListTile(
title: Text(product.productName ?? ''),
subtitle: Text(
'${_lang.stock}: ${product.stocksSumProductStock ?? 0}, ${_lang.price}: $currency${product.productPurchasePrice}'),
);
},
onSelected: (suggestion) {
final type = suggestion['type'] as String;
if (type == 'variant' || type == 'single') {
final product = suggestion['product'] as Product;
setState(() {
selectedProduct = product;
if (type == 'variant') {
selectedStock = suggestion['stock'] as Stock;
} else {
selectedStock = product.stocks?.isNotEmpty == true ? product.stocks!.first : null;
}
_searchController.text = type == 'variant'
? "${product.productName} - ${selectedStock?.variantName}"
: product.productName ?? '';
unitController.text = product.unit?.unitName ?? 'Pcs';
priceController.text = (selectedStock?.productPurchasePrice ?? 0).toStringAsFixed(2);
_calculateTotal();
});
}
},
);
},
loading: () => LinearProgressIndicator(),
error: (e, _) => Text("Error: $e"),
),
// productListAsync.when(
// data: (products) {
// final List<Product> filteredProducts = products.where((p) => p.productType != 'combo').toList();
//
// return TypeAheadField<Map<String, dynamic>>(
// emptyBuilder: (context) => Padding(
// padding: const EdgeInsets.all(12),
// child: Text("No item found"),
// ),
// builder: (context, controller, focusNode) {
// _searchController = controller;
// return TextField(
// controller: controller,
// focusNode: focusNode,
// decoration: InputDecoration(
// prefixIcon: Icon(AntDesign.search_outline, color: kGreyTextColor),
// hintText: selectedProduct != null ? selectedProduct?.productName : 'Search product',
// suffixIcon: IconButton(
// onPressed: () {
// controller.clear();
// selectedProduct = null;
// selectedStock = null;
// setState(() {});
// },
// icon: Icon(Icons.close, color: kSubPeraColor),
// ),
// ),
// );
// },
// suggestionsCallback: (pattern) {
// final query = pattern.toLowerCase().trim();
// final List<Map<String, dynamic>> suggestions = [];
//
// for (var product in filteredProducts) {
// final productName = (product.productName ?? '').toLowerCase();
// if (product.productType != 'variant') {
// if (query.isEmpty || productName.contains(query)) {
// suggestions.add({'type': 'single', 'product': product});
// }
// continue;
// }
//
// bool headerAdded = false;
//
// if (query.isEmpty) {
// suggestions.add({'type': 'header', 'product': product});
// headerAdded = true;
//
// for (var s in product.stocks ?? []) {
// suggestions.add({
// 'type': 'variant',
// 'product': product,
// 'stock': s,
// });
// }
// continue;
// }
//
// if (productName.contains(query)) {
// suggestions.add({'type': 'header', 'product': product});
// headerAdded = true;
// }
//
// for (var s in product.stocks ?? []) {
// final variantName = (s.variantName ?? '').toLowerCase();
//
// if (variantName.contains(query)) {
// if (!headerAdded) {
// // Only add header once
// suggestions.add({'type': 'header', 'product': product});
// headerAdded = true;
// }
//
// suggestions.add({
// 'type': 'variant',
// 'product': product,
// 'stock': s,
// });
// }
// }
// }
//
// return suggestions;
// },
// itemBuilder: (context, suggestion) {
// final type = suggestion['type'] as String;
// if (type == 'header') {
// final p = suggestion['product'] as Product;
// return ListTile(
// contentPadding: EdgeInsets.symmetric(horizontal: 10),
// visualDensity: VisualDensity(horizontal: -4, vertical: -4),
// leading: Icon(Icons.circle, color: Colors.black, size: 10),
// title: Text(
// p.productName ?? '',
// style: _theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
// ),
// // header is not selectable, so we make it visually disabled
// enabled: false,
// );
// }
//
// if (type == 'variant') {
// final product = suggestion['product'] as Product;
// final stock = suggestion['stock'] as Stock;
// return ListTile(
// contentPadding: EdgeInsets.symmetric(horizontal: 10),
// visualDensity: VisualDensity(horizontal: -4, vertical: -4),
// leading: Icon(Icons.subdirectory_arrow_right, color: Colors.grey, size: 18),
// title: Text("${product.productName} (${stock.variantName ?? 'n/a'})"),
// subtitle: Text('Stock: ${stock.productStock}, Price: $currency${stock.productPurchasePrice}, Batch: ${stock.batchNo}'),
// );
// }
//
// // single product
// final product = suggestion['product'] as Product;
// return ListTile(
// title: Text(product.productName ?? ''),
// subtitle: Text('Stock: ${product.stocksSumProductStock ?? 0}, Price: $currency${product.productPurchasePrice}'),
// );
// },
// onSelected: (suggestion) {
// final type = suggestion['type'] as String;
// // Only allow single or variant selection
// if (type == 'single' || type == 'variant') {
// final product = suggestion['product'] as Product;
// setState(() {
// selectedProduct = product;
//
// if (type == 'variant') {
// selectedStock = suggestion['stock'] as Stock;
// } else {
// selectedStock = product.stocks?.isNotEmpty == true ? product.stocks!.first : null;
// }
//
// // Update search field
// _searchController.text = type == 'variant' ? "${product.productName} - ${selectedStock?.variantName}" : product.productName ?? '';
//
// // Update unit field
// unitController.text = product.unit?.unitName ?? 'Pcs';
//
// // Update price field
// priceController.text = (selectedStock?.productPurchasePrice ?? 0).toStringAsFixed(2);
//
// // Recalculate total
// _calculateTotal();
// });
// }
// },
// );
// },
// loading: () => LinearProgressIndicator(),
// error: (e, _) => Text("Error: $e"),
// ),
// --------------use typehead---------------------
] else ...[
TextFormField(
controller: searchController,
readOnly: true,
decoration: InputDecoration(
labelText: _lang.product,
border: OutlineInputBorder(),
filled: true,
fillColor: Color(0xFFF5F5F5),
),
),
],
// --------previous code-----------------
// if (widget.existingItem == null) ...[
// // --------------use typehead---------------------
// productListAsync.when(
// data: (products) {
// // Filter out combo products
// final filteredProducts = products.where((p) => p.productType != 'combo').toList();
//
// return TypeAheadField<Map<String, dynamic>>(
// builder: (context, controller, focusNode) {
// return TextField(
// controller: _searchController,
// focusNode: focusNode,
// decoration: InputDecoration(
// prefixIcon: Icon(AntDesign.search_outline, color: kGreyTextColor),
// hintText: selectedProduct != null ? selectedProduct?.productName : 'Search product',
// suffixIcon: IconButton(
// onPressed: () {
// _searchController.clear();
// selectedProduct = null;
// setState(() {});
// },
// icon: Icon(Icons.close, color: kSubPeraColor),
// ),
// ),
// );
// },
// suggestionsCallback: (pattern) {
// final List<Map<String, dynamic>> suggestions = [];
//
// for (var product in filteredProducts) {
// if (product.productType == 'variant') {
// // Show parent product as a header if it matches the search
// if ((product.productName ?? '').toLowerCase().contains(pattern.toLowerCase())) {
// suggestions.add({'type': 'header', 'product': product});
// }
//
// // Show variant stocks
// for (var stock in product.stocks ?? []) {
// if ((stock.variantName ?? '').toLowerCase().contains(pattern.toLowerCase())) {
// suggestions.add({'type': 'variant', 'product': product, 'stock': stock});
// }
// }
// } else {
// // Single product
// if ((product.productName ?? '').toLowerCase().contains(pattern.toLowerCase())) {
// suggestions.add({'type': 'single', 'product': product});
// }
// }
// }
//
// return suggestions;
// },
// itemBuilder: (context, suggestion) {
// final type = suggestion['type'] as String;
// if (type == 'header') {
// final product = suggestion['product'] as Product;
// return ListTile(
// contentPadding: EdgeInsets.symmetric(horizontal: 10),
// visualDensity: VisualDensity(horizontal: -4, vertical: -4),
// leading: Icon(
// Icons.circle,
// color: Colors.black,
// size: 10,
// ),
// title: Text(
// product.productName ?? '',
// style: _theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
// ),
// );
// } else if (type == 'variant') {
// final product = suggestion['product'] as Product;
// final stock = suggestion['stock'] as Stock;
// return ListTile(
// contentPadding: EdgeInsets.symmetric(horizontal: 10),
// visualDensity: VisualDensity(horizontal: -4, vertical: -4),
// leading: Icon(Icons.subdirectory_arrow_right, color: Colors.grey, size: 18),
// title: Text("${product.productName} (${stock.variantName ?? 'n/a'})"),
// subtitle: Text('Stock: ${stock.productStock}, Price: $currency${stock.productPurchasePrice}, Batch: ${stock.batchNo}'),
// );
// } else {
// final product = suggestion['product'] as Product;
// return ListTile(
// title: Text(product.productName ?? ''),
// subtitle: Text('Stock: ${product.stocksSumProductStock ?? 0}, Price: $currency${product.productPurchasePrice}'),
// );
// }
// },
// onSelected: (suggestion) {
// setState(() {
// final type = suggestion['type'] as String;
// final product = suggestion['product'] as Product;
//
// selectedProduct = product;
//
// if (type == 'variant') {
// selectedStock = suggestion['stock'] as Stock;
// } else {
// selectedStock = product.stocks != null && product.stocks!.isNotEmpty ? product.stocks!.first : null;
// }
//
// _searchController.text = type == 'variant' ? "${product.productName} - ${selectedStock?.variantName}" : product.productName ?? '';
//
// unitController.text = product.unit?.unitName ?? 'Pcs';
// priceController.text = (selectedStock?.productPurchasePrice ?? 0).toString();
// _calculateTotal();
// });
//
// FocusScope.of(context).unfocus();
// },
// );
// },
// loading: () => const Center(child: LinearProgressIndicator()),
// error: (e, stack) => Text('Error: $e'),
// ),
// // --------------use typehead---------------------
// ] else ...[
// TextFormField(
// controller: searchController,
// readOnly: true,
// decoration: const InputDecoration(
// labelText: 'Product',
// border: OutlineInputBorder(),
// filled: true,
// fillColor: Color(0xFFF5F5F5),
// ),
// ),
// ],
SizedBox(height: 20),
// --- Row 1: Quantity & Units ---
Row(
children: [
Expanded(
child: TextFormField(
controller: qtyController,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
labelText: _lang.quantity,
hintText: 'Ex: 1',
border: OutlineInputBorder(),
),
onChanged: (_) => _calculateTotal(),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: unitController,
readOnly: true,
decoration: InputDecoration(
labelText: _lang.units,
border: OutlineInputBorder(),
filled: true,
fillColor: Color(0xFFF5F5F5),
),
),
),
],
),
const SizedBox(height: 16),
// --- Row 2: Purchase Price & Total ---
Row(
children: [
Expanded(
child: TextFormField(
controller: priceController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: _lang.purchasePrice,
hintText: 'Ex: 20',
border: OutlineInputBorder(),
),
onChanged: (_) => _calculateTotal(),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: totalController,
readOnly: true,
decoration: InputDecoration(
labelText: _lang.total,
border: OutlineInputBorder(),
filled: true,
fillColor: Color(0xFFF5F5F5),
),
),
),
],
),
],
),
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: BorderSide(color: DAppColors.kWarning),
),
onPressed: () => Navigator.pop(context),
child: Text(
_lang.cancel,
style: TextStyle(
color: DAppColors.kWarning,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
// minimumSize: Size.fromHeight(48),
backgroundColor: const Color(0xFFB71C1C), // Red color
),
onPressed: () {
if (selectedProduct != null && selectedStock != null) {
final newItem = ComboItem(
product: selectedProduct!,
stockData: selectedStock!,
quantity: int.tryParse(qtyController.text) ?? 1,
manualPurchasePrice: double.tryParse(priceController.text),
);
widget.onSubmit(newItem);
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text("Please select a product")));
}
},
child: Text(_lang.save, style: TextStyle(color: Colors.white)),
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,318 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_pos/Screens/Products/Model/product_model.dart';
import 'package:mobile_pos/constant.dart';
import 'package:mobile_pos/currency.dart';
import 'package:mobile_pos/invoice_constant.dart' hide kMainColor;
import '../../../Provider/product_provider.dart';
import 'package:mobile_pos/generated/l10n.dart' as l;
import 'add_edit_comboItem.dart';
import 'modle/create_product_model.dart';
// Updated Helper Model to support manual price override
class ComboItem {
final Product product;
final Stock stockData;
int quantity;
double? manualPurchasePrice; // Added this field
ComboItem({
required this.product,
required this.stockData,
this.quantity = 1,
this.manualPurchasePrice,
});
// Use manual price if set, otherwise stock price
double get purchasePrice => manualPurchasePrice ?? (stockData.productPurchasePrice ?? 0).toDouble();
double get totalAmount => purchasePrice * quantity;
}
class ComboProductForm extends ConsumerStatefulWidget {
final TextEditingController profitController;
final TextEditingController saleController;
final TextEditingController purchasePriceController;
final List<ComboProductModel>? initialComboList;
final Function(List<ComboProductModel>) onComboListChanged;
const ComboProductForm({
super.key,
required this.profitController,
required this.saleController,
required this.purchasePriceController,
this.initialComboList,
required this.onComboListChanged,
});
@override
ConsumerState<ComboProductForm> createState() => _ComboProductFormState();
}
class _ComboProductFormState extends ConsumerState<ComboProductForm> {
List<ComboItem> selectedComboItems = [];
bool _isDataLoaded = false;
// --- Calculation Logic (Same as before) ---
void _calculateValues({String? source}) {
double totalPurchase = 0;
for (var item in selectedComboItems) {
totalPurchase += item.totalAmount;
}
if (widget.purchasePriceController.text != totalPurchase.toStringAsFixed(2)) {
widget.purchasePriceController.text = totalPurchase.toStringAsFixed(2);
}
double purchase = totalPurchase;
double profit = double.tryParse(widget.profitController.text) ?? 0;
double sale = double.tryParse(widget.saleController.text) ?? 0;
if (source == 'margin') {
sale = purchase + (purchase * profit / 100);
widget.saleController.text = sale.toStringAsFixed(2);
} else if (source == 'sale') {
if (purchase > 0) {
profit = ((sale - purchase) / purchase) * 100;
widget.profitController.text = profit.toStringAsFixed(2);
}
} else {
sale = purchase + (purchase * profit / 100);
widget.saleController.text = sale.toStringAsFixed(2);
}
List<ComboProductModel> finalApiList = selectedComboItems.map((item) {
return ComboProductModel(
stockId: item.stockData.id.toString(),
quantity: item.quantity.toString(),
purchasePrice: item.purchasePrice.toString(),
);
}).toList();
widget.onComboListChanged(finalApiList);
}
// --- Open the Popup for Add or Edit ---
void openProductForm({ComboItem? item, int? index}) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AddOrEditComboItem(
existingItem: item,
onSubmit: (newItem) {
setState(() {
if (index != null) {
// Edit Mode: Replace item
selectedComboItems[index] = newItem;
} else {
// Add Mode: Check duplicate or add new
bool exists = false;
for (int i = 0; i < selectedComboItems.length; i++) {
if (selectedComboItems[i].stockData.id == newItem.stockData.id) {
// If same product exists, just update that entry
selectedComboItems[i] = newItem;
exists = true;
break;
}
}
if (!exists) selectedComboItems.add(newItem);
}
_calculateValues(source: 'item_updated');
});
},
),
),
);
}
@override
Widget build(BuildContext context) {
final productListAsync = ref.watch(productProvider);
final _theme = Theme.of(context);
// Load Initial Data Logic
productListAsync.whenData((products) {
if (!_isDataLoaded && widget.initialComboList != null && widget.initialComboList!.isNotEmpty) {
Future.delayed(Duration.zero, () {
List<ComboItem> tempLoadedItems = [];
for (var initialItem in widget.initialComboList!) {
for (var product in products) {
if (product.stocks != null) {
try {
var matchingStock =
product.stocks!.firstWhere((s) => s.id.toString() == initialItem.stockId.toString());
tempLoadedItems.add(ComboItem(
product: product,
stockData: matchingStock,
quantity: int.tryParse(initialItem.quantity.toString()) ?? 1,
manualPurchasePrice: double.tryParse(initialItem.purchasePrice.toString()),
));
break;
} catch (_) {}
}
}
}
if (mounted) {
setState(() {
selectedComboItems = tempLoadedItems;
_isDataLoaded = true;
});
_calculateValues(source: 'init');
}
});
}
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Add Product Button
ElevatedButton(
onPressed: () => openProductForm(),
style: ElevatedButton.styleFrom(
backgroundColor: kMainColor50, // Light reddish background
minimumSize: Size(131, 36),
elevation: 0,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
),
child: Text(
"+ ${l.S.of(context).addProduct}",
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: kMainColor,
fontWeight: FontWeight.w500,
),
),
),
// 2. List of Items (Matching Screenshot 1)
if (selectedComboItems.isNotEmpty)
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: selectedComboItems.length,
separatorBuilder: (_, __) => const Divider(
height: 1,
color: kLineColor,
),
itemBuilder: (context, index) {
final item = selectedComboItems[index];
return ListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity(horizontal: -4, vertical: 0),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
item.product.productType == 'single'
? item.product.productName ?? 'n/a'
: ('${item.product.productName ?? ''} (${item.product.stocks?[index].variantName ?? 'n/a'})'),
style: _theme.textTheme.bodyLarge,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Text(
'${l.S.of(context).qty}: ${item.quantity}',
style: _theme.textTheme.bodyLarge?.copyWith(
color: kPeraColor,
),
),
],
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
'${l.S.of(context).code} : ${item.product.productCode ?? 'n/a'}, ${l.S.of(context).batchNo}: ${item.stockData.batchNo ?? 'n/a'}',
style: _theme.textTheme.bodyMedium,
),
),
Text(
'$currency${item.totalAmount ?? 'n/a'}',
style: _theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
trailing: SizedBox(
width: 30,
child: PopupMenuButton<String>(
iconColor: kPeraColor,
onSelected: (value) {
if (value == 'edit') {
openProductForm(item: item, index: index);
} else if (value == 'delete') {
setState(() {
selectedComboItems.removeAt(index);
_calculateValues(source: 'item_removed');
});
}
},
itemBuilder: (BuildContext context) => [
PopupMenuItem(value: 'edit', child: Text(l.S.of(context).edit)),
PopupMenuItem(
value: 'delete', child: Text(l.S.of(context).delete, style: TextStyle(color: Colors.red))),
],
),
),
);
},
),
if (selectedComboItems.isNotEmpty)
const Divider(
height: 1,
color: kLineColor,
),
SizedBox(height: 13),
// 3. Footer: Net Total, Profit, Sale Price
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("${l.S.of(context).netTotalAmount}:", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
Text("\$${widget.purchasePriceController.text}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: widget.profitController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: '${l.S.of(context).profitMargin} (%)',
hintText: 'Ex: 25%',
border: OutlineInputBorder(),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
onChanged: (value) => _calculateValues(source: 'margin'),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: widget.saleController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: l.S.of(context).defaultSellingPrice,
hintText: 'Ex: 150',
border: OutlineInputBorder(),
floatingLabelBehavior: FloatingLabelBehavior.always,
),
onChanged: (value) => _calculateValues(source: 'sale'),
),
),
],
),
SizedBox(height: 24),
],
);
}
}

View File

@@ -0,0 +1,150 @@
import 'dart:io';
// Enum for clearer logic in UI
enum ProductType { single, variant, combo }
// --- 1. Combo Product Model ---
class ComboProductModel {
ComboProductModel({
this.stockId,
this.quantity,
this.purchasePrice,
});
String? stockId;
String? quantity;
String? purchasePrice;
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {
'stock_id': stockId,
'quantity': quantity,
'purchase_price': purchasePrice,
};
return data;
}
}
// --- 2. Stock Data Model (Existing) ---
class StockDataModel {
StockDataModel({
this.stockId,
this.batchNo,
this.warehouseId,
this.productStock,
this.exclusivePrice,
this.inclusivePrice,
this.profitPercent,
this.productSalePrice,
this.productWholeSalePrice,
this.productDealerPrice,
this.mfgDate,
this.expireDate,
this.serialNumbers,
this.variantName,
this.variationData,
this.subStock,
});
String? stockId;
String? batchNo;
String? warehouseId;
String? productStock;
String? exclusivePrice;
String? inclusivePrice;
String? profitPercent;
String? productSalePrice;
String? productWholeSalePrice;
String? productDealerPrice;
String? mfgDate;
String? expireDate;
List<String>? serialNumbers;
bool? subStock;
String? variantName;
List<Map<String, dynamic>>? variationData;
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {
'stock_id': stockId,
'batch_no': batchNo,
'warehouse_id': warehouseId,
'productStock': productStock,
'exclusive_price': exclusivePrice,
'inclusive_price': inclusivePrice,
'profit_percent': profitPercent == 'Infinity' ? '0' : profitPercent,
'productSalePrice': productSalePrice,
'productWholeSalePrice': productWholeSalePrice,
'productDealerPrice': productDealerPrice,
'mfg_date': mfgDate,
'expire_date': expireDate,
'serial_numbers': serialNumbers,
'variant_name': variantName,
'variation_data': variationData,
};
data.removeWhere((key, value) => value == null || value.toString().isEmpty || value == 'null');
return data;
}
}
// --- 3. Main Create Product Model ---
class CreateProductModel {
CreateProductModel({
this.productId,
this.name,
this.categoryId,
this.brandId,
this.productCode,
this.modelId,
this.rackId,
this.shelfId,
this.alertQty,
this.unitId,
this.vatId,
this.vatType,
this.vatAmount,
this.image,
this.productType,
this.stocks,
this.comboProducts,
this.variationIds,
this.warrantyDuration,
this.warrantyPeriod,
this.guaranteeDuration,
this.guaranteePeriod,
this.productManufacturer,
this.productDiscount,
this.comboProfitPercent,
this.comboProductSalePrice,
});
String? productId;
String? name;
String? categoryId;
String? brandId;
String? productCode;
String? modelId;
String? rackId;
String? shelfId;
String? alertQty;
String? unitId;
String? vatId;
String? vatType;
String? vatAmount;
File? image;
String? productType;
String? comboProfitPercent;
String? comboProductSalePrice;
// Lists
List<StockDataModel>? stocks;
List<ComboProductModel>? comboProducts;
List<String?>? variationIds;
String? productManufacturer;
String? productDiscount;
String? warrantyDuration;
String? warrantyPeriod;
String? guaranteeDuration;
String? guaranteePeriod;
}

View File

@@ -0,0 +1,373 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:iconly/iconly.dart';
import 'package:intl/intl.dart';
import 'package:mobile_pos/Screens/Products/product_setting/model/get_product_setting_model.dart';
import 'package:mobile_pos/Screens/warehouse/warehouse_model/warehouse_list_model.dart';
import 'package:mobile_pos/generated/l10n.dart' as lang;
import 'package:nb_utils/nb_utils.dart';
import '../../../constant.dart';
import '../../../service/check_user_role_permission_provider.dart';
import '../../warehouse/warehouse_provider/warehouse_provider.dart';
class SingleProductForm extends ConsumerWidget {
const SingleProductForm({
super.key,
required this.snapShot,
required this.batchController,
required this.stockController,
required this.purchaseExController,
required this.purchaseIncController,
required this.profitController,
required this.saleController,
required this.wholesaleController,
required this.dealerController,
required this.mfgDateController,
required this.expDateController,
this.selectedWarehouse,
required this.onWarehouseChanged,
required this.onPriceChanged,
required this.onMfgDateSelected,
required this.onExpDateSelected,
});
final GetProductSettingModel snapShot;
// Controllers passed from Parent
final TextEditingController batchController;
final TextEditingController stockController;
final TextEditingController purchaseExController;
final TextEditingController purchaseIncController;
final TextEditingController profitController;
final TextEditingController saleController;
final TextEditingController wholesaleController;
final TextEditingController dealerController;
final TextEditingController mfgDateController;
final TextEditingController expDateController;
// State variables passed from Parent
final WarehouseData? selectedWarehouse;
// Callbacks to update Parent State
final Function(WarehouseData?) onWarehouseChanged;
final Function(String from) onPriceChanged; // To trigger calculation
final Function(String date) onMfgDateSelected;
final Function(String date) onExpDateSelected;
@override
Widget build(BuildContext context, WidgetRef ref) {
final permissionService = PermissionService(ref);
final warehouseData = ref.watch(fetchWarehouseListProvider);
final modules = snapShot.data?.modules;
final _lang = lang.S.of(context);
return Column(
children: [
///-------------Batch No & Warehouse----------------------------------
if (modules?.showBatchNo == '1' || modules?.showWarehouse == '1') ...[
const SizedBox(height: 24),
Row(
children: [
if (modules?.showBatchNo == '1')
Expanded(
child: TextFormField(
controller: batchController,
decoration: InputDecoration(
labelText: _lang.batchNo,
hintText: _lang.enterBatchNo,
border: OutlineInputBorder(),
),
),
),
if (modules?.showBatchNo == '1' && modules?.showWarehouse == '1') const SizedBox(width: 14),
if (modules?.showWarehouse == '1')
Expanded(
child: warehouseData.when(
data: (dataList) {
return Stack(
alignment: Alignment.centerRight,
children: [
DropdownButtonFormField<WarehouseData>(
hint: Text(_lang.selectWarehouse),
isExpanded: true,
decoration: InputDecoration(
labelText: _lang.warehouse,
),
value: selectedWarehouse,
icon: selectedWarehouse != null
? IconButton(
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
visualDensity: VisualDensity(
horizontal: -4,
vertical: -4,
),
),
icon: const Icon(
Icons.clear,
color: Colors.red,
size: 20,
),
onPressed: () {
onWarehouseChanged.call(null);
},
)
: const Icon(Icons.keyboard_arrow_down_outlined),
items: dataList.data
?.map(
(rack) => DropdownMenuItem<WarehouseData>(
value: rack,
child: Text(
rack.name ?? '',
style: const TextStyle(fontWeight: FontWeight.normal),
),
),
)
.toList(),
onChanged: onWarehouseChanged,
),
],
);
},
error: (e, st) => const Text('Warehouse Load Error'),
loading: () => const Center(child: CircularProgressIndicator()),
),
// child: warehouseData.when(
// data: (dataList) {
// return DropdownButtonFormField<WarehouseData>(
// hint: const Text('Select Warehouse'),
// isExpanded: true,
// decoration: const InputDecoration(labelText: 'Warehouse', border: OutlineInputBorder()),
// value: selectedWarehouse,
// icon: const Icon(Icons.keyboard_arrow_down_outlined),
// items: dataList.data
// ?.map(
// (rack) => DropdownMenuItem<WarehouseData>(
// value: rack,
// child: Text(rack.name ?? '', style: const TextStyle(fontWeight: FontWeight.normal)),
// ),
// )
// .toList(),
// onChanged: onWarehouseChanged,
// );
// },
// error: (e, st) => const Text('Rack Load Error'),
// loading: () => const Center(child: CircularProgressIndicator()),
// ),
),
],
),
],
if (modules?.showProductStock == '1') const SizedBox(height: 24),
///-------------Stock--------------------------------------
if (modules?.showProductStock == '1')
TextFormField(
controller: stockController,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}'))],
keyboardType: TextInputType.number,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: lang.S.of(context).stock,
hintText: lang.S.of(context).enterStock,
border: const OutlineInputBorder(),
),
),
///_________Purchase Price (Exclusive & Inclusive)____________________
if ((modules?.showExclusivePrice == '1' || modules?.showInclusivePrice == '1') &&
permissionService.hasPermission(Permit.productsPriceView.value)) ...[
const SizedBox(height: 24),
Row(
children: [
if (modules?.showExclusivePrice == '1')
Expanded(
child: TextFormField(
controller: purchaseExController,
onChanged: (value) => onPriceChanged('purchase_ex'),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}'))],
keyboardType: TextInputType.number,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: lang.S.of(context).purchaseEx,
hintText: lang.S.of(context).enterPurchasePrice,
border: const OutlineInputBorder(),
),
),
),
if (modules?.showExclusivePrice == '1' && modules?.showInclusivePrice == '1') const SizedBox(width: 14),
if (modules?.showInclusivePrice == '1')
Expanded(
child: TextFormField(
controller: purchaseIncController,
onChanged: (value) => onPriceChanged('purchase_inc'),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}'))],
keyboardType: TextInputType.number,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: lang.S.of(context).purchaseIn,
hintText: lang.S.of(context).enterSaltingPrice,
border: const OutlineInputBorder(),
),
),
),
],
),
],
///_________Profit Margin & MRP_____________________
if (modules?.showProfitPercent == '1' || modules?.showProductSalePrice == '1') ...[
const SizedBox(height: 24),
Row(
children: [
if (modules?.showProfitPercent == '1' &&
(permissionService.hasPermission(Permit.productsPriceView.value)))
Expanded(
child: TextFormField(
controller: profitController,
onChanged: (value) => onPriceChanged('profit_margin'),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}'))],
keyboardType: TextInputType.number,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: lang.S.of(context).profitMargin,
hintText: lang.S.of(context).enterPurchasePrice,
border: const OutlineInputBorder(),
),
),
),
if (modules?.showProfitPercent == '1' &&
modules?.showProductSalePrice == '1' &&
permissionService.hasPermission(Permit.productsPriceView.value))
const SizedBox(width: 14),
if (modules?.showProductSalePrice == '1')
Expanded(
child: TextFormField(
controller: saleController,
onChanged: (value) => onPriceChanged('mrp'),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}'))],
keyboardType: TextInputType.number,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: lang.S.of(context).mrp,
hintText: lang.S.of(context).enterSaltingPrice,
border: const OutlineInputBorder(),
),
),
),
],
),
],
///_______Wholesale & Dealer Price_________________
if (modules?.showProductWholesalePrice == '1' || modules?.showProductDealerPrice == '1') ...[
const SizedBox(height: 24),
Row(
children: [
if (modules?.showProductWholesalePrice == '1')
Expanded(
child: TextFormField(
controller: wholesaleController,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}'))],
keyboardType: TextInputType.number,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: lang.S.of(context).wholeSalePrice,
hintText: lang.S.of(context).enterWholesalePrice,
border: const OutlineInputBorder(),
),
),
),
if (modules?.showProductWholesalePrice == '1' && modules?.showProductDealerPrice == '1')
const SizedBox(width: 14),
if (modules?.showProductDealerPrice == '1')
Expanded(
child: TextFormField(
controller: dealerController,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}'))],
keyboardType: TextInputType.number,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: lang.S.of(context).dealerPrice,
hintText: lang.S.of(context).enterDealerPrice,
border: const OutlineInputBorder(),
),
),
),
],
),
],
///_______Dates_________________
if ((modules?.showMfgDate == '1') || (modules?.showExpireDate == '1')) ...[
const SizedBox(height: 24),
Row(
children: [
if (modules?.showMfgDate == '1')
Expanded(
child: TextFormField(
readOnly: true,
controller: mfgDateController,
decoration: InputDecoration(
labelText: lang.S.of(context).manuDate,
hintText: lang.S.of(context).selectDate,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
onPressed: () async {
final DateTime? picked = await showDatePicker(
initialDate: DateTime.now(),
firstDate: DateTime(2015, 8),
lastDate: DateTime(2101),
context: context,
);
if (picked != null) {
onMfgDateSelected(picked.toString());
}
},
icon: const Icon(IconlyLight.calendar, size: 22),
),
),
),
),
if (modules?.showMfgDate == '1' && modules?.showExpireDate == '1') const SizedBox(width: 14),
if (modules?.showExpireDate == '1')
Expanded(
child: TextFormField(
readOnly: true,
controller: expDateController,
decoration: InputDecoration(
labelText: lang.S.of(context).expDate,
hintText: lang.S.of(context).selectDate,
border: const OutlineInputBorder(),
suffixIcon: IconButton(
padding: EdgeInsets.zero,
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
onPressed: () async {
final DateTime? picked = await showDatePicker(
initialDate: DateTime.now(),
firstDate: DateTime(2015, 8),
lastDate: DateTime(2101),
context: context,
);
if (picked != null) {
onExpDateSelected(picked.toString());
}
},
icon: const Icon(IconlyLight.calendar, size: 22),
),
),
),
),
],
),
],
const SizedBox(height: 24),
],
);
}
}

View File

@@ -0,0 +1,898 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hugeicons/hugeicons.dart';
import 'package:intl/intl.dart';
import 'package:mobile_pos/Screens/Products/product_setting/model/get_product_setting_model.dart';
import 'package:mobile_pos/Screens/warehouse/warehouse_model/warehouse_list_model.dart';
import 'package:mobile_pos/generated/l10n.dart' as lang;
import '../../../constant.dart';
import '../../../currency.dart';
import '../../../service/check_user_role_permission_provider.dart';
import '../../product variation/model/product_variation_model.dart';
import '../../product variation/provider/product_variation_provider.dart';
import '../../vat_&_tax/model/vat_model.dart';
import '../../warehouse/warehouse_provider/warehouse_provider.dart';
import '../Widgets/acnoo_multiple_select_dropdown.dart';
import '../Widgets/dropdown_styles.dart';
import 'modle/create_product_model.dart';
class VariantProductForm extends ConsumerStatefulWidget {
const VariantProductForm({
super.key,
required this.initialStocks,
required this.onStocksUpdated,
required this.snapShot,
this.selectedWarehouse,
required this.onSelectVariation,
this.tax,
required this.taxType,
this.productVariationIds,
this.productCode,
});
final List<StockDataModel> initialStocks;
final Function(List<StockDataModel>) onStocksUpdated;
final Function(List<String?>) onSelectVariation;
final GetProductSettingModel snapShot;
final VatModel? tax;
final String taxType;
final List<String>? productVariationIds;
final String? productCode;
// State variables passed from Parent
final WarehouseData? selectedWarehouse; // Received from parent
@override
ConsumerState<VariantProductForm> createState() => _VariantProductFormState();
}
class _VariantProductFormState extends ConsumerState<VariantProductForm> {
List<int?> selectedVariation = [];
List<VariationData> variationList = [];
Map<num?, List<String>?> selectedVariationValues = {};
List<StockDataModel> localVariantStocks = [];
bool isDataInitialized = false;
final kLoader = const Center(child: CircularProgressIndicator(strokeWidth: 2));
@override
void initState() {
super.initState();
localVariantStocks = widget.initialStocks;
}
void generateVariants({bool? changeState}) {
if (selectedVariation.isEmpty) {
setState(() => localVariantStocks.clear());
widget.onStocksUpdated(localVariantStocks);
return;
}
// 1. Gather active Variations (No Change)
List<VariationData> activeVariations = [];
List<List<String>> activeValues = [];
for (var id in selectedVariation) {
if (id != null &&
selectedVariationValues.containsKey(id) &&
selectedVariationValues[id] != null &&
selectedVariationValues[id]!.isNotEmpty) {
var vData = variationList.firstWhere((element) => element.id == id, orElse: () => VariationData());
if (vData.id != null) {
activeVariations.add(vData);
activeValues.add(selectedVariationValues[id]!);
}
}
}
if (activeVariations.isEmpty || activeValues.length != activeVariations.length) {
setState(() => localVariantStocks = []);
widget.onStocksUpdated(localVariantStocks);
return;
}
;
// 2. Calculate Cartesian Product (No Change)
List<List<String>> cartesian(List<List<String>> lists) {
List<List<String>> result = [[]];
for (var list in lists) {
result = [
for (var a in result)
for (var b in list) [...a, b]
];
}
return result;
}
List<List<String>> combinations = cartesian(activeValues);
List<StockDataModel> newStocks = [];
String baseCode = widget.productCode ?? "";
int counter = 1;
for (var combo in combinations) {
String variantName = combo.join(" - ");
List<Map<String, String>> vData = [];
for (int i = 0; i < combo.length; i++) {
vData.add({activeVariations[i].name ?? '': combo[i]});
}
// Check if this ROOT variant already exists (to preserve edits)
var existingIndex = localVariantStocks.indexWhere((element) => element.variantName == variantName);
if (existingIndex != -1) {
StockDataModel parent = localVariantStocks[existingIndex];
// Updating batch no according to new code structure
if (baseCode.isNotEmpty) {
parent.batchNo = "$baseCode-$counter";
}
newStocks.add(parent);
} else {
// C. New Root Variant
String autoBatchNo = baseCode.isNotEmpty ? "$baseCode-$counter" : "";
newStocks.add(StockDataModel(
profitPercent: '0',
variantName: variantName,
batchNo: autoBatchNo, // NEW LOGIC: 1002-1
variationData: vData,
productStock: "0",
exclusivePrice: "0",
inclusivePrice: "0",
productSalePrice: "0",
));
}
counter++;
}
setState(() => localVariantStocks = newStocks);
widget.onStocksUpdated(localVariantStocks);
}
// --- Logic to Initialize Data from Edit Mode ---
void _initializeEditData(List<VariationData> allVariations) {
if (isDataInitialized) return;
if (localVariantStocks.isEmpty && (widget.productVariationIds == null || widget.productVariationIds!.isEmpty))
return;
// 1. Set Selected Variation Types (Example: Size, Color IDs)
if (widget.productVariationIds != null) {
selectedVariation = widget.productVariationIds!.map((e) => int.tryParse(e)).where((e) => e != null).toList();
}
for (final stock in localVariantStocks) {
print('Pioewruwr------------------------> ${stock.variationData}');
if (stock.variationData != null) {
for (Map<String, dynamic> vMap in stock.variationData!) {
print('$vMap');
// vMap looks like {"Size": "M"}
vMap.forEach((keyName, value) {
// Find the ID associated with this Name (e.g., "Size" -> ID 1)
final variationObj = allVariations.firstWhere(
(element) => element.name?.toLowerCase() == keyName.toLowerCase(),
orElse: () => VariationData(),
);
if (variationObj.id != null) {
num vId = variationObj.id!;
// Add value to the list if not exists
if (!selectedVariationValues.containsKey(vId)) {
selectedVariationValues[vId] = [];
}
if (value is String && !selectedVariationValues[vId]!.contains(value)) {
selectedVariationValues[vId]!.add(value);
}
}
});
}
}
}
isDataInitialized = true;
Future.microtask(() => setState(() {}));
}
void _addSubVariation(int parentIndex) {
final parentStock = localVariantStocks[parentIndex];
// Ensure parent has a batch number
if (parentStock.batchNo == null || parentStock.batchNo!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Parent must have a Batch No first")));
return;
}
// Count existing children to generate ID (e.g., 1001-1, 1001-2)
final String parentBatch = parentStock.batchNo!;
int childCount = localVariantStocks
.where((element) => element.batchNo != null && element.batchNo!.startsWith("$parentBatch-"))
.length;
String newSubBatch = "$parentBatch-${childCount + 1}";
// Create Child Stock (Copying basic data from parent if needed, or blank)
StockDataModel childStock = StockDataModel(
variantName: "${parentStock.variantName} (Sub ${childCount + 1})", // Indicating it's a sub
batchNo: '',
variationData: parentStock.variationData, // Inherit variation traits
profitPercent: parentStock.profitPercent ?? '0',
productStock: "0",
exclusivePrice: parentStock.exclusivePrice ?? "0",
inclusivePrice: parentStock.inclusivePrice ?? "0",
productSalePrice: parentStock.productSalePrice ?? "0",
warehouseId: parentStock.warehouseId,
);
setState(() {
// Insert immediately after the parent (and its existing children)
// We insert at parentIndex + 1 + childCount to keep them grouped
localVariantStocks.insert(parentIndex + 1 + childCount, childStock);
});
widget.onStocksUpdated(localVariantStocks);
}
void _removeVariation(int index) {
final stockToRemove = localVariantStocks[index];
final String? batchNo = stockToRemove.batchNo;
setState(() {
localVariantStocks.removeAt(index);
// If it was a parent, remove all its children (Sub-variations)
if (batchNo != null && !batchNo.contains('-')) {
localVariantStocks
.removeWhere((element) => element.batchNo != null && element.batchNo!.startsWith("$batchNo-"));
}
});
widget.onStocksUpdated(localVariantStocks);
}
@override
Widget build(BuildContext context) {
final _dropdownStyle = AcnooDropdownStyle(context);
final variationData = ref.watch(variationListProvider);
final _theme = Theme.of(context);
return Column(
children: [
const SizedBox(height: 24),
//------- Variation Type Selection --------------------
variationData.when(
data: (variation) {
variationList = variation.data ?? [];
// -----------------------------------------
// HERE IS THE FIX: Initialize Data Once
// -----------------------------------------
if (!isDataInitialized && variationList.isNotEmpty) {
_initializeEditData(variationList);
}
return AcnooMultiSelectDropdown(
menuItemStyleData: _dropdownStyle.multiSelectMenuItemStyle,
buttonStyleData: _dropdownStyle.buttonStyle,
iconStyleData: _dropdownStyle.iconStyle,
dropdownStyleData: _dropdownStyle.dropdownStyle,
labelText: lang.S.of(context).selectVariations,
decoration: InputDecoration(
contentPadding: EdgeInsets.all(8),
hintText: lang.S.of(context).selectItems,
),
values: selectedVariation,
items: variationList.map((item) {
return MultiSelectDropdownMenuItem(value: item.id, labelText: item.name ?? '');
}).toList(),
onChanged: (values) {
setState(() {
selectedVariation = values?.map((e) => e as int?).toList() ?? [];
selectedVariationValues.removeWhere((key, value) => !selectedVariation.contains(key));
});
widget.onSelectVariation(values?.map((e) => e.toString()).toList() ?? []);
if (selectedVariation.isEmpty) {
setState(() => localVariantStocks.clear());
widget.onStocksUpdated(localVariantStocks);
} else {
generateVariants();
}
},
);
},
error: (e, stack) => Center(child: Text(e.toString())),
loading: () => kLoader,
),
//----------- Variation Values Selection ---------------
if (selectedVariation.isNotEmpty) const SizedBox(height: 24),
if (selectedVariation.isNotEmpty)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: variationList.where((item) => selectedVariation.contains(item.id)).length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 2.8),
itemBuilder: (context, index) {
final filteredItems = variationList.where((item) => selectedVariation.contains(item.id)).toList();
final varItem = filteredItems[index];
return AcnooMultiSelectDropdown<String>(
key: GlobalKey(debugLabel: varItem.name),
labelText: varItem.name ?? '',
values: selectedVariationValues[varItem.id] ?? [],
items: (varItem.values ?? []).map((value) {
return MultiSelectDropdownMenuItem(value: value, labelText: value);
}).toList(),
onChanged: (values) {
selectedVariationValues[varItem.id?.toInt()] = values != null && values.isNotEmpty ? values : null;
generateVariants(changeState: false);
},
);
},
),
if (selectedVariation.isEmpty) const SizedBox(height: 24),
// ================= GENERATED VARIANT LIST =================
if (localVariantStocks.isNotEmpty) ...[
// const SizedBox(height: 24),
Row(
children: [
Text(
"${lang.S.of(context).selectVariations} (${localVariantStocks.length})",
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 10),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: localVariantStocks.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final stock = localVariantStocks[index];
// Check if this is a Sub-Variation (contains '-')
bool isSubVariation = stock.batchNo != null && stock.variantName!.contains('Sub');
return Container(
color: isSubVariation ? Colors.grey.shade50 : Colors.transparent, // Light bg for sub items
child: ListTile(
onTap: () {
showVariantEditSheet(
context: context,
stock: localVariantStocks[index],
snapShot: widget.snapShot,
tax: widget.tax,
taxType: widget.taxType,
onSave: (updatedStock) {
setState(() {
localVariantStocks[index] = updatedStock;
});
widget.onStocksUpdated(localVariantStocks);
},
);
},
contentPadding: !isSubVariation ? EdgeInsets.zero : EdgeInsetsDirectional.only(start: 30),
// (+) Button only for Parent items
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
leading: !isSubVariation
? IconButton(
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
icon: const Icon(Icons.add, color: kTitleColor),
tooltip: lang.S.of(context).addSubVariation,
onPressed: () => _addSubVariation(index),
)
: Icon(Icons.subdirectory_arrow_right,
color: Colors.grey, size: 18), // Visual indicator for child
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
stock.variantName ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _theme.textTheme.titleSmall?.copyWith(
fontWeight: isSubVariation ? FontWeight.normal : FontWeight.w500,
fontSize: isSubVariation ? 13 : 14,
),
),
),
SizedBox(width: 8),
Text.rich(TextSpan(
text: '${lang.S.of(context).stock}: ',
style: _theme.textTheme.bodyMedium?.copyWith(
color: kTitleColor,
),
children: [
TextSpan(
text: stock.productStock ?? 'n/a',
style: _theme.textTheme.bodyMedium?.copyWith(
fontWeight: isSubVariation ? FontWeight.normal : FontWeight.w500,
fontSize: isSubVariation ? 13 : 14,
color: kPeraColor),
)
])),
],
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
'${lang.S.of(context).batchNo}: ${stock.batchNo ?? 'N/A'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _theme.textTheme.bodyMedium
?.copyWith(fontSize: isSubVariation ? 13 : 14, color: kPeraColor),
),
),
Text.rich(TextSpan(
text: '${lang.S.of(context).sale}: ',
style: _theme.textTheme.bodyMedium?.copyWith(
color: kTitleColor,
),
children: [
TextSpan(
text: '$currency${stock.productSalePrice ?? 'n/a'}',
style: _theme.textTheme.bodyMedium?.copyWith(
fontWeight: isSubVariation ? FontWeight.normal : FontWeight.w500,
fontSize: isSubVariation ? 13 : 14,
color: kTitleColor,
),
)
])),
],
),
trailing: SizedBox(
width: 30,
child: PopupMenuButton<String>(
onSelected: (value) {
if (value == 'edit') {
showVariantEditSheet(
context: context,
stock: localVariantStocks[index],
snapShot: widget.snapShot,
tax: widget.tax,
taxType: widget.taxType,
onSave: (updatedStock) {
setState(() {
localVariantStocks[index] = updatedStock;
});
widget.onStocksUpdated(localVariantStocks);
},
);
} else if (value == 'delete') {
_removeVariation(index);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
HugeIcon(
icon: HugeIcons.strokeRoundedPencilEdit02,
color: kGreyTextColor,
size: 20,
),
SizedBox(width: 8),
Text(
lang.S.of(context).edit,
style: _theme.textTheme.titleSmall?.copyWith(
color: kGreyTextColor,
),
),
],
),
),
// Show delete only if sub-variation
if (isSubVariation)
PopupMenuItem(
value: 'delete',
child: Row(
children: [
HugeIcon(
icon: HugeIcons.strokeRoundedDelete03,
color: kGreyTextColor,
size: 20,
),
SizedBox(width: 8),
Text(
lang.S.of(context).edit,
style: _theme.textTheme.titleSmall?.copyWith(
color: kGreyTextColor,
),
),
],
),
),
],
),
),
),
);
},
)
]
],
);
}
}
void showVariantEditSheet({
required BuildContext context,
required StockDataModel stock,
required GetProductSettingModel snapShot,
VatModel? tax,
required String taxType,
required Function(StockDataModel updatedStock) onSave,
}) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (context) =>
VariantEditSheet(stock: stock, snapShot: snapShot, tax: tax, taxType: taxType, onSave: onSave),
);
}
class VariantEditSheet extends ConsumerStatefulWidget {
const VariantEditSheet(
{super.key,
required this.stock,
required this.snapShot,
required this.tax,
required this.taxType,
required this.onSave});
final StockDataModel stock;
final GetProductSettingModel snapShot;
final VatModel? tax;
final String taxType;
final Function(StockDataModel) onSave;
@override
ConsumerState<VariantEditSheet> createState() => _VariantEditSheetState();
}
class _VariantEditSheetState extends ConsumerState<VariantEditSheet> {
late TextEditingController productBatchNumberController;
late TextEditingController productStockController;
late TextEditingController purchaseExclusivePriceController;
late TextEditingController purchaseInclusivePriceController;
late TextEditingController profitMarginController;
late TextEditingController salePriceController;
late TextEditingController wholeSalePriceController;
late TextEditingController dealerPriceController;
late TextEditingController expireDateController;
late TextEditingController manufactureDateController;
String? selectedExpireDate;
String? selectedManufactureDate;
String? selectedWarehouseId; // Added variable for Warehouse
@override
void initState() {
super.initState();
productBatchNumberController = TextEditingController(text: widget.stock.batchNo ?? '');
productStockController = TextEditingController(text: widget.stock.productStock ?? '');
purchaseExclusivePriceController = TextEditingController(text: widget.stock.exclusivePrice ?? '');
purchaseInclusivePriceController = TextEditingController(text: widget.stock.inclusivePrice ?? '');
profitMarginController = TextEditingController(text: widget.stock.profitPercent ?? '');
salePriceController = TextEditingController(text: widget.stock.productSalePrice ?? '');
wholeSalePriceController = TextEditingController(text: widget.stock.productWholeSalePrice ?? '');
dealerPriceController = TextEditingController(text: widget.stock.productDealerPrice ?? '');
selectedExpireDate = widget.stock.expireDate;
selectedManufactureDate = widget.stock.mfgDate;
// Initialize Warehouse ID
selectedWarehouseId = widget.stock.warehouseId;
expireDateController = TextEditingController(
text: selectedExpireDate != null && selectedExpireDate!.isNotEmpty
? DateFormat.yMd().format(DateTime.parse(selectedExpireDate!))
: '');
manufactureDateController = TextEditingController(
text: selectedManufactureDate != null && selectedManufactureDate!.isNotEmpty
? DateFormat.yMd().format(DateTime.parse(selectedManufactureDate!))
: '');
}
@override
void dispose() {
productBatchNumberController.dispose();
productStockController.dispose();
purchaseExclusivePriceController.dispose();
purchaseInclusivePriceController.dispose();
profitMarginController.dispose();
salePriceController.dispose();
wholeSalePriceController.dispose();
dealerPriceController.dispose();
expireDateController.dispose();
manufactureDateController.dispose();
super.dispose();
}
void calculatePurchaseAndMrp({String? from}) {
num taxRate = widget.tax?.rate ?? 0;
num purchaseExc = num.tryParse(purchaseExclusivePriceController.text) ?? 0;
num purchaseInc = num.tryParse(purchaseInclusivePriceController.text) ?? 0;
num profitMargin = num.tryParse(profitMarginController.text) ?? 0;
num salePrice = num.tryParse(salePriceController.text) ?? 0;
if (from == 'purchase_inc') {
purchaseExc = (taxRate != 0) ? purchaseInc / (1 + taxRate / 100) : purchaseInc;
purchaseExclusivePriceController.text = purchaseExc.toStringAsFixed(2);
} else {
purchaseInc = purchaseExc + (purchaseExc * taxRate / 100);
purchaseInclusivePriceController.text = purchaseInc.toStringAsFixed(2);
}
purchaseExc = num.tryParse(purchaseExclusivePriceController.text) ?? 0;
purchaseInc = num.tryParse(purchaseInclusivePriceController.text) ?? 0;
num basePrice = widget.taxType.toLowerCase() == 'exclusive' ? purchaseExc : purchaseInc;
if (from == 'mrp') {
salePrice = num.tryParse(salePriceController.text) ?? 0;
if (basePrice > 0) {
profitMargin = ((salePrice - basePrice) / basePrice) * 100;
profitMarginController.text = profitMargin.toStringAsFixed(2);
}
} else {
if (basePrice > 0) {
salePrice = basePrice + (basePrice * profitMargin / 100);
salePriceController.text = salePrice.toStringAsFixed(2);
}
}
setState(() {});
}
@override
Widget build(BuildContext context) {
final permissionService = PermissionService(ref);
final theme = Theme.of(context);
final modules = widget.snapShot.data?.modules;
// 1. Fetch Warehouse List from Provider
final warehouseData = ref.watch(fetchWarehouseListProvider);
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Container(
decoration:
const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Flexible(
child: Text('${lang.S.of(context).edit} ${widget.stock.variantName}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, fontSize: 18)),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close, size: 20, color: Colors.grey))
])),
const Divider(height: 1, color: kBorderColor),
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(children: [
// 2. Display Warehouse Dropdown
warehouseData.when(
data: (data) => DropdownButtonFormField<String>(
value: selectedWarehouseId,
decoration: InputDecoration(
labelText: lang.S.of(context).warehouse,
hintText: lang.S.of(context).selectWarehouse,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12)),
items: data.data
?.map((WarehouseData w) =>
DropdownMenuItem<String>(value: w.id.toString(), child: Text(w.name ?? '')))
.toList(),
onChanged: (v) => setState(() => selectedWarehouseId = v)),
error: (e, s) => const Text('Failed to load warehouse'),
loading: () => const Center(child: LinearProgressIndicator())),
const SizedBox(height: 16),
if (modules?.showBatchNo == '1' || modules?.showProductStock == '1') ...[
Row(children: [
if (modules?.showBatchNo == '1')
Expanded(
child: _buildField(
controller: productBatchNumberController,
label: lang.S.of(context).batchNo,
hint: "Ex: B-001")),
if (modules?.showBatchNo == '1' && modules?.showProductStock == '1') const SizedBox(width: 12),
if (modules?.showProductStock == '1')
Expanded(
child: _buildField(
controller: productStockController,
label: lang.S.of(context).stock,
isNumber: true,
hint: "Ex: 50"))
]),
const SizedBox(height: 16)
],
if ((modules?.showExclusivePrice == '1' || modules?.showInclusivePrice == '1') &&
permissionService.hasPermission(Permit.productsPriceView.value)) ...[
Row(children: [
if (modules?.showExclusivePrice == '1')
Expanded(
child: _buildField(
controller: purchaseExclusivePriceController,
label: lang.S.of(context).purchaseEx,
isNumber: true,
hint: "Ex: 100.00",
onChanged: (v) => calculatePurchaseAndMrp())),
if (modules?.showExclusivePrice == '1' && modules?.showInclusivePrice == '1')
const SizedBox(width: 12),
if (modules?.showInclusivePrice == '1')
Expanded(
child: _buildField(
controller: purchaseInclusivePriceController,
label: lang.S.of(context).purchaseIn,
isNumber: true,
hint: "Ex: 115.00",
onChanged: (v) => calculatePurchaseAndMrp(from: "purchase_inc")))
]),
const SizedBox(height: 16)
],
if (modules?.showProfitPercent == '1' || modules?.showProductSalePrice == '1') ...[
Row(children: [
if (modules?.showProfitPercent == '1' &&
permissionService.hasPermission(Permit.productsPriceView.value))
Expanded(
child: _buildField(
controller: profitMarginController,
label: lang.S.of(context).profitMargin,
isNumber: true,
hint: "Ex: 20%",
onChanged: (v) => calculatePurchaseAndMrp())),
if (modules?.showProfitPercent == '1' &&
modules?.showProductSalePrice == '1' &&
permissionService.hasPermission(Permit.productsPriceView.value))
const SizedBox(width: 12),
if (modules?.showProductSalePrice == '1')
Expanded(
child: _buildField(
controller: salePriceController,
label: lang.S.of(context).mrp,
isNumber: true,
hint: "Ex: 150.00",
onChanged: (v) => calculatePurchaseAndMrp(from: 'mrp')))
]),
const SizedBox(height: 16)
],
if (modules?.showProductWholesalePrice == '1' || modules?.showProductDealerPrice == '1') ...[
Row(children: [
if (modules?.showProductWholesalePrice == '1')
Expanded(
child: _buildField(
controller: wholeSalePriceController,
label: lang.S.of(context).wholeSalePrice,
isNumber: true,
hint: "Ex: 130.00")),
if (modules?.showProductWholesalePrice == '1' && modules?.showProductDealerPrice == '1')
const SizedBox(width: 12),
if (modules?.showProductDealerPrice == '1')
Expanded(
child: _buildField(
controller: dealerPriceController,
label: lang.S.of(context).dealerPrice,
isNumber: true,
hint: "Ex: 120.00"))
]),
const SizedBox(height: 16)
],
if (modules?.showMfgDate == '1' || modules?.showExpireDate == '1') ...[
Row(children: [
if (modules?.showMfgDate == '1')
Expanded(
child: _buildDateField(
controller: manufactureDateController,
label: lang.S.of(context).manufactureDate,
isExpire: false,
hint: lang.S.of(context).selectDate)),
if (modules?.showMfgDate == '1' && modules?.showExpireDate == '1') const SizedBox(width: 12),
if (modules?.showExpireDate == '1')
Expanded(
child: _buildDateField(
controller: expireDateController,
label: lang.S.of(context).expDate,
isExpire: true,
hint: lang.S.of(context).selectDate,
))
]),
const SizedBox(height: 24)
],
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: () {
// 3. Set the selected warehouse ID to the stock object
widget.stock.warehouseId = selectedWarehouseId;
widget.stock.batchNo = productBatchNumberController.text;
widget.stock.productStock = productStockController.text;
widget.stock.exclusivePrice = purchaseExclusivePriceController.text;
widget.stock.inclusivePrice = purchaseInclusivePriceController.text;
widget.stock.profitPercent = profitMarginController.text;
widget.stock.productSalePrice = salePriceController.text;
widget.stock.productWholeSalePrice = wholeSalePriceController.text;
widget.stock.productDealerPrice = dealerPriceController.text;
widget.stock.expireDate = selectedExpireDate;
widget.stock.mfgDate = selectedManufactureDate;
widget.onSave(widget.stock);
Navigator.pop(context);
},
child: Text(lang.S.of(context).saveVariant))),
const SizedBox(height: 16),
]),
),
),
]),
),
);
}
Widget _buildField(
{required TextEditingController controller,
required String label,
String? hint,
bool isNumber = false,
Function(String)? onChanged}) {
return TextFormField(
controller: controller,
keyboardType: isNumber ? TextInputType.number : TextInputType.text,
inputFormatters: isNumber ? [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}'))] : [],
onChanged: onChanged,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: label,
hintText: hint,
hintStyle: const TextStyle(color: Colors.grey, fontSize: 12),
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)));
}
Widget _buildDateField(
{required TextEditingController controller, required String label, String? hint, required bool isExpire}) {
return TextFormField(
controller: controller,
readOnly: true,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.always,
labelText: label,
hintText: hint,
hintStyle: const TextStyle(color: Colors.grey, fontSize: 12),
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
suffixIcon: const Icon(Icons.calendar_today, size: 18)),
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context, initialDate: DateTime.now(), firstDate: DateTime(2015, 8), lastDate: DateTime(2101));
if (picked != null) {
setState(() {
controller.text = DateFormat.yMd().format(picked);
if (isExpire) {
selectedExpireDate = picked.toString();
} else {
selectedManufactureDate = picked.toString();
}
});
}
});
}
}

View File

@@ -0,0 +1,141 @@
import 'dart:io';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_pos/Provider/profile_provider.dart';
import 'package:mobile_pos/generated/l10n.dart' as lang;
import 'package:mobile_pos/Screens/Products/bulk%20product%20upload/repo/bulk_upload_repo.dart';
import 'package:mobile_pos/constant.dart';
import '../../../GlobalComponents/glonal_popup.dart';
class BulkUploader extends StatefulWidget {
const BulkUploader({
super.key,
});
@override
State<BulkUploader> createState() => _BulkUploaderState();
}
class _BulkUploaderState extends State<BulkUploader> {
File? file;
String getFileExtension(String fileName) {
return fileName.split('/').last;
}
@override
Widget build(BuildContext context) {
final _lang = lang.S.of(context);
return GlobalPopup(
child: Scaffold(
appBar: AppBar(
title: Text(_lang.excelUploader),
),
body: Consumer(builder: (context, ref, __) {
final businessInfo = ref.watch(businessInfoProvider);
return businessInfo.when(data: (details) {
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Visibility(
visible: file != null,
child: Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Card(
child: ListTile(
leading: Container(
height: 40,
width: 40,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: const BorderRadius.all(Radius.circular(10)),
),
child: const Image(image: AssetImage('images/excel.png'))),
title: Text(
getFileExtension(file?.path ?? ''),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: GestureDetector(
onTap: () {
setState(() {
file = null;
});
},
child: Text(_lang.remove)))),
),
),
Visibility(
visible: file == null,
child: const Padding(
padding: EdgeInsets.only(bottom: 20),
child: Image(
height: 100,
width: 100,
image: AssetImage('images/file-upload.png'),
)),
),
ElevatedButton(
style: const ButtonStyle(backgroundColor: WidgetStatePropertyAll(kMainColor)),
onPressed: () async {
if (file == null) {
await pickAndUploadFile(ref: ref);
} else {
EasyLoading.show(status: _lang.uploading);
await BulkUpLoadRepo().uploadBulkFile(file: file!, ref: ref, context: context);
EasyLoading.dismiss();
}
},
child: Text(file == null ? _lang.pickAndUploadFile : _lang.upload,
style: const TextStyle(color: Colors.white)),
),
TextButton(
onPressed: () async {
await BulkUpLoadRepo().downloadFile(context);
},
child: Text(_lang.downloadExcelFormat),
),
],
),
),
);
}, error: (e, stack) {
return Text(e.toString());
}, loading: () {
return const Center(
child: CircularProgressIndicator(),
);
});
}),
),
);
}
///
Future<void> pickAndUploadFile({required WidgetRef ref}) async {
XTypeGroup typeGroup = XTypeGroup(
label: lang.S.of(context).excelFiles,
extensions: ['xlsx'],
);
final XFile? fileResult = await openFile(acceptedTypeGroups: [typeGroup]);
if (fileResult != null) {
final File files = File(fileResult.path);
setState(() {
file = files;
});
} else {
print(lang.S.of(context).noFileSelected);
}
}
}

View File

@@ -0,0 +1,75 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_pos/Provider/product_provider.dart';
import 'package:mobile_pos/Screens/product_brand/product_brand_provider/product_brand_provider.dart';
import 'package:mobile_pos/Screens/product_category/provider/product_category_provider/product_unit_provider.dart';
import 'package:mobile_pos/Screens/product_unit/provider/product_unit_provider.dart';
import '../../../../Const/api_config.dart';
import '../../../../Repository/constant_functions.dart';
import '../../../../http_client/custome_http_client.dart';
import 'package:http/http.dart' as http;
class BulkUpLoadRepo {
Future<void> uploadBulkFile({
required WidgetRef ref,
required BuildContext context,
required File file,
}) async {
CustomHttpClient customHttpClient = CustomHttpClient(client: http.Client(), context: context, ref: ref);
final uri = Uri.parse('${APIConfig.url}/bulk-uploads');
var request = http.MultipartRequest('POST', uri)
..headers['Accept'] = 'application/json'
..headers['Authorization'] = await getAuthToken();
request.files.add(http.MultipartFile.fromBytes('file', file.readAsBytesSync(), filename: file.path));
final response = await customHttpClient.uploadFile(url: uri, fileFieldName: 'file', file: file, fields: request.fields);
final responseData = await response.stream.bytesToString();
final parsedData = jsonDecode(responseData);
if (response.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Added successful!')));
ref.refresh(productProvider);
ref.refresh(categoryProvider);
ref.refresh(brandsProvider);
ref.refresh(unitsProvider);
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: ${parsedData['message']}')));
}
}
final String fileUrl = '${APIConfig.domain}assets/POSpro_bulk_product_upload.xlsx';
Future<void> downloadFile(BuildContext context) async {
try {
final response = await http.get(Uri.parse(fileUrl));
if (response.statusCode != 200) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to download file!')),
);
return;
}
final downloadPath = '/storage/emulated/0/Download';
final file = File('$downloadPath/POSpro_bulk_product_upload.xlsx');
await file.writeAsBytes(response.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('File saved to: ${file.path}')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Download error: $e')),
);
}
}
}

View File

@@ -0,0 +1,985 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:hugeicons/hugeicons.dart';
import 'package:intl/intl.dart';
import 'package:mobile_pos/Const/api_config.dart';
import 'package:mobile_pos/Screens/Products/Model/product_model.dart';
import 'package:mobile_pos/Screens/Products/add%20product/modle/create_product_model.dart';
import 'package:mobile_pos/constant.dart';
import 'package:mobile_pos/currency.dart';
import 'package:mobile_pos/generated/l10n.dart' as lang;
import '../../GlobalComponents/glonal_popup.dart';
import '../../Provider/product_provider.dart';
import '../../service/check_actions_when_no_branch.dart';
import '../../service/check_user_role_permission_provider.dart';
import '../../widgets/empty_widget/_empty_widget.dart';
import '../../widgets/key_values/key_values_widget.dart';
import '../Purchase/Repo/purchase_repo.dart';
import '../Purchase/purchase_product_buttom_sheet.dart';
import '../hrm/widgets/deleteing_alart_dialog.dart';
import 'Repo/product_repo.dart';
import 'add product/add_edit_comboItem.dart';
import 'add product/add_product.dart';
import 'add product/combo_product_form.dart';
class ProductDetails extends ConsumerStatefulWidget {
const ProductDetails({
super.key,
required this.details,
});
final Product details;
@override
ConsumerState<ProductDetails> createState() => _ProductDetailsState();
}
class _ProductDetailsState extends ConsumerState<ProductDetails> {
TextEditingController productStockController = TextEditingController();
TextEditingController salePriceController = TextEditingController();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final providerData = ref.watch(fetchProductDetails(widget.details.id.toString()));
final permissionService = PermissionService(ref);
final _lang = lang.S.of(context);
return GlobalPopup(
child: providerData.when(data: (snapshot) {
return Scaffold(
backgroundColor: kWhite,
appBar: AppBar(
backgroundColor: kWhite,
surfaceTintColor: kWhite,
title: Text(
lang.S.of(context).productDetails,
//'Product Details',
),
actions: [
IconButton(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero,
onPressed: () async {
bool result = await checkActionWhenNoBranch(ref: ref, context: context);
if (!result) {
return;
}
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AddProduct(
productModel: snapshot,
),
),
);
},
icon: Icon(
Icons.edit,
color: Colors.green,
size: 22,
)),
IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
onPressed: () async {
bool confirmDelete = await showDeleteConfirmationDialog(context: context, itemName: 'product');
if (confirmDelete) {
EasyLoading.show(
status: lang.S.of(context).deleting,
);
ProductRepo productRepo = ProductRepo();
await productRepo.deleteProduct(id: snapshot.id.toString(), context: context, ref: ref);
Navigator.pop(context);
}
},
icon: HugeIcon(
icon: HugeIcons.strokeRoundedDelete02,
color: kMainColor,
size: 22,
),
),
SizedBox(width: 10),
],
centerTitle: true,
// iconTheme: const IconThemeData(color: Colors.white),
elevation: 0.0,
),
body: Container(
alignment: Alignment.topCenter,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(topRight: Radius.circular(30), topLeft: Radius.circular(30))),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (permissionService.hasPermission(Permit.productsRead.value)) ...{
Container(
height: 256,
padding: EdgeInsets.all(8),
width: MediaQuery.of(context).size.width,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: Color(0xffF5F3F3),
),
child: Container(
width: MediaQuery.of(context).size.width,
decoration: BoxDecoration(
color: Color(0xffF5F3F3),
borderRadius: BorderRadius.circular(5),
image: snapshot.productPicture == null
? DecorationImage(fit: BoxFit.cover, image: AssetImage(noProductImageUrl))
: DecorationImage(
fit: BoxFit.cover,
image: NetworkImage('${APIConfig.domain}${snapshot.productPicture}'))),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
snapshot.productName.toString(),
//'Smart watch',
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600, fontSize: 18),
),
SizedBox(height: 8),
Text(
snapshot.category?.categoryName.toString() ?? 'n/a',
//'Apple Watch',
style: theme.textTheme.bodyMedium?.copyWith(
color: kGreyTextColor,
fontSize: 15,
),
),
const SizedBox(height: 10),
Container(
margin: const EdgeInsets.symmetric(vertical: 10),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: const Color(0xffFEF0F1),
),
child: Column(
children: [
//Single product details-------------------------
if (snapshot.productType == 'single')
...{
_lang.skuOrCode: snapshot.productCode ?? 'n/a',
_lang.brand: snapshot.brand?.brandName ?? 'n/a',
_lang.model: snapshot.productModel?.name ?? 'n/a',
_lang.units: snapshot.unit?.unitName ?? 'n/a',
_lang.rack: snapshot.rack?.name ?? 'n/a',
_lang.shelf: snapshot.shelf?.name ?? 'n/a',
// 'Test': snapshot.shelfId ?? 'n/a',
_lang.stock: snapshot.stocksSumProductStock?.toString() ?? '0',
_lang.lowStockAlert: snapshot.alertQty?.toString() ?? 'n/a',
_lang.warehouse: snapshot.stocks?.first.warehouse?.name?.toString() ?? 'n/a',
_lang.taxType: snapshot.vatType ?? 'n/a',
_lang.tax: snapshot.vatAmount?.toString() ?? 'n/a',
_lang.costExclusionTax: (snapshot.vatType != 'exclusive')
? (snapshot.stocks != null &&
snapshot.stocks!.isNotEmpty &&
snapshot.stocks!.first.productPurchasePrice != null &&
snapshot.vatAmount != null
? '${snapshot.stocks!.first.productPurchasePrice! - snapshot.vatAmount!}'
: '0')
: ('$currency${snapshot.stocks?.isNotEmpty == true ? snapshot.stocks!.first.productPurchasePrice ?? '0' : '0'}'),
_lang.costInclusionTax: (snapshot.vatType == 'exclusive')
? (snapshot.stocks != null &&
snapshot.stocks!.isNotEmpty &&
snapshot.stocks!.first.productPurchasePrice != null &&
snapshot.vatAmount != null
? '$currency${snapshot.stocks!.first.productPurchasePrice! + snapshot.vatAmount!}'
: '0')
: ('$currency${snapshot.stocks?.isNotEmpty == true ? snapshot.stocks!.first.productPurchasePrice ?? '0' : '0'}'),
'${_lang.profitMargin} (%)': (snapshot.stocks?.isNotEmpty == true &&
snapshot.stocks!.first.profitPercent != null
? snapshot.stocks!.first.profitPercent.toString()
: '0'),
_lang.mrpOrSalePrice: (snapshot.stocks?.isNotEmpty == true &&
snapshot.stocks!.first.productSalePrice != null
? '$currency${snapshot.stocks!.first.productSalePrice}'
: '0'),
_lang.wholeSalePrice: (snapshot.stocks?.isNotEmpty == true &&
snapshot.stocks!.first.productWholeSalePrice != null
? '$currency${snapshot.stocks!.first.productWholeSalePrice}'
: '0'),
_lang.dealerPrice: (snapshot.stocks?.isNotEmpty == true &&
snapshot.stocks!.first.productDealerPrice != null
? '$currency${snapshot.stocks?.first.productDealerPrice}'
: '0'),
_lang.manufactureDate:
(snapshot.stocks?.isNotEmpty == true && snapshot.stocks!.first.mfgDate != null)
? DateFormat('d MMMM yyyy')
.format(DateTime.parse(snapshot.stocks!.first.mfgDate!))
: 'n/a',
_lang.expiredDate:
(snapshot.stocks?.isNotEmpty == true && snapshot.stocks!.first.expireDate != null)
? DateFormat('d MMMM yyyy')
.format(DateTime.parse(snapshot.stocks?.first.expireDate ?? ''))
: 'n/a',
_lang.warranty:
'${snapshot.warrantyGuaranteeInfo?.warrantyDuration?.toString() ?? ''} ${snapshot.warrantyGuaranteeInfo?.warrantyUnit?.toString() ?? 'n/a'}',
_lang.warranty:
'${snapshot.warrantyGuaranteeInfo?.guaranteeDuration?.toString() ?? ''} ${snapshot.warrantyGuaranteeInfo?.guaranteeUnit?.toString() ?? 'n/a'}',
}.entries.map(
(entry) => KeyValueRow(
title: entry.key,
titleFlex: 6,
description: entry.value.toString(),
descriptionFlex: 8,
),
),
//---------------variant product----------------
if (snapshot.productType == 'variant')
...{
_lang.skuOrCode: snapshot.productCode ?? 'n/a',
_lang.brand: snapshot.brand?.brandName ?? 'n/a',
_lang.model: snapshot.productModel?.name ?? 'n/a',
_lang.rack: snapshot.shelf?.name ?? 'n/a',
_lang.lowStockAlert: snapshot.alertQty?.toString() ?? 'n/a',
_lang.taxReport: snapshot.vatType ?? 'n/a',
_lang.tax: snapshot.vatAmount?.toString() ?? 'n/a',
_lang.warranty:
'${snapshot.warrantyGuaranteeInfo?.warrantyDuration?.toString() ?? ''} ${snapshot.warrantyGuaranteeInfo?.warrantyUnit?.toString() ?? 'n/a'}',
_lang.guarantee:
'${snapshot.warrantyGuaranteeInfo?.guaranteeDuration?.toString() ?? ''} ${snapshot.warrantyGuaranteeInfo?.guaranteeUnit?.toString() ?? 'n/a'}',
}.entries.map(
(entry) => KeyValueRow(
title: entry.key,
titleFlex: 6,
description: entry.value.toString(),
descriptionFlex: 8,
),
),
//---------------Combo product----------------
if (snapshot.productType == 'combo')
...{
_lang.skuOrCode: snapshot.productCode ?? 'n/a',
_lang.brand: snapshot.brand?.brandName ?? 'n/a',
_lang.model: snapshot.productModel?.name ?? 'n/a',
_lang.units: snapshot.unit?.unitName ?? 'n/a',
_lang.rack: snapshot.rack?.name ?? 'n/a',
_lang.shelf: snapshot.shelf?.name ?? 'n/a',
_lang.lowStockAlert: snapshot.alertQty?.toString() ?? 'n/a',
_lang.type: snapshot.productType ?? 'n/a',
_lang.taxType: snapshot.vatType ?? 'n/a',
_lang.tax: snapshot.vatAmount?.toString() ?? 'n/a',
_lang.netTotalAmount:
(snapshot.productSalePrice != null && snapshot.profitPercent != null)
? (snapshot.productSalePrice! / (1 + (snapshot.profitPercent! / 100)))
.toStringAsFixed(2)
: 'n/a',
'${_lang.profitMargin} (%)': '${snapshot.profitPercent ?? 0}%',
_lang.sellingPrice: '$currency${snapshot.productSalePrice ?? 0}',
_lang.warranty:
'${snapshot.warrantyGuaranteeInfo?.warrantyDuration?.toString() ?? ''} ${snapshot.warrantyGuaranteeInfo?.warrantyUnit?.toString() ?? 'n/a'}',
_lang.guarantee:
'${snapshot.warrantyGuaranteeInfo?.guaranteeDuration?.toString() ?? ''} ${snapshot.warrantyGuaranteeInfo?.guaranteeUnit?.toString() ?? 'n/a'}',
}.entries.map(
(entry) => KeyValueRow(
title: entry.key,
titleFlex: 6,
description: entry.value.toString(),
descriptionFlex: 8,
),
),
],
),
),
],
),
),
//--------------variant product details---------------------------------
if (snapshot.productType == 'variant') ...[
Padding(
padding: EdgeInsetsDirectional.only(start: 16, end: 16, top: 16, bottom: 6),
child: Text(
_lang.variationsProduct,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
ListView.separated(
// padding: EdgeInsetsGeometry.symmetric(vertical: 10, horizontal: 16),
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: snapshot.stocks?.length ?? 0,
separatorBuilder: (context, index) => Divider(
thickness: 0.3,
color: kBorderColorTextField,
),
itemBuilder: (context, index) {
return ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 0),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
snapshot.stocks?[index].variantName ?? 'n/a',
maxLines: 1,
style: theme.textTheme.bodyMedium?.copyWith(
color: kTitleColor,
// fontWeight: FontWeight.w500,
fontSize: 16,
),
),
),
Flexible(
child: Text(
'${_lang.sale}: $currency${snapshot.stocks?[index].productSalePrice ?? '0'}',
style: theme.textTheme.bodyMedium?.copyWith(
color: kTitleColor,
fontWeight: FontWeight.w400,
fontSize: 16,
),
maxLines: 1,
),
),
],
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
'${_lang.batch}: ${snapshot.stocks?[index].batchNo ?? 'N/A'}',
maxLines: 1,
style: theme.textTheme.bodyMedium?.copyWith(
color: kTitleColor,
// fontWeight: FontWeight.w500,
fontSize: 14,
),
),
),
Flexible(
child: RichText(
text: TextSpan(
text: '${_lang.stock}: ',
style: theme.textTheme.bodyMedium?.copyWith(
color: kNeutralColor,
fontWeight: FontWeight.w400,
fontSize: 14,
),
children: [
TextSpan(
text: snapshot.stocks?[index].productStock.toString() ?? '0',
style: theme.textTheme.bodyMedium?.copyWith(
color: Color(0xff34C759),
fontWeight: FontWeight.w400,
fontSize: 14,
),
),
],
),
),
),
],
),
trailing: SizedBox(
width: 30,
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
iconColor: kPeraColor,
onSelected: (value) {
switch (value) {
case 'view':
viewModal(context, snapshot, index);
break;
case 'edit':
final stock = snapshot.stocks?[index];
final cartProduct = CartProductModelPurchase(
productId: snapshot.id ?? 0,
variantName: stock?.variantName,
brandName: snapshot.brand?.brandName,
productName: snapshot.productName ?? '',
productDealerPrice: stock?.productDealerPrice,
productPurchasePrice: stock?.productPurchasePrice,
productSalePrice: stock?.productSalePrice,
productWholeSalePrice: stock?.productWholeSalePrice,
quantities: stock?.productStock,
productType: snapshot.productType ?? '',
vatAmount: snapshot.vatAmount ?? 0,
vatRate: snapshot.vat?.rate ?? 0,
vatType: snapshot.vatType ?? 'exclusive',
expireDate: stock?.expireDate,
mfgDate: stock?.mfgDate,
profitPercent: stock?.profitPercent ?? 0,
stock: stock?.productStock,
batchNumber: stock?.batchNo ?? '',
);
addProductInPurchaseCartButtomSheet(
context: context,
product: cartProduct,
ref: ref,
fromUpdate: false,
index: index,
fromStock: true,
stocks: []);
break;
case 'add_stock':
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
productStockController.text = '1';
salePriceController.text =
snapshot.stocks?[index].productSalePrice?.toString() ?? '0.0';
addStockPopUp(context, _formKey, theme, snapshot, index);
break;
case 'delete':
showEditDeletePopUp(
context: context,
data: snapshot.stocks?[index],
ref: ref,
productId: widget.details.id.toString());
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(value: 'view', child: Text(_lang.view)),
PopupMenuItem(value: 'edit', child: Text(_lang.edit)),
PopupMenuItem(value: 'add_stock', child: Text(_lang.addStock)),
PopupMenuItem(value: 'delete', child: Text(_lang.delete)),
],
),
),
visualDensity: VisualDensity(vertical: -4, horizontal: -4),
);
},
),
],
//--------------Combo product details---------------------------------
if (snapshot.productType == 'combo') ...[
Padding(
padding: EdgeInsetsDirectional.only(start: 16, end: 16, top: 16, bottom: 6),
child: Text(
_lang.comboProducts,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
ListView.separated(
// padding: EdgeInsetsGeometry.symmetric(vertical: 10, horizontal: 16),
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: snapshot.comboProducts?.length ?? 0,
separatorBuilder: (context, index) => Divider(
thickness: 0.3,
color: kBorderColorTextField,
),
itemBuilder: (context, index) {
final combo = snapshot.comboProducts![index];
return ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 0),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${combo.stock?.product?.productName ?? 'n/a'} ${combo.stock?.variantName ?? ''}',
maxLines: 1,
style: theme.textTheme.bodyMedium?.copyWith(
color: kTitleColor,
fontSize: 16,
),
),
Text(
'${_lang.qty}: ${combo.quantity ?? '0'}',
style: theme.textTheme.bodyLarge?.copyWith(
color: kPeraColor,
),
),
],
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_lang.code}: ${combo.stock?.product?.productCode ?? 'n/a'}, ${_lang.batchNo}: ${snapshot.comboProducts?[index].stock?.batchNo ?? 'n/a'}',
maxLines: 1,
style: theme.textTheme.bodyMedium?.copyWith(
color: kPeraColor,
),
),
Text(
'$currency${combo.stock?.productSalePrice ?? 0}',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
// trailing: SizedBox(
// width: 30,
// child: PopupMenuButton<String>(
// iconColor: kPeraColor,
// onSelected: (value) async {
// switch (value) {
// case 'edit':
// // Convert ComboProductComponent → ComboItem
// final comboItem = ComboItem(
// product: combo.product!,
// stockData: combo.stock!,
// quantity: combo.quantity ?? combo.stock?.productStock ?? 1,
// manualPurchasePrice: combo.purchasePrice?.toDouble(),
// );
//
// // Navigate to edit page
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (context) => AddOrEditComboItem(
// existingItem: comboItem,
// onSubmit: (updatedItem) {
// setState(() {
// // Convert ComboItem → ComboProductComponent after edit
// snapshot.comboProducts![index] = ComboProductComponent(
// id: combo.id,
// productId: updatedItem.product.id,
// stockId: updatedItem.stockData.id,
// purchasePrice: updatedItem.manualPurchasePrice,
// quantity: updatedItem.quantity,
// stock: updatedItem.stockData,
// product: updatedItem.product,
// );
// });
// },
// ),
// ),
// );
// break;
//
// case 'delete':
// final confirmDelete = await showDialog<bool>(
// context: context,
// builder: (context) => AlertDialog(
// title: const Text('Delete Combo Product'),
// content: const Text('Are you sure you want to delete this item?'),
// actions: [
// TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
// TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')),
// ],
// ),
// );
//
// if (confirmDelete == true) {
// setState(() {
// snapshot.comboProducts!.removeAt(index);
// });
// }
// break;
// }
// },
// itemBuilder: (BuildContext context) => [
// const PopupMenuItem(value: 'edit', child: Text('Edit')),
// const PopupMenuItem(
// value: 'delete',
// child: Text('Delete', style: TextStyle(color: Colors.red)),
// ),
// ],
// ),
// ),
visualDensity: VisualDensity(vertical: -4, horizontal: -4),
);
},
),
],
} else
Center(child: PermitDenyWidget()),
],
),
),
),
);
}, error: (e, stack) {
return Text(e.toString());
}, loading: () {
return const Center(child: CircularProgressIndicator());
}));
}
// Add stock popup
Future<dynamic> addStockPopUp(
BuildContext context, GlobalKey<FormState> _formKey, ThemeData theme, Product snapshot, int index) {
return showDialog(
context: context,
builder: (context) {
return Dialog(
insetPadding: EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsetsDirectional.only(start: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
lang.S.of(context).addStock,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: kTitleColor,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
IconButton(
icon: Icon(Icons.close, color: kTitleColor, size: 16),
iconSize: 16,
constraints: BoxConstraints(
minWidth: 30,
minHeight: 30,
),
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(Color(0xffEEF3FF)),
padding: WidgetStatePropertyAll(EdgeInsets.zero),
),
onPressed: () => Navigator.pop(context),
),
],
),
),
Divider(
thickness: 0.3,
color: kBorderColorTextField,
height: 0,
),
SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
textAlign: TextAlign.center,
controller: productStockController,
validator: (value) {
final int? enteredStock = int.tryParse(value ?? '');
if (enteredStock == null || enteredStock < 1) {
return lang.S.of(context).stockWarn;
}
return null;
},
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: lang.S.of(context).enterStock,
prefixIcon: Container(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
height: 26,
width: 26,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color(0xffE0E2E7),
),
child: InkWell(
borderRadius: BorderRadius.circular(50),
onTap: () {
int quantity = int.tryParse(productStockController.text) ?? 1;
if (quantity > 1) {
quantity--;
productStockController.text = quantity.toString();
}
},
child: Icon(Icons.remove, color: Color(0xff4A4A52)),
),
),
suffixIcon: Container(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
height: 26,
width: 26,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: kMainColor.withOpacity(0.15),
),
child: InkWell(
borderRadius: BorderRadius.circular(50),
onTap: () {
int quantity = int.tryParse(productStockController.text) ?? 1;
quantity++;
productStockController.text = quantity.toString();
},
child: Icon(Icons.add, color: theme.colorScheme.primary),
),
),
border: UnderlineInputBorder(borderSide: BorderSide(color: Color(0xffE0E2E7))),
enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: Color(0xffE0E2E7))),
focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: Color(0xffE0E2E7))),
contentPadding: EdgeInsets.symmetric(horizontal: 0, vertical: 8),
),
),
SizedBox(height: 24),
TextFormField(
readOnly: true,
controller: salePriceController,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
],
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: lang.S.of(context).salePrice,
hintText: lang.S.of(context).enterAmount,
border: const OutlineInputBorder(),
),
),
SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: BorderSide(color: Color(0xffF68A3D)),
),
child: Text(lang.S.of(context).cancel, style: TextStyle(color: Color(0xffF68A3D))),
onPressed: () => Navigator.pop(context),
),
),
SizedBox(width: 12),
Expanded(
child: ElevatedButton(
child: Text(lang.S.of(context).save),
onPressed: () async {
if (_formKey.currentState?.validate() ?? false) {
final int newStock = int.tryParse(productStockController.text) ?? 0;
try {
EasyLoading.show(status: lang.S.of(context).updating);
final repo = ProductRepo();
final String productId = snapshot.stocks?[index].id.toString() ?? '';
final bool success = await repo.addStock(
id: productId,
qty: newStock.toString(),
);
EasyLoading.dismiss();
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(lang.S.of(context).updateSuccess)),
);
ref.refresh(fetchProductDetails(widget.details.id.toString()));
ref.refresh(productProvider);
productStockController.clear();
salePriceController.clear();
Navigator.pop(context);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(lang.S.of(context).updateFailed)),
);
}
} catch (e) {
EasyLoading.dismiss();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${e.toString()}')),
);
}
}
},
),
),
],
),
],
),
)
],
),
),
);
},
);
}
// view modal sheet
Future<dynamic> viewModal(BuildContext context, Product snapshot, int index) {
final _lang = lang.S.of(context);
return showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (BuildContext context, StateSetter setNewState) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsetsDirectional.only(start: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
lang.S.of(context).view,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
IconButton(
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.close, size: 18),
)
],
),
),
Divider(color: kBorderColor, height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
children: [
if (snapshot.stocks != null && snapshot.stocks!.isNotEmpty && index < snapshot.stocks!.length)
...{
_lang.batchNo: snapshot.stocks![index].batchNo ?? 'n/a',
_lang.qty: snapshot.stocks![index].productStock?.toString() ?? '0',
_lang.costExclusionTax: snapshot.vatType != 'exclusive'
? (snapshot.stocks![index].productPurchasePrice != null && snapshot.vatAmount != null
? '${snapshot.stocks![index].productPurchasePrice! - snapshot.vatAmount!}'
: 'n/a')
: (snapshot.stocks![index].productPurchasePrice?.toString() ?? 'n/a'),
_lang.costInclusionTax: snapshot.vatType == 'exclusive'
? (snapshot.stocks![index].productPurchasePrice != null && snapshot.vatAmount != null
? '${snapshot.stocks![index].productPurchasePrice! + snapshot.vatAmount!}'
: 'n/a')
: (snapshot.stocks![index].productPurchasePrice?.toString() ?? 'n/a'),
'${_lang.profitMargin} (%)': snapshot.stocks![index].profitPercent?.toString() ?? 'n/a',
_lang.salePrice: snapshot.stocks![index].productSalePrice?.toString() ?? 'n/a',
_lang.wholeSalePrice: snapshot.stocks![index].productWholeSalePrice?.toString() ?? 'n/a',
_lang.dealerPrice: snapshot.stocks![index].productDealerPrice?.toString() ?? 'n/a',
_lang.manufactureDate:
(snapshot.stocks![index].mfgDate != null && snapshot.stocks![index].mfgDate!.isNotEmpty)
? DateFormat('d MMMM yyyy')
.format(DateTime.tryParse(snapshot.stocks![index].mfgDate!) ?? DateTime(0))
: 'n/a',
_lang.expiredDate: (snapshot.stocks![index].expireDate != null &&
snapshot.stocks![index].expireDate!.isNotEmpty)
? DateFormat('d MMMM yyyy')
.format(DateTime.tryParse(snapshot.stocks![index].expireDate!) ?? DateTime(0))
: 'n/a',
}.entries.map(
(entry) => KeyValueRow(
title: entry.key,
titleFlex: 6,
description: entry.value.toString(),
descriptionFlex: 8,
),
)
else
Text(_lang.noStockAvailable),
],
),
),
],
);
},
),
);
}
}
Future<void> showEditDeletePopUp(
{required BuildContext context, Stock? data, required WidgetRef ref, required String productId}) async {
final _theme = Theme.of(context);
return await showDialog(
barrierDismissible: false,
context: context,
builder: (BuildContext dialogContext) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: Container(
padding: EdgeInsets.all(16),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
lang.S.of(context).deleteBatchWarn,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 26),
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color(0xffF68A3D).withValues(alpha: 0.1),
),
padding: EdgeInsets.all(20),
child: SvgPicture.asset(
height: 146,
width: 146,
'images/trash.svg',
),
),
SizedBox(height: 26),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () async {
Navigator.pop(context);
},
child: Text(lang.S.of(context).cancel),
),
),
SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () async {
await Future.delayed(Duration.zero);
ProductRepo repo = ProductRepo();
bool success;
success = await repo.deleteStock(
id: data?.id.toString() ?? '',
);
if (success) {
ref.refresh(fetchProductDetails(productId));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(lang.S.of(context).deletedSuccessFully)));
Navigator.pop(context);
}
},
child: Text(lang.S.of(context).delete),
),
),
],
),
],
),
),
),
);
},
);
}

View File

@@ -0,0 +1,487 @@
// File: product_list.dart (Refactored and Cleaned)
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_svg/svg.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hugeicons/hugeicons.dart';
import 'package:iconly/iconly.dart';
import 'package:icons_plus/icons_plus.dart';
// --- Local Imports ---
import 'package:mobile_pos/Const/api_config.dart';
import 'package:mobile_pos/Provider/product_provider.dart';
import 'package:mobile_pos/Provider/profile_provider.dart';
import 'package:mobile_pos/Screens/Products/product_details.dart';
import 'package:mobile_pos/Screens/Products/product_setting/provider/setting_provider.dart';
import 'package:mobile_pos/Screens/product%20variation/product_variation_list_screen.dart';
import 'package:mobile_pos/Screens/product_unit/unit_list.dart';
import 'package:mobile_pos/Screens/shelfs/shelf_list_screen.dart';
import 'package:mobile_pos/core/theme/_app_colors.dart';
import 'package:mobile_pos/generated/l10n.dart' as lang;
import 'package:nb_utils/nb_utils.dart';
import 'package:screenshot/screenshot.dart';
import '../../GlobalComponents/bar_code_scaner_widget.dart';
import '../../GlobalComponents/glonal_popup.dart';
import '../../constant.dart';
import '../../currency.dart';
import '../../service/check_actions_when_no_branch.dart';
import '../../service/check_user_role_permission_provider.dart';
import '../../widgets/empty_widget/_empty_widget.dart';
import '../barcode/gererate_barcode.dart';
import '../product racks/product_racks_list.dart';
import '../product_brand/brands_list.dart';
import '../product_category/product_category_list_screen.dart';
import '../product_model/product_model_list.dart';
import '../product_category/provider/product_category_provider/product_unit_provider.dart';
import 'Repo/product_repo.dart';
import 'add product/add_product.dart';
import 'bulk product upload/bulk_product_upload_screen.dart';
import '../hrm/widgets/deleteing_alart_dialog.dart';
class ProductList extends ConsumerStatefulWidget {
const ProductList({super.key});
@override
ConsumerState<ProductList> createState() => _ProductListState();
}
class _ProductListState extends ConsumerState<ProductList> {
bool _isRefreshing = false;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
final _productRepo = ProductRepo(); // Instantiate repo once
// --- Data Refresh Logic ---
Future<void> _refreshData() async {
if (_isRefreshing) return;
_isRefreshing = true;
// Invalidate main providers to force reload
ref.invalidate(productProvider);
ref.invalidate(categoryProvider);
ref.invalidate(fetchSettingProvider);
// Wait for reload (optional, but good for UX)
await Future.delayed(const Duration(milliseconds: 500));
_isRefreshing = false;
}
@override
void initState() {
super.initState();
_searchController.addListener(() {
setState(() {
_searchQuery = _searchController.text;
});
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
// --- Helper Widgets ---
// Builds the main context menu for product management tasks
Widget _buildProductMenu() {
final _theme = Theme.of(context);
// Helper function to build consistent menu items
PopupMenuItem<void> buildItem(
{required VoidCallback onTap, List<List<dynamic>>? hugeIcons, IconData? icon, required String text}) {
return PopupMenuItem(
onTap: onTap,
child: Row(
children: [
hugeIcons != null
? HugeIcon(
icon: hugeIcons,
color: kPeraColor,
size: 20,
)
: Icon(
icon,
color: kPeraColor,
size: 20,
),
const SizedBox(width: 8),
Text(
text,
style: _theme.textTheme.bodyLarge,
),
],
),
);
}
return PopupMenuButton<void>(
itemBuilder: (context) => [
buildItem(
onTap: () => const CategoryList(isFromProductList: true).launch(context),
hugeIcons: HugeIcons.strokeRoundedAddToList,
text: lang.S.of(context).productCategory,
),
buildItem(
onTap: () => const BrandsList(isFromProductList: true).launch(context),
hugeIcons: HugeIcons.strokeRoundedSecurityCheck,
text: lang.S.of(context).brand,
),
buildItem(
onTap: () => const ProductModelList(fromProductList: true).launch(context),
hugeIcons: HugeIcons.strokeRoundedDrawingMode,
text: lang.S.of(context).model,
),
buildItem(
onTap: () => const UnitList(isFromProductList: true).launch(context),
hugeIcons: HugeIcons.strokeRoundedCells,
text: lang.S.of(context).productUnit,
),
buildItem(
onTap: () => const ProductShelfList(isFromProductList: true).launch(context),
icon: Bootstrap.bookshelf,
text: lang.S.of(context).shelf,
),
buildItem(
onTap: () => const ProductRackList(isFromProductList: true).launch(context),
icon: Bootstrap.hdd_rack,
text: lang.S.of(context).racks,
),
buildItem(
onTap: () => const ProductVariationList().launch(context),
hugeIcons: HugeIcons.strokeRoundedPackage,
text: lang.S.of(context).variations,
),
// const PopupMenuDivider(),
buildItem(
onTap: () async {
bool result = await checkActionWhenNoBranch(ref: ref, context: context);
if (result) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const BulkUploader(),
),
);
}
},
hugeIcons: HugeIcons.strokeRoundedInboxUpload,
text: lang.S.of(context).bulk,
),
buildItem(
onTap: () => const BarcodeGeneratorScreen().launch(context),
hugeIcons: HugeIcons.strokeRoundedBarCode01,
text: lang.S.of(context).barcodeGen,
),
],
offset: const Offset(0, 40),
color: kWhite,
padding: EdgeInsets.zero,
elevation: 2,
);
}
// Builds the search and barcode scanner input field
Widget _buildSearchInput() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Row(
children: [
Flexible(
child: TextFormField(
controller: _searchController,
decoration: InputDecoration(
hintText: lang.S.of(context).searchH,
prefixIcon: Padding(
padding: EdgeInsetsDirectional.only(start: 16),
child: Icon(
AntDesign.search_outline,
color: kPeraColor,
),
),
contentPadding: EdgeInsetsDirectional.zero,
),
onChanged: (value) {
// No need for setState here as controller listener already handles it
},
),
),
SizedBox(width: 10),
Container(
height: 48,
width: 48,
decoration: BoxDecoration(
color: kMainColor50,
borderRadius: BorderRadius.circular(4),
),
child: IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => BarcodeScannerWidget(
onBarcodeFound: (String code) {
setState(() {
_searchController.text = code;
_searchQuery = code;
});
},
),
);
},
icon: HugeIcon(
icon: HugeIcons.strokeRoundedIrisScan,
color: kMainColor,
),
),
),
],
),
);
}
// Builds the main list of products
Widget _buildProductList(List<dynamic> products) {
final filteredProducts = products.where((product) {
final query = _searchQuery.toLowerCase();
final name = product.productName?.toLowerCase() ?? '';
final code = product.productCode?.toLowerCase() ?? '';
return name.contains(query) || code.contains(query);
}).toList();
final permissionService = PermissionService(ref);
final _theme = Theme.of(context);
final locale = Localizations.localeOf(context).languageCode;
if (!permissionService.hasPermission(Permit.productsRead.value)) {
return const Center(child: PermitDenyWidget());
}
if (filteredProducts.isEmpty && _searchQuery.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.only(top: 30.0),
child: Text(
lang.S.of(context).addProduct,
maxLines: 2,
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 20.0,
),
),
),
);
}
if (filteredProducts.isEmpty && _searchQuery.isNotEmpty) {
return Center(
child: Padding(
padding: EdgeInsets.only(top: 30.0),
child: Text(lang.S.of(context).noProductMatchYourSearch),
),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: filteredProducts.length,
itemBuilder: (_, i) {
final product = filteredProducts[i];
// Helper function for building PopupMenuItems (Edit/Delete)
PopupMenuItem<int> buildActionItem(
{required int value, required IconData icon, required String text, required VoidCallback onTap}) {
return PopupMenuItem(
onTap: onTap,
value: value,
child: Row(
children: [
Icon(icon, color: kGreyTextColor),
const SizedBox(width: 10),
Text(text, style: _theme.textTheme.bodyMedium?.copyWith(color: kGreyTextColor)),
],
),
);
}
return ListTile(
onTap: () =>
Navigator.push(context, MaterialPageRoute(builder: (context) => ProductDetails(details: product))),
visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
contentPadding: EdgeInsets.symmetric(horizontal: 16),
leading: product.productPicture == null
? CircleAvatarWidget(
name: product.productName,
size: const Size(50, 50),
)
: Container(
height: 50,
width: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
image: NetworkImage('${APIConfig.domain}${product.productPicture!}'),
fit: BoxFit.cover,
),
),
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
product.productName ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
Text(
product.productType == 'combo'
? '$currency${product.productSalePrice.toString()}'
: "$currency${product.stocks != null && product.stocks!.isNotEmpty && product.stocks!.first.productSalePrice != null ? product.stocks!.first.productSalePrice : '0'}",
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: _theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"${lang.S.of(context).type} : ${product.productType == 'single' ? lang.S.of(context).single : product.productType == 'variant' ? locale == 'en' ? 'Variant' : lang.S.of(context).variations : product.productType == 'combo' ? lang.S.of(context).combo : product.productType}",
style: _theme.textTheme.bodyMedium?.copyWith(
fontSize: 14,
fontWeight: FontWeight.w400,
color: kPeraColor,
),
),
Text.rich(
TextSpan(
text: '${lang.S.of(context).stock} : ',
style: _theme.textTheme.bodyMedium?.copyWith(
color: kPeraColor,
),
children: [
TextSpan(
text: product.productType == 'combo'
? lang.S.of(context).combo
: '${product.stocksSumProductStock ?? '0'}',
style: _theme.textTheme.bodyMedium?.copyWith(
color: DAppColors.kSuccess,
))
]),
),
],
),
trailing: SizedBox(
width: 30,
child: PopupMenuButton<int>(
style: const ButtonStyle(padding: WidgetStatePropertyAll(EdgeInsets.zero)),
itemBuilder: (context) => [
// Edit Action
buildActionItem(
value: 1,
icon: IconlyBold.edit,
text: lang.S.of(context).edit,
onTap: () async {
bool result = await checkActionWhenNoBranch(ref: ref, context: context);
if (!result) return;
Navigator.push(context, MaterialPageRoute(builder: (context) => AddProduct(productModel: product)));
},
),
// Delete Action
buildActionItem(
value: 2,
icon: IconlyBold.delete,
text: lang.S.of(context).delete,
onTap: () async {
bool confirmDelete = await showDeleteConfirmationDialog(context: context, itemName: 'product');
if (confirmDelete) {
EasyLoading.show(status: lang.S.of(context).deleting);
await _productRepo.deleteProduct(id: product.id.toString(), context: context, ref: ref);
// Refresh will happen automatically if repo is implemented correctly
}
},
),
],
offset: const Offset(0, 40),
color: kWhite,
padding: EdgeInsets.zero,
elevation: 2,
),
),
);
},
separatorBuilder: (context, index) {
return Divider(color: const Color(0xff808191).withAlpha(50));
},
);
}
// --- Main Build Method ---
@override
Widget build(BuildContext context) {
return GlobalPopup(
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
surfaceTintColor: kWhite,
elevation: 0,
iconTheme: const IconThemeData(color: Colors.black),
title: Text(lang.S.of(context).productList),
actions: [_buildProductMenu()],
toolbarHeight: 80,
bottom: PreferredSize(
preferredSize: Size.fromHeight(40),
child: _buildSearchInput(),
),
centerTitle: true,
),
floatingActionButton: FloatingActionButton(
backgroundColor: kMainColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(100)),
onPressed: () async {
bool result = await checkActionWhenNoBranch(ref: ref, context: context);
if (result) {
Navigator.push(context, MaterialPageRoute(builder: (context) => AddProduct()));
}
},
child: const Icon(Icons.add, color: kWhite),
),
body: RefreshIndicator(
onRefresh: _refreshData,
child: Consumer(builder: (context, ref, __) {
final providerData = ref.watch(productProvider);
// This is the outer check, but the main data display is inside providerData.when
final businessInfo = ref.watch(businessInfoProvider);
return businessInfo.when(
data: (_) => providerData.when(
data: (products) => SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: _buildProductList(products),
),
error: (e, stack) => Center(child: Text('Error loading products: ${e.toString()}')),
loading: () => const Center(child: CircularProgressIndicator()),
),
error: (e, stack) => Center(child: Text('Error loading business info: ${e.toString()}')),
loading: () => const Center(child: CircularProgressIndicator()),
);
}),
),
),
);
}
}

View File

@@ -0,0 +1,243 @@
class GetProductSettingModel {
GetProductSettingModel({
this.message,
this.data,
});
GetProductSettingModel.fromJson(dynamic json) {
message = json['message'];
data = json['data'] != null ? Data.fromJson(json['data']) : null;
}
String? message;
Data? data;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['message'] = message;
if (data != null) {
map['data'] = data?.toJson();
}
return map;
}
}
class Data {
Data({
// this.id,
// this.businessId,
this.modules,
this.createdAt,
this.updatedAt,
});
Data.fromJson(dynamic json) {
// id = json['id'];
// businessId = json['business_id'];
modules = json['modules'] != null ? Modules.fromJson(json['modules']) : null;
createdAt = json['created_at'];
updatedAt = json['updated_at'];
}
// num? id;
// num? businessId;
Modules? modules;
String? createdAt;
String? updatedAt;
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
// map['id'] = id;
// map['business_id'] = businessId;
if (modules != null) {
map['modules'] = modules?.toJson();
}
map['created_at'] = createdAt;
map['updated_at'] = updatedAt;
return map;
}
}
class Modules {
Modules({
this.showProductName,
this.variantName,
this.showProductCode,
this.showProductStock,
this.showProductSalePrice,
this.showProductDealerPrice,
this.showProductWholesalePrice,
this.showProductUnit,
this.showProductBrand,
this.showProductCategory,
this.showProductManufacturer,
this.showProductImage,
this.showExpireDate,
this.showAlertQty,
this.showVatId,
this.showVatType,
this.showExclusivePrice,
this.showInclusivePrice,
this.showProfitPercent,
this.showWarehouse,
this.showBatchNo,
this.showMfgDate,
this.showModelNo,
this.defaultSalePrice,
this.defaultWholesalePrice,
this.defaultDealerPrice,
this.showProductTypeSingle,
this.showProductTypeVariant,
this.showAction,
this.defaultExpiredDate,
this.defaultMfgDate,
this.expireDateType,
this.mfgDateType,
this.showProductBatchNo,
this.showProductExpireDate,
// --- NEW FIELDS ---
this.showProductTypeCombo,
this.showRack,
this.showShelf,
this.showGuaranty,
this.showWarranty,
// this.showSerial,
// ------------------
});
Modules.fromJson(dynamic json) {
showProductName = json['show_product_name'];
variantName = json['variant_name'];
showProductCode = json['show_product_code'];
showProductStock = json['show_product_stock'];
showProductSalePrice = json['show_product_sale_price'];
showProductDealerPrice = json['show_product_dealer_price'];
showProductWholesalePrice = json['show_product_wholesale_price'];
showProductUnit = json['show_product_unit'];
showProductBrand = json['show_product_brand'];
showProductCategory = json['show_product_category'];
showProductManufacturer = json['show_product_manufacturer'];
showProductImage = json['show_product_image'];
showExpireDate = json['show_expire_date'];
showAlertQty = json['show_alert_qty'];
showVatId = json['show_vat_id'];
showVatType = json['show_vat_type'];
showWarehouse = json['show_warehouse'];
showExclusivePrice = json['show_exclusive_price'];
showInclusivePrice = json['show_inclusive_price'];
showProfitPercent = json['show_profit_percent'];
showBatchNo = json['show_batch_no'];
showMfgDate = json['show_mfg_date'];
showModelNo = json['show_model_no'];
defaultSalePrice = json['default_sale_price'];
defaultWholesalePrice = json['default_wholesale_price'];
defaultDealerPrice = json['default_dealer_price'];
showProductTypeSingle = json['show_product_type_single'];
showProductTypeVariant = json['show_product_type_variant'];
showAction = json['show_action'];
defaultExpiredDate = json['default_expired_date'];
defaultMfgDate = json['default_mfg_date'];
expireDateType = json['expire_date_type'];
mfgDateType = json['mfg_date_type'];
showProductBatchNo = json['show_product_batch_no'];
showProductExpireDate = json['show_product_expire_date'];
// --- NEW FIELDS ---
showProductTypeCombo = json['show_product_type_combo'] ?? '1';
showRack = json['show_rack'] ?? '1';
showShelf = json['show_shelf'] ?? '1';
showGuaranty = json['show_guarantee'] ?? '1';
showWarranty = json['show_warranty'] ?? '1';
// showSerial = json['show_serial'] ?? '1';
// ------------------
}
String? showProductName;
String? showProductCode;
String? showProductStock;
String? showProductSalePrice;
String? showProductDealerPrice;
String? showProductWholesalePrice;
String? showProductUnit;
String? showProductBrand;
String? showProductCategory;
String? showProductManufacturer;
String? showProductImage;
String? showExpireDate;
String? showAlertQty;
String? showVatId;
String? showVatType;
String? showExclusivePrice;
String? showInclusivePrice;
String? showProfitPercent;
String? showBatchNo;
String? variantName;
String? showMfgDate;
String? showModelNo;
String? defaultSalePrice;
String? defaultWholesalePrice;
String? defaultDealerPrice;
String? showProductTypeSingle;
String? showProductTypeVariant;
String? showAction;
String? defaultExpiredDate;
String? defaultMfgDate;
String? expireDateType;
String? mfgDateType;
String? showProductBatchNo;
String? showProductExpireDate;
// --- NEW FIELDS ---
String? showWarehouse;
String? showProductTypeCombo;
String? showRack;
String? showShelf;
String? showGuaranty;
String? showWarranty;
// String? showSerial;
// ------------------
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['show_product_name'] = showProductName;
map['variant_name'] = variantName;
map['show_product_code'] = showProductCode;
map['show_product_stock'] = showProductStock;
map['show_product_sale_price'] = showProductSalePrice;
map['show_product_dealer_price'] = showProductDealerPrice;
map['show_product_wholesale_price'] = showProductWholesalePrice;
map['show_product_unit'] = showProductUnit;
map['show_product_brand'] = showProductBrand;
map['show_product_category'] = showProductCategory;
map['show_product_manufacturer'] = showProductManufacturer;
map['show_product_image'] = showProductImage;
map['show_expire_date'] = showExpireDate;
map['show_alert_qty'] = showAlertQty;
map['show_vat_id'] = showVatId;
map['show_warehouse'] = showWarehouse;
map['show_vat_type'] = showVatType;
map['show_exclusive_price'] = showExclusivePrice;
map['show_inclusive_price'] = showInclusivePrice;
map['show_profit_percent'] = showProfitPercent;
map['show_batch_no'] = showBatchNo;
map['show_mfg_date'] = showMfgDate;
map['show_model_no'] = showModelNo;
map['default_sale_price'] = defaultSalePrice;
map['default_wholesale_price'] = defaultWholesalePrice;
map['default_dealer_price'] = defaultDealerPrice;
map['show_product_type_single'] = showProductTypeSingle;
map['show_product_type_variant'] = showProductTypeVariant;
map['show_action'] = showAction;
map['default_expired_date'] = defaultExpiredDate;
map['default_mfg_date'] = defaultMfgDate;
map['expire_date_type'] = expireDateType;
map['mfg_date_type'] = mfgDateType;
map['show_product_batch_no'] = showProductBatchNo;
map['show_product_expire_date'] = showProductExpireDate;
// --- NEW FIELDS ---
map['show_product_type_combo'] = showProductTypeCombo;
map['show_rack'] = showRack;
map['show_shelf'] = showShelf;
map['show_guarantee'] = showGuaranty;
map['show_warranty'] = showWarranty;
// map['show_serial'] = showSerial;
// ------------------
return map;
}
}

View File

@@ -0,0 +1,84 @@
class UpdateProductSettingModel {
String? productCode;
String? productStock;
String? salePrice;
String? dealerPrice;
String? wholesalePrice;
String? unit;
String? brand;
String? category;
String? manufacturer;
String? image;
String? showExpireDate;
String? alertQty;
String? vatId;
String? vatType;
String? exclusivePrice;
String? inclusivePrice;
String? profitPercent;
String? batchNo;
String? showManufactureDate;
String? model;
String? showSingle;
String? showVariant;
String? showAction;
String? defaultExpireDate;
String? defaultManufactureDate;
String? expireDateType;
String? manufactureDateType;
String? showBatchNo;
// --- NEW FIELDS ---
String? showWarehouse;
String? showProductTypeCombo;
String? showRack;
String? showShelf;
String? showGuaranty;
String? showWarranty;
// String? showSerial;
// String? defaultSalePrice;
// String? defaultWholeSalePrice;
// String? defaultDealerPrice;
UpdateProductSettingModel({
this.productCode,
this.productStock,
this.salePrice,
this.dealerPrice,
this.wholesalePrice,
this.unit,
this.brand,
this.category,
this.manufacturer,
this.image,
this.showExpireDate,
this.alertQty,
this.vatId,
this.vatType,
this.exclusivePrice,
this.inclusivePrice,
this.profitPercent,
this.batchNo,
this.showManufactureDate,
this.model,
this.showSingle,
this.showVariant,
this.showAction,
this.defaultExpireDate,
this.defaultManufactureDate,
this.expireDateType,
this.manufactureDateType,
this.showBatchNo,
this.showWarranty,
this.showGuaranty,
this.showShelf,
this.showRack,
this.showProductTypeCombo,
this.showWarehouse,
// this.showSerial,
// --- NEW FIELDS ---
// this.defaultSalePrice,
// this.defaultWholeSalePrice,
// this.defaultDealerPrice,
});
}

View File

@@ -0,0 +1,305 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_pos/Screens/Products/product_setting/provider/setting_provider.dart';
import 'package:mobile_pos/Screens/Products/product_setting/repo/product_setting_repo.dart';
import 'package:mobile_pos/constant.dart';
import 'model/get_product_setting_model.dart';
import 'model/product_setting_model.dart';
import 'package:mobile_pos/generated/l10n.dart' as lang;
class ProductSettingsDrawer extends ConsumerStatefulWidget {
final VoidCallback? onSave;
final Modules? modules;
const ProductSettingsDrawer({
super.key,
this.onSave,
this.modules,
});
@override
ConsumerState<ProductSettingsDrawer> createState() => _ProductSettingsDrawerState();
}
class _ProductSettingsDrawerState extends ConsumerState<ProductSettingsDrawer> {
final Map<String, bool> _switchValues = {};
@override
void initState() {
super.initState();
final modules = widget.modules;
_switchValues.addAll({
'Product Code': modules?.showProductCode == '1',
'Product Stock': modules?.showProductStock == '1',
'Sale': modules?.showProductSalePrice == '1',
'Dealer': modules?.showProductDealerPrice == '1',
'Wholesale Price': modules?.showProductWholesalePrice == '1',
'Unit': modules?.showProductUnit == '1',
'Brand': modules?.showProductBrand == '1',
'Category': modules?.showProductCategory == '1',
'Manufacturer': modules?.showProductManufacturer == '1',
'Image': modules?.showProductImage == '1',
'Show Expire Date': modules?.showExpireDate == '1',
'Low Stock Alert': modules?.showAlertQty == '1',
'Vat Id': modules?.showVatId == '1',
'Vat Type': modules?.showVatType == '1',
'Exclusive Price': modules?.showExclusivePrice == '1',
'Inclusive Price': modules?.showInclusivePrice == '1',
'Profit Percent': modules?.showProfitPercent == '1',
'Batch No': modules?.showBatchNo == '1',
'Show Manufacture Date': modules?.showMfgDate == '1',
'Model': modules?.showModelNo == '1',
'Show Single': modules?.showProductTypeSingle == '1',
'Show Combo': modules?.showProductTypeCombo == '1',
'Show Variant': modules?.showProductTypeVariant == '1',
'Show Action': modules?.showAction == '1',
'Warehouse': modules?.showWarehouse == '1',
'Rack': modules?.showRack == '1',
'Shelf': modules?.showShelf == '1',
'Guarantee': modules?.showGuaranty == '1',
'Warranty': modules?.showWarranty == '1',
});
_saleController.text = modules?.defaultSalePrice ?? '';
_wholesaleController.text = modules?.defaultWholesalePrice ?? '';
_dealerController.text = modules?.defaultDealerPrice ?? '';
}
final TextEditingController _saleController = TextEditingController();
final TextEditingController _wholesaleController = TextEditingController();
final TextEditingController _dealerController = TextEditingController();
GlobalKey<FormState> globalKey = GlobalKey<FormState>();
String getStringFromBool(Map<String, bool> map, String key) {
return map[key] == true ? '1' : '0';
}
final Map<String, String Function(lang.S)> labelMap = {
'Product Code': (s) => s.productCode,
'Product Stock': (s) => s.productStock,
'Sale': (s) => s.salePrice,
'Dealer': (s) => s.dealerPrice,
'Wholesale Price': (s) => s.wholeSalePrice,
'Unit': (s) => s.unit,
'Brand': (s) => s.brand,
'Category': (s) => s.category,
'Manufacturer': (s) => s.manufacturer,
'Image': (s) => s.image,
'Show Expire Date': (s) => s.showExpireDate,
'Low Stock Alert': (s) => s.lowStockAlert,
'Vat Id': (s) => s.vatId,
'Vat Type': (s) => s.vatType,
'Exclusive Price': (s) => s.exclusivePrice,
'Inclusive Price': (s) => s.inclusivePrice,
'Profit Percent': (s) => s.profitPercent,
'Batch No': (s) => s.batchNo,
'Show Manufacture Date': (s) => s.manufactureDate,
'Model': (s) => s.model,
'Show Single': (s) => s.showSingle,
'Show Combo': (s) => s.showCombo,
'Show Variant': (s) => s.showVariant,
'Show Action': (s) => s.showAction,
'Warehouse': (s) => s.warehouse,
'Rack': (s) => s.rack,
'Shelf': (s) => s.shelf,
'Guarantee': (s) => s.guarantee,
'Warranty': (s) => s.warranty,
};
String _label(BuildContext context, String key) {
final s = lang.S.of(context);
return labelMap[key]?.call(s) ?? key;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Drawer(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: SingleChildScrollView(
child: Form(
key: globalKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 4, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
lang.S.of(context).productSetting,
style: theme.textTheme.bodyMedium
?.copyWith(color: kTitleColor, fontWeight: FontWeight.w600, fontSize: 16),
),
IconButton(
icon: Icon(
Icons.close,
color: theme.colorScheme.primary,
),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Divider(color: Color(0xffE6E6E6)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Switches List
..._switchValues.entries.map(_buildSwitchTile),
Divider(),
SizedBox(height: 16),
// Price Fields
// Text('PRICE SETTINGS', style: Theme.of(context).textTheme.bodyMedium),
// SizedBox(height: 14),
// _buildPriceField('Sale Price', _saleController),
// SizedBox(height: 8),
// _buildPriceField('Wholesale Price', _wholesaleController),
// SizedBox(height: 8),
// _buildPriceField('Dealer Price', _dealerController),
// SizedBox(height: 16),
// Save Button
Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () async {
if ((globalKey.currentState?.validate() ?? false)) {
final single = _switchValues['Show Single'] ?? false;
final variant = _switchValues['Show Variant'] ?? false;
final combo = _switchValues['Show Combo'] ?? false;
if (!single && !variant && !combo) {
EasyLoading.showError('Please enable at least one: Single, Variant or Combo');
return;
}
ProductSettingRepo setting = ProductSettingRepo();
bool success;
// Prepare the data for the update
UpdateProductSettingModel data = UpdateProductSettingModel(
productCode: getStringFromBool(_switchValues, 'Product Code'),
productStock: getStringFromBool(_switchValues, 'Product Stock'),
salePrice: getStringFromBool(_switchValues, 'Sale'),
dealerPrice: getStringFromBool(_switchValues, 'Dealer'),
wholesalePrice: getStringFromBool(_switchValues, 'Wholesale Price'),
unit: getStringFromBool(_switchValues, 'Unit'),
brand: getStringFromBool(_switchValues, 'Brand'),
category: getStringFromBool(_switchValues, 'Category'),
manufacturer: getStringFromBool(_switchValues, 'Manufacturer'),
image: getStringFromBool(_switchValues, 'Image'),
showExpireDate: getStringFromBool(_switchValues, 'Show Expire Date'),
alertQty: getStringFromBool(_switchValues, 'Low Stock Alert'),
vatId: getStringFromBool(_switchValues, 'Vat Id'),
vatType: getStringFromBool(_switchValues, 'Vat Type'),
exclusivePrice: getStringFromBool(_switchValues, 'Exclusive Price'),
inclusivePrice: getStringFromBool(_switchValues, 'Inclusive Price'),
profitPercent: getStringFromBool(_switchValues, 'Profit Percent'),
batchNo: getStringFromBool(_switchValues, 'Batch No'),
showManufactureDate: getStringFromBool(_switchValues, 'Show Manufacture Date'),
model: getStringFromBool(_switchValues, 'Model'),
showWarehouse: getStringFromBool(_switchValues, 'Warehouse'),
showRack: getStringFromBool(_switchValues, 'Rack'),
showShelf: getStringFromBool(_switchValues, 'Shelf'),
showSingle: getStringFromBool(_switchValues, 'Show Single'),
showProductTypeCombo: getStringFromBool(_switchValues, 'Show Combo'),
showVariant: getStringFromBool(_switchValues, 'Show Variant'),
showAction: getStringFromBool(_switchValues, 'Show Action'),
defaultExpireDate: getStringFromBool(_switchValues, 'Default ExpireDate'),
defaultManufactureDate:
getStringFromBool(_switchValues, 'Default Manufacture Date'),
expireDateType: getStringFromBool(_switchValues, 'ExpireDate type'),
manufactureDateType: getStringFromBool(_switchValues, 'ManufactureDate type'),
showBatchNo: getStringFromBool(_switchValues, 'Show batch no.'),
showWarranty: getStringFromBool(_switchValues, 'Warranty'),
showGuaranty: getStringFromBool(_switchValues, 'Guarantee'),
// defaultSalePrice: _saleController.text,
// defaultDealerPrice: _dealerController.text,
// defaultWholeSalePrice: _wholesaleController.text,
);
success = await setting.updateProductSetting(data: data);
if (success) {
EasyLoading.showSuccess('Update Successfully');
ref.refresh(fetchSettingProvider);
widget.onSave?.call();
} else {
EasyLoading.showError('Please Try Again!');
}
}
},
style: ElevatedButton.styleFrom(),
child: Text(lang.S.of(context).saveSetting),
),
),
)
],
),
),
)
],
),
),
),
),
),
);
}
Widget _buildSwitchTile(MapEntry<String, bool> entry) {
return ListTile(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
contentPadding: EdgeInsets.symmetric(horizontal: 0, vertical: 0),
title: Text(_label(context, entry.key)),
trailing: Transform.scale(
scale: 0.7,
child: SizedBox(
height: 20,
width: 40,
child: CupertinoSwitch(
applyTheme: true,
value: entry.value,
onChanged: (value) => setState(() => _switchValues[entry.key] = value),
activeTrackColor: Theme.of(context).colorScheme.primary,
inactiveTrackColor: Color(0xff999999),
),
),
),
);
}
Widget _buildPriceField(String label, TextEditingController controller) {
return Padding(
padding: EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 14),
),
keyboardType: TextInputType.numberWithOptions(decimal: true),
),
);
}
@override
void dispose() {
_saleController.dispose();
_wholesaleController.dispose();
_dealerController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../model/get_product_setting_model.dart';
import '../repo/product_setting_repo.dart';
ProductSettingRepo repo = ProductSettingRepo();
final fetchSettingProvider = FutureProvider<GetProductSettingModel>((ref) {
return repo.fetchProductSetting();
});

View File

@@ -0,0 +1,105 @@
import 'dart:convert';
import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:http/http.dart' as http;
import 'package:nb_utils/nb_utils.dart';
import '../../../../Const/api_config.dart';
import '../../../../Repository/constant_functions.dart';
import '../../../../http_client/customer_http_client_get.dart';
import '../model/get_product_setting_model.dart';
import '../model/product_setting_model.dart';
class ProductSettingRepo {
// add/update setting
Future<bool> updateProductSetting({required UpdateProductSettingModel data}) async {
EasyLoading.show(status: 'Updating');
final prefs = await SharedPreferences.getInstance();
final url = Uri.parse('${APIConfig.url}/product-settings');
var request = http.MultipartRequest('POST', url);
request.headers.addAll({
'Accept': 'application/json',
'Authorization': await getAuthToken(),
});
request.fields['show_product_name'] = '1';
request.fields['show_product_code'] = data.productCode.toString();
request.fields['show_product_stock'] = data.productStock.toString();
request.fields['show_product_sale_price'] = data.salePrice.toString();
request.fields['show_product_dealer_price'] = data.dealerPrice.toString();
request.fields['show_product_wholesale_price'] = data.wholesalePrice.toString();
request.fields['show_product_unit'] = data.unit.toString();
request.fields['show_product_brand'] = data.brand.toString();
request.fields['show_product_category'] = data.category.toString();
request.fields['show_product_manufacturer'] = data.manufacturer.toString();
request.fields['show_product_image'] = data.image.toString();
request.fields['show_expire_date'] = data.showExpireDate.toString();
request.fields['show_alert_qty'] = data.alertQty.toString();
request.fields['show_vat_id'] = data.vatId.toString();
request.fields['show_vat_type'] = data.vatType.toString();
request.fields['show_exclusive_price'] = data.exclusivePrice.toString();
request.fields['show_inclusive_price'] = data.inclusivePrice.toString();
request.fields['show_profit_percent'] = data.profitPercent.toString();
request.fields['show_batch_no'] = data.batchNo.toString();
request.fields['show_mfg_date'] = data.showManufactureDate.toString();
request.fields['show_model_no'] = data.model.toString();
request.fields['show_product_type_single'] = data.showSingle.toString();
request.fields['show_product_type_variant'] = data.showVariant.toString();
request.fields['show_action'] = data.showAction.toString();
request.fields['default_expired_date'] = data.defaultExpireDate.toString();
request.fields['default_mfg_date'] = data.defaultManufactureDate.toString();
request.fields['expire_date_type'] = data.expireDateType.toString();
request.fields['mfg_date_type'] = data.manufactureDateType.toString();
request.fields['mfg_date_type'] = data.manufactureDateType.toString();
request.fields['show_product_type_combo'] = data.showProductTypeCombo.toString();
request.fields['show_warehouse'] = data.showWarehouse.toString();
request.fields['show_rack'] = data.showRack.toString();
request.fields['show_shelf'] = data.showShelf.toString();
request.fields['show_guarantee'] = data.showGuaranty.toString();
request.fields['show_warranty'] = data.showWarranty.toString();
try {
var response = await request.send();
var responseData = await http.Response.fromStream(response);
EasyLoading.dismiss();
print(response.statusCode);
if (response.statusCode == 200) {
return true;
} else {
var data = jsonDecode(responseData.body);
EasyLoading.showError(data['message'] ?? 'Failed to update');
return false;
}
} catch (e) {
EasyLoading.dismiss();
EasyLoading.showError('Error: ${e.toString()}');
return false;
}
}
Future<GetProductSettingModel> fetchProductSetting() async {
CustomHttpClientGet clientGet = CustomHttpClientGet(client: http.Client());
final url = Uri.parse('${APIConfig.url}/product-settings');
try {
var response = await clientGet.get(url: url);
EasyLoading.dismiss();
if (response.statusCode == 200) {
var jsonData = jsonDecode(response.body);
return GetProductSettingModel.fromJson(jsonData);
} else {
var data = jsonDecode(response.body);
EasyLoading.showError(data['message'] ?? 'Failed to Setting');
throw Exception(data['message'] ?? 'Failed to fetch Setting');
}
} catch (e) {
EasyLoading.dismiss();
EasyLoading.showError('Error: ${e.toString()}');
throw Exception('Error: ${e.toString()}');
}
}
}