Files
apskel-pos-flutter-v2/lib/presentation/components/dialog/weight_input_dialog.dart
T
efrilmandClaude Opus 5 1505a83dd3 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>
2026-09-06 23:03:31 +07:00

268 lines
7.8 KiB
Dart

part of 'dialog.dart';
/// Papan angka desimal untuk produk yang dijual per timbangan.
///
/// Satu kali dialog ini ditutup dengan angka = satu penimbangan = satu baris
/// keranjang. Untuk mengoreksi salah timbang, buka lagi baris yang sama lewat
/// [initialWeight] alih-alih menambah baris baru.
class WeightInputDialog extends StatefulWidget {
final Product product;
final ProductVariant? variant;
final double? initialWeight;
final ValueChanged<double> onSubmit;
const WeightInputDialog({
super.key,
required this.product,
required this.onSubmit,
this.variant,
this.initialWeight,
});
/// Berat terkecil yang diterima backend; di bawah ini membulat ke nol.
static const double minWeight = 0.001;
/// Backend membulatkan berat ke 3 desimal.
static const int maxDecimals = 3;
@override
State<WeightInputDialog> createState() => _WeightInputDialogState();
}
class _WeightInputDialogState extends State<WeightInputDialog> {
/// Angka disimpan sebagai teks supaya "4," dan "4,20" bisa diketik utuh
/// tanpa dinormalisasi di tengah pengetikan.
late String _input = _formatInitial(widget.initialWeight);
static String _formatInitial(double? weight) {
if (weight == null || weight <= 0) return '';
return weight
.toStringAsFixed(WeightInputDialog.maxDecimals)
.replaceFirst(RegExp(r'\.?0+$'), '')
.replaceAll('.', ',');
}
double get _weight => double.tryParse(_input.replaceAll(',', '.')) ?? 0;
bool get _isValid => _weight >= WeightInputDialog.minWeight;
double get _unitPrice =>
widget.product.price + (widget.variant?.priceModifier ?? 0);
String get _unitLabel => widget.product.unitLabel;
void _tapDigit(String digit) {
final separatorIndex = _input.indexOf(',');
if (separatorIndex != -1 &&
_input.length - separatorIndex - 1 >= WeightInputDialog.maxDecimals) {
return;
}
setState(() {
// Hindari "007": nol di depan hanya berarti sesuatu sebelum koma.
_input = _input == '0' ? digit : '$_input$digit';
});
}
void _tapSeparator() {
if (_input.contains(',')) return;
setState(() => _input = _input.isEmpty ? '0,' : '$_input,');
}
void _tapBackspace() {
if (_input.isEmpty) return;
setState(() => _input = _input.substring(0, _input.length - 1));
}
void _tapClear() => setState(() => _input = '');
void _submit() {
if (!_isValid) return;
final rounded = double.parse(
_weight.toStringAsFixed(WeightInputDialog.maxDecimals),
);
context.maybePop();
widget.onSubmit(rounded);
}
@override
Widget build(BuildContext context) {
return CustomModalDialog(
title: widget.product.name,
subtitle: _unitLabel.isEmpty
? 'Masukkan berat'
: 'Masukkan berat dalam $_unitLabel',
minWidth: 380,
maxWidth: 420,
minHeight: 0,
contentPadding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildDisplay(),
const SpaceHeight(16),
_buildKeypad(),
const SpaceHeight(16),
AppElevatedButton.filled(
borderRadius: 12,
disabled: !_isValid,
onPressed: _submit,
label: widget.initialWeight == null
? 'Tambah ke Pesanan'
: 'Simpan Berat',
),
if (_input.isNotEmpty && !_isValid) ...[
const SpaceHeight(8),
Text(
'Berat minimal 0,001${_unitLabel.isEmpty ? '' : ' $_unitLabel'}',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(color: AppColor.error),
),
],
],
),
);
}
Widget _buildDisplay() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.primary.withOpacity(0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
_input.isEmpty ? '0' : _input,
textAlign: TextAlign.right,
style: AppStyle.xxl.copyWith(
fontSize: 40,
fontWeight: FontWeight.bold,
color: _input.isEmpty
? AppColor.textSecondary
: AppColor.black,
),
),
),
if (_unitLabel.isNotEmpty) ...[
const SpaceWidth(8),
Text(
_unitLabel,
style: AppStyle.lg.copyWith(color: AppColor.textSecondary),
),
],
],
),
const SpaceHeight(4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_unitPrice.currencyFormatRpV2}'
'${_unitLabel.isEmpty ? '' : ' / $_unitLabel'}',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
Text(
(_unitPrice * _weight).currencyFormatRpV2,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.primary,
),
),
],
),
],
),
);
}
Widget _buildKeypad() {
const keys = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
[',', '0', 'hapus'],
];
return Column(
children: [
for (final row in keys)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
for (final key in row)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: _KeypadButton(
label: key,
onTap: () {
switch (key) {
case ',':
_tapSeparator();
case 'hapus':
_tapBackspace();
default:
_tapDigit(key);
}
},
// Tahan lama untuk mengosongkan angka sekaligus.
onLongPress: key == 'hapus' ? _tapClear : null,
),
),
),
],
),
),
],
);
}
}
class _KeypadButton extends StatelessWidget {
final String label;
final VoidCallback onTap;
final VoidCallback? onLongPress;
const _KeypadButton({
required this.label,
required this.onTap,
this.onLongPress,
});
@override
Widget build(BuildContext context) {
final isBackspace = label == 'hapus';
return InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 56,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.border),
),
child: isBackspace
? const Icon(Icons.backspace_outlined, color: AppColor.primary)
: Text(
label,
style: AppStyle.xxl.copyWith(fontWeight: FontWeight.w600),
),
),
);
}
}