feat: dukung penjualan produk per timbangan

Produk kini punya cara jual `unit` (cacah) atau `weight` (timbangan),
mengikuti kontrak backend di docs/integration-weight-based-products.md.

- tipe SellBy, domain/infrastructure unit baru, dan endpoint /api/v1/units
- dialog input berat saat produk timbangan ditambahkan ke keranjang
- tiap penimbangan jadi baris keranjang sendiri; total baris dihitung
  berat x harga satuan, sedangkan produk cacah tetap digabung
- request ke backend mengirim quantity 1 dan membawa berat
- migrasi DB v3: kolom sell_by, unit_id, unit_name, unit_abbreviation
  di tabel products supaya satuan tetap tampil saat offline
- tampilan keranjang, struk, split bill, void, dan refund memakai berat
- test/weight_based_products_test.dart menutup perilaku di atas (9 test)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-09-06 23:03:31 +07:00
co-authored by Claude Opus 5
parent 9777f63426
commit 1505a83dd3
72 changed files with 5388 additions and 584 deletions
@@ -160,7 +160,9 @@ class SplitBillLeftPanel extends StatelessWidget {
),
),
Text(
'Qty: ${item.quantity} x Rp ${item.unitPrice.currencyFormatRpV2} ',
item.isWeighted
? item.quantityPriceLabel
: 'Qty: ${item.quantity} x ${item.unitPrice.currencyFormatRpV2}',
style: AppStyle.md.copyWith(
color: AppColor.textSecondary,
),
@@ -332,97 +332,148 @@ class _SplitBillRightPanelState extends State<SplitBillRightPanel> {
),
),
Text(
'${item.unitPrice.currencyFormatRpV2} per item',
item.isWeighted
? '${item.unitPrice.currencyFormatRpV2} per ${item.unitLabel}'
: '${item.unitPrice.currencyFormatRpV2} per item',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
],
),
),
Row(
children: [
GestureDetector(
onTap: () {
if (selectedQty > 0) {
setState(() {
context.read<SplitBillFormBloc>().add(
SplitBillFormEvent.itemQuantityChanged(
itemId: item.id,
quantity: selectedQty - 1,
),
);
});
}
},
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: selectedQty > 0 ? AppColor.primary : AppColor.border,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.remove,
size: 16,
color: selectedQty > 0
? Colors.white
: AppColor.textSecondary,
),
),
),
Container(
width: 40,
height: 32,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border.all(color: AppColor.border),
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Text(
'$selectedQty',
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
),
GestureDetector(
onTap: () {
if (selectedQty < maxQty) {
context.read<SplitBillFormBloc>().add(
SplitBillFormEvent.itemQuantityChanged(
itemId: item.id,
quantity: selectedQty + 1,
),
);
}
},
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: selectedQty < maxQty
? AppColor.primary
: AppColor.border,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.add,
size: 16,
color: selectedQty < maxQty
? Colors.white
: AppColor.textSecondary,
),
),
),
],
),
// Baris berbobot hanya punya dua keadaan: belum dibayar atau lunas.
// Tidak ada nilai di antaranya, jadi stepper diganti tombol pilih.
if (item.isWeighted)
_buildWeightedLineSelector(item, selectedQty > 0, maxQty)
else
_buildQuantityStepper(item, selectedQty, maxQty),
],
),
);
}
Widget _buildWeightedLineSelector(OrderItem item, bool selected, int maxQty) {
final canSelect = selected || maxQty > 0;
return GestureDetector(
onTap: () {
if (!canSelect) return;
context.read<SplitBillFormBloc>().add(
SplitBillFormEvent.itemQuantityChanged(
itemId: item.id,
quantity: selected ? 0 : 1,
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: selected ? AppColor.primary : Colors.transparent,
border: Border.all(
color: canSelect ? AppColor.primary : AppColor.border,
),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
selected ? Icons.check_circle : Icons.radio_button_unchecked,
size: 16,
color: selected ? Colors.white : AppColor.textSecondary,
),
const SizedBox(width: 6),
Text(
selected ? 'Dibayar' : 'Bayar baris',
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: selected ? Colors.white : AppColor.textSecondary,
),
),
],
),
),
);
}
Widget _buildQuantityStepper(OrderItem item, int selectedQty, int maxQty) {
return Row(
children: [
GestureDetector(
onTap: () {
if (selectedQty > 0) {
setState(() {
context.read<SplitBillFormBloc>().add(
SplitBillFormEvent.itemQuantityChanged(
itemId: item.id,
quantity: selectedQty - 1,
),
);
});
}
},
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: selectedQty > 0 ? AppColor.primary : AppColor.border,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.remove,
size: 16,
color: selectedQty > 0 ? Colors.white : AppColor.textSecondary,
),
),
),
Container(
width: 40,
height: 32,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border.all(color: AppColor.border),
borderRadius: BorderRadius.circular(4),
),
child: Center(
child: Text(
'$selectedQty',
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
),
GestureDetector(
onTap: () {
if (selectedQty < maxQty) {
context.read<SplitBillFormBloc>().add(
SplitBillFormEvent.itemQuantityChanged(
itemId: item.id,
quantity: selectedQty + 1,
),
);
}
},
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: selectedQty < maxQty ? AppColor.primary : AppColor.border,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.add,
size: 16,
color: selectedQty < maxQty
? Colors.white
: AppColor.textSecondary,
),
),
),
],
);
}
Widget _buildRowButtonSplit() {
switch (widget.state.order.splitType) {
case 'AMOUNT':
@@ -510,7 +561,10 @@ class _SplitBillRightPanelState extends State<SplitBillRightPanel> {
splitItems.add(
originalItem.copyWith(
quantity: quantity,
totalPrice: (originalItem.unitPrice) * quantity,
// Baris berbobot dibayar penuh; nilainya sudah weight x harga.
totalPrice: originalItem.isWeighted
? originalItem.totalPrice
: (originalItem.unitPrice) * quantity,
),
);
}