142 lines
3.6 KiB
Dart
142 lines
3.6 KiB
Dart
import 'package:equatable/equatable.dart';
|
|
|
|
import '../../core/constants/app_constants.dart';
|
|
|
|
/// Merchandise categories shown as filter chips on the POS dashboard.
|
|
enum ProductCategory {
|
|
dairy('Dairy', '🥛'),
|
|
grocery('Grocery', '🛒'),
|
|
fruits('Fruits', '🍎'),
|
|
vegetables('Vegetables', '🥦'),
|
|
beverages('Beverages', '🥤'),
|
|
snacks('Snacks', '🍪'),
|
|
personalCare('Personal Care', '🧴'),
|
|
household('Household', '🏠');
|
|
|
|
const ProductCategory(this.label, this.emoji);
|
|
|
|
final String label;
|
|
final String emoji;
|
|
}
|
|
|
|
/// Unit of measure — drives whether fractional quantities are permitted.
|
|
enum UnitOfMeasure {
|
|
piece('pc'),
|
|
kilogram('kg'),
|
|
gram('g'),
|
|
litre('L'),
|
|
millilitre('ml'),
|
|
pack('pack');
|
|
|
|
const UnitOfMeasure(this.symbol);
|
|
|
|
final String symbol;
|
|
|
|
bool get allowsFractional =>
|
|
this == UnitOfMeasure.kilogram || this == UnitOfMeasure.litre;
|
|
}
|
|
|
|
/// A sellable item in the catalogue.
|
|
class Product extends Equatable {
|
|
const Product({
|
|
required this.id,
|
|
required this.name,
|
|
required this.barcode,
|
|
required this.sku,
|
|
required this.category,
|
|
required this.price,
|
|
required this.stock,
|
|
this.mrp,
|
|
this.emoji = '📦',
|
|
this.imageUrl,
|
|
this.unit = UnitOfMeasure.piece,
|
|
this.gstRate = AppConstants.defaultGstRate,
|
|
this.hsnCode,
|
|
this.brand,
|
|
this.isActive = true,
|
|
});
|
|
|
|
final String id;
|
|
final String name;
|
|
final String barcode;
|
|
final String sku;
|
|
final ProductCategory category;
|
|
|
|
/// Selling price per [unit], inclusive of GST (Indian retail convention).
|
|
final double price;
|
|
|
|
/// Printed maximum retail price, used to display savings.
|
|
final double? mrp;
|
|
|
|
final double stock;
|
|
final String emoji;
|
|
final String? imageUrl;
|
|
final UnitOfMeasure unit;
|
|
final double gstRate;
|
|
|
|
/// HSN/SAC code. Printed on the tax invoice — legally required on a GST
|
|
/// invoice above the turnover threshold. Blank when the catalogue omits it.
|
|
final String? hsnCode;
|
|
|
|
final String? brand;
|
|
final bool isActive;
|
|
|
|
bool get isOutOfStock => stock <= 0;
|
|
bool get isLowStock =>
|
|
stock > 0 && stock <= AppConstants.lowStockThreshold;
|
|
|
|
bool get hasDiscount => mrp != null && mrp! > price;
|
|
|
|
double get savings => hasDiscount ? (mrp! - price) : 0;
|
|
|
|
double get discountPercent =>
|
|
hasDiscount ? ((mrp! - price) / mrp!) * 100 : 0;
|
|
|
|
/// Price stripped of embedded GST — the taxable value.
|
|
double get netPrice => price / (1 + gstRate);
|
|
|
|
/// GST rupees embedded inside [price].
|
|
double get taxPerUnit => price - netPrice;
|
|
|
|
/// Fuzzy match used by the search bar across name, barcode, SKU and brand.
|
|
bool matches(String query) {
|
|
final q = query.trim().toLowerCase();
|
|
if (q.isEmpty) return true;
|
|
return name.toLowerCase().contains(q) ||
|
|
barcode.toLowerCase().contains(q) ||
|
|
sku.toLowerCase().contains(q) ||
|
|
(brand?.toLowerCase().contains(q) ?? false) ||
|
|
category.label.toLowerCase().contains(q);
|
|
}
|
|
|
|
Product copyWith({
|
|
String? name,
|
|
double? price,
|
|
double? mrp,
|
|
double? stock,
|
|
ProductCategory? category,
|
|
bool? isActive,
|
|
}) {
|
|
return Product(
|
|
id: id,
|
|
name: name ?? this.name,
|
|
barcode: barcode,
|
|
sku: sku,
|
|
category: category ?? this.category,
|
|
price: price ?? this.price,
|
|
mrp: mrp ?? this.mrp,
|
|
stock: stock ?? this.stock,
|
|
emoji: emoji,
|
|
imageUrl: imageUrl,
|
|
unit: unit,
|
|
gstRate: gstRate,
|
|
hsnCode: hsnCode,
|
|
brand: brand,
|
|
isActive: isActive ?? this.isActive,
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [id, name, barcode, sku, price, stock, isActive];
|
|
}
|