Compare commits

..
24 Commits
Author SHA1 Message Date
efrilmandClaude Opus 5 924d698640 chore: naikkan versi ke 1.0.13+20
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 23:03:32 +07:00
efrilmandClaude Opus 5 4342af689f docs: rapikan format tabel README
Hanya perataan kolom tabel, tidak ada perubahan isi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 23:03:31 +07:00
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
efrilm 9777f63426 chore: naikkan build number ke 1.0.12+19 2026-09-05 14:06:35 +07:00
efrilmandClaude Opus 5 7986164a17 fix: beri tiap tab pengaturan printer bloc sendiri
PrinterFormBloc dan PrinterLoaderBloc di-provide sekali secara global, sementara lima tab pengaturan printer hidup bersamaan di dalam TabBarView dan semuanya membaca-menulis state yang sama.

Karena created dan updated membangun entitas Printer sepenuhnya dari state itu (type, address, code, name, paper), tab yang terakhir menulis state akan menang, lalu tombol Simpan di tab mana pun menyimpan milik tab lain. Nilai awal state kebetulan type 'Bluetooth' — itu sebabnya printer kitchen tersimpan dengan alamat IP yang benar tapi tipe Bluetooth, dan cetakannya dikirim ke radio Bluetooth untuk mencari perangkat yang tidak pernah ada.

Provider global dihapus karena tidak ada tempat lain yang memakainya. Catatan: ini mencegah kerusakan baru, tidak memulihkan baris yang sudah terlanjur salah di SQLite — konfigurasi kelima tab perlu disimpan ulang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:06:34 +07:00
efrilmandClaude Opus 5 32f64a197a fix: pulihkan koneksi Bluetooth dan deteksi kegagalan printer jaringan
Bluetooth: percobaan ulang connect sebelumnya tidak menutup socket lebih dulu, padahal 'read failed, socket might closed or timeout' justru berarti socket RFCOMM masih setengah terbuka — ketiga percobaan gagal identik dan retry-nya sia-sia. Tiap percobaan ulang kini didahului disconnect paksa.

connectBluetooth mengembalikan sukses begitu ADA koneksi tanpa memeriksa apakah itu printer yang diminta, dan tidak pernah mencatat MAC yang aktif. Kini memakai jalur yang sama dengan mencetak.

Izin runtime Bluetooth sebelumnya hanya diminta dari dialog pilih perangkat. Karena reinstall mencabut izin, kasir yang tidak pernah membuka dialog itu gagal cetak tanpa penjelasan. Status adapter, izin, dan pairing kini ikut di log, dan printer yang tidak ter-pairing digagalkan langsung dengan pesan yang menyebut tindakannya.

Jaringan: printTicket hanya menaruh byte di socket lalu menutup koneksi, tidak pernah membaca balik, sehingga kertas habis atau cover terbuka terlihat sama dengan sukses — itu sebabnya kegagalan kitchen tidak pernah muncul di Crashlytics. NetworkPrinterClient baru menanyakan kondisi printer lewat DLE EOT sebelum dan sesudah mencetak, dengan degradasi anggun untuk printer yang tidak mendukungnya.

Panggilan disconnect() ganda dihapus; printTicket sudah flush dan close sendiri, dan panggilan kedua tanpa await membuat error-nya tercatat sebagai crash fatal palsu. Ditambah jejak per station ke Crashlytics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:06:22 +07:00
efrilmandClaude Opus 5 cb7139fc3e docs: tambahkan langkah PATH untuk fvm di bagian Instalasi
dart pub global activate memasang fvm ke folder bin pub yang belum tentu ada di PATH, dan setiap orang yang setup dari nol akan menabraknya tepat setelah langkah activate. Sebelumnya hanya disebut di Troubleshooting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 20:46:47 +07:00
efrilm 951e154623 chore: base URL dev, exclude platform dir dari analyzer, dan naikkan versi ke 1.0.12+18
Perubahan ini sudah ada di working tree sebelum pekerjaan print dimulai; dikumpulkan di sini supaya tidak tercampur dengan commit perbaikan printer. Ditambah pembersihan import injectable yang tidak terpakai di main.dart.
2026-09-02 20:45:13 +07:00
efrilmandClaude Opus 5 ebcddca4bb docs: perbarui README untuk FVM, build_runner, dan cara run/build
Bagian setup lama tidak cuma kurang, tapi salah: diklaim environment dipilih otomatis dari debug/release, padahal datang dari --dart-define=ENV. Siapa pun yang mengikutinya akan menghasilkan APK 'production' yang menunjuk database dev. Tabel environment dan contoh kodenya juga sudah tidak sesuai isi lib/env.dart.

Ditambah: prasyarat FVM, langkah instalasi ber-fvm, perintah build per environment, bagian Code Generation (termasuk peringatan --delete-conflicting-outputs yang menghapus seluruh file generated sebelum build), dan Troubleshooting.

URL base sengaja tidak disalin ke README; cukup menunjuk lib/env.dart, karena menyalin nilai itulah yang membuat dokumen ini basi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 20:44:24 +07:00
efrilmandClaude Opus 5 1b4caf45d5 fix: cetak semua station tanpa saling membatalkan
printStruckOrder berhenti di kegagalan pertama, jadi satu station gagal membuat semua station sesudahnya tidak pernah dicoba. Karena checker (Bluetooth) berada sebelum kitchen dan bar (Network) dalam rantai, satu kegagalan koneksi Bluetooth menghilangkan tiga tiket sekaligus, dua di antaranya di jaringan yang sehat.

Jalur printer Network juga mengembalikan right(false) saat gagal, yang lolos sebagai sukses ke pemanggil, sehingga kasir melihat 'berhasil dicetak' padahal tidak ada kertas keluar.

Perubahan: repository kini mengembalikan Stream<List<PrintStationResult>> dan tetap mencoba semua station; kegagalan Network jadi left; station Network berjalan paralel dengan rantai Bluetooth; station Bluetooth dikelompokkan per alamat agar perpindahan koneksi seminimal mungkin; CapabilityProfile di-cache.

Bluetooth: retry connect dengan backoff dan disconnect di tiap percobaan ulang (socket RFCOMM setengah terbuka membuat connect berulang gagal identik), jeda settle dikembalikan setelah dipangkas ke 300ms, satu lock untuk seluruh radio (bukan per MAC), izin runtime dipastikan di jalur cetak, dan repository jadi LazySingleton agar state koneksi benar-benar dibagi.

UI: dialog progres per-station menggantikan toast tunggal, lengkap dengan durasi tiap station dan tombol cetak ulang khusus yang gagal. Enam listener duplikat di halaman diganti satu listener terpusat di AppWidget. Menu Print di detail transaksi dapat item Print All.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 20:44:14 +07:00
efrilmandClaude Opus 5 038f4d65fe chore: pin Flutter 3.41.9 lewat FVM
Stack codegen project ini (freezed 2.x, build_runner 2.5, analyzer 7.7.1) hanya paham bahasa Dart sampai 3.9. Flutter 3.44+ membawa framework yang ditulis di bahasa 3.10+, dan analyzer 7.7.1 crash saat men-serialize-nya (visitDotShorthandPropertyAccess) sehingga build_runner tidak bisa jalan sama sekali.

3.41.9 adalah rilis terbaru yang framework-nya masih di bahasa 3.9. Versi dipin per-project lewat .fvmrc supaya tidak mengganggu project lain di mesin yang sama.

android/build/ ikut di-ignore; isinya artefak Gradle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 20:43:49 +07:00
efrilmandClaude Opus 5 865897fc04 config: upgrade gradle 8.14.3, agp 8.11.1, kotlin 2.2.20
Flutter 3.47 menolak build dengan Gradle < 8.14.0, AGP < 8.11.1, dan
Kotlin < 2.2.20.

Hapus juga assets/icons/ dan assets/json/ dari pubspec: foldernya tidak
pernah ada dan tidak dirujuk dari lib/, menyebabkan error "unable to find
directory entry" saat build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 19:10:11 +07:00
Efril f5b0e66706 config: update version 2026-07-22 14:57:06 +07:00
Efril 1ff144a1a2 feat: sort order printer 2026-07-22 14:56:38 +07:00
Efril eb031d4c2a config: prod and dev 2026-07-09 23:08:50 +07:00
Efril 7161f523c2 feat: update api url 2026-07-03 16:04:08 +07:00
Efril d097df1592 update 2026-06-04 07:22:58 +07:00
Efril e8bbef03b0 config: update version 2026-06-04 00:50:34 +07:00
Efril b228538725 feat: implement idempotency key for critical API endpoints 2026-06-04 00:48:50 +07:00
Efril 2ab20a1150 add printer checker checks 2026-05-28 18:11:02 +07:00
Efril b9fcd79962 update version 2026-05-27 19:52:47 +07:00
Efril 9a5f0c7415 fix printer 2026-05-27 19:51:43 +07:00
Efril d345294a2f update printer bar and kitchen 2026-05-27 16:19:11 +07:00
Efril beb86f6259 update printer checker 2026-05-27 16:13:12 +07:00
120 changed files with 8677 additions and 2205 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.41.9"
}
+6
View File
@@ -32,6 +32,7 @@ migrate_working_dir/
.pub-cache/
.pub/
/build/
android/build/
# Symbolication related
app.*.symbols
@@ -43,3 +44,8 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# FVM - versi Flutter dipin di .fvmrc (ikut di-commit), SDK-nya tidak
# FVM Version Cache
.fvm/
+17
View File
@@ -0,0 +1,17 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Dev",
"request": "launch",
"type": "dart",
"args": ["--dart-define=ENV=dev"]
},
{
"name": "Prod",
"request": "launch",
"type": "dart",
"args": ["--dart-define=ENV=prod"]
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.41.9"
}
+158 -42
View File
@@ -23,21 +23,22 @@ Aplikasi Point of Sale (POS) berbasis Flutter untuk manajemen kasir restoran. Me
## Tech Stack
| Kategori | Library |
|---|---|
| State Management | `flutter_bloc` + `bloc` |
| Dependency Injection | `get_it` + `injectable` |
| Navigation | `auto_route` |
| HTTP Client | `dio` |
| Local Database | `sqflite` |
| Local Storage | `shared_preferences` |
| Firebase | `firebase_core`, `firebase_crashlytics`, `firebase_messaging` |
| Push Notification | `flutter_local_notifications` |
| Device Info | `device_info_plus`, `package_info_plus` |
| Code Generation | `freezed`, `json_serializable` |
| Printer | `print_bluetooth_thermal`, `flutter_esc_pos_network` |
| Chart | `fl_chart` |
| Connectivity | `connectivity_plus` |
| Kategori | Library |
| -------------------- | ------------------------------------------------------------- |
| Version Manager | `fvm` — Flutter dipin di `.fvmrc` |
| State Management | `flutter_bloc` + `bloc` |
| Dependency Injection | `get_it` + `injectable` |
| Navigation | `auto_route` |
| HTTP Client | `dio` |
| Local Database | `sqflite` |
| Local Storage | `shared_preferences` |
| Firebase | `firebase_core`, `firebase_crashlytics`, `firebase_messaging` |
| Push Notification | `flutter_local_notifications` |
| Device Info | `device_info_plus`, `package_info_plus` |
| Code Generation | `freezed`, `json_serializable` |
| Printer | `print_bluetooth_thermal`, `flutter_esc_pos_network` |
| Chart | `fl_chart` |
| Connectivity | `connectivity_plus` |
---
@@ -78,11 +79,13 @@ feature/
### Prasyarat
- Flutter SDK `^3.8.1`
- Dart SDK `^3.8.1`
- **FVM** — project ini mengunci versi Flutter, jangan pakai Flutter global
- **Flutter 3.41.9** (Dart 3.11.5) — dipin di `.fvmrc`
- Android SDK / Xcode (untuk iOS)
- Firebase project yang sudah dikonfigurasi
> **Kenapa versinya dikunci?** Stack code generation project ini (`freezed` 2.x, `build_runner` 2.5, `analyzer` 7.7.1) hanya paham bahasa Dart sampai 3.9. Flutter 3.44 ke atas membawa framework yang ditulis di bahasa 3.10+, dan `analyzer` 7.7.1 crash saat membacanya. Selama stack codegen belum dinaikkan, **Flutter 3.41.9 adalah versi terbaru yang masih bisa menjalankan `build_runner`.** Lihat [Troubleshooting](#troubleshooting).
### Instalasi
```bash
@@ -90,44 +93,120 @@ feature/
git clone <repo-url>
cd apskel-pos-flutter-v2
# Install dependencies
flutter pub get
# Pasang FVM (sekali per mesin)
dart pub global activate fvm
# Generate kode (freezed, injectable, auto_route, json_serializable)
dart run build_runner build --delete-conflicting-outputs
# Ambil & pakai versi Flutter yang dipin di .fvmrc
fvm install
fvm use
```
`dart pub global activate` memasang `fvm` ke folder bin milik pub yang **belum tentu ada di PATH**. Kalau muncul `fvm: command not found` / `The term 'fvm' is not recognized`, tambahkan foldernya sekali saja:
```powershell
# Windows PowerShell — permanen, buka terminal baru setelahnya
[Environment]::SetEnvironmentVariable('Path', [Environment]::GetEnvironmentVariable('Path','User') + ";$env:LOCALAPPDATA\Pub\Cache\bin", 'User')
# Untuk terminal yang sedang terbuka
$env:Path += ";$env:LOCALAPPDATA\Pub\Cache\bin"
```
```bash
# macOS / Linux — tambahkan ke ~/.zshrc atau ~/.bashrc
export PATH="$PATH:$HOME/.pub-cache/bin"
```
Setelah `fvm` bisa dipanggil, lanjutkan:
```bash
# Install dependencies
fvm flutter pub get
# Generate kode
fvm dart run build_runner build
```
> Semua perintah Flutter/Dart di project ini **harus diawali `fvm`**. `flutter run` tanpa `fvm` memakai Flutter global dan hasilnya bisa berbeda.
### Menjalankan App
Environment **tidak** ditentukan otomatis dari debug/release — harus dioper lewat `--dart-define`:
```bash
# Development
flutter run
fvm flutter run --dart-define=ENV=dev
# Release
flutter run --release
# Production
fvm flutter run --dart-define=ENV=prod
# Production, release mode
fvm flutter run --release --dart-define=ENV=prod
```
App otomatis menggunakan environment `dev` saat debug dan `prod` saat release.
Kalau `ENV` tidak dioper, nilainya jatuh ke `dev` (lihat `defaultValue` di `lib/main.dart`).
Di VSCode sudah tersedia konfigurasi **Dev** dan **Prod** di `.vscode/launch.json`, tinggal pilih di panel Run and Debug.
### Build
`--dart-define=ENV=prod` wajib ikut, kalau tidak APK-nya akan memakai database dan base URL milik dev:
```bash
# APK
fvm flutter build apk --release --dart-define=ENV=prod
fvm flutter build apk --release --split-per-abi --dart-define=ENV=prod
# App Bundle (Play Store)
fvm flutter build appbundle --release --dart-define=ENV=prod
# iOS
fvm flutter build ipa --release --dart-define=ENV=prod
```
---
## Code Generation
Project ini banyak memakai kode generated (`freezed`, `injectable`, `auto_route`, `json_serializable`, `flutter_gen`). Kode itu **wajib di-generate ulang** setiap kali kamu:
- menambah/mengubah kelas `@freezed` (entity, event, state, DTO)
- menambah/mengubah anotasi DI (`@injectable`, `@LazySingleton`, `@Injectable(as: ...)`)
- menambah halaman baru dengan `@RoutePage()`
- menambah aset di `pubspec.yaml`
```bash
# Sekali jalan
fvm dart run build_runner build
# Mode watch — regenerate otomatis saat file berubah, enak dipakai saat ngoding
fvm dart run build_runner watch
```
> **Hindari `--delete-conflicting-outputs`.** Flag itu menghapus seluruh file generated **sebelum** mulai build. Kalau build-nya gagal di tengah jalan, kamu berakhir tanpa kode generated sama sekali dan project tidak bisa di-compile. Pakai hanya kalau build_runner benar-benar mengeluh soal konflik output, dan pastikan working tree-mu bersih dulu supaya bisa `git restore`.
---
## Environment
Konfigurasi environment ada di `lib/env.dart`:
Konfigurasi environment ada di `lib/env.dart` — **itu sumber kebenarannya**, jangan disalin ke sini karena gampang basi.
| Environment | Base URL | Database |
|---|---|---|
| `dev` | `https://api-pos.apskel.id` | `apskel_pos_dev.db` |
| `prod` | `https://api-pos.apskel.id` | `apskel_pos_dev.db` |
| Environment | Database |
| ----------- | ----------------------- |
| `dev` | `apskel_pos_staging.db` |
| `prod` | `apskel_pos_prod.db` |
Environment dipilih otomatis di `main.dart`:
Environment dipilih dari `--dart-define=ENV=...` di `lib/main.dart`:
```dart
await configureDependencies(
kReleaseMode ? Environment.prod : Environment.dev,
);
const String env = String.fromEnvironment('ENV', defaultValue: 'dev');
await configureDependencies(env);
```
Nilai `env` diteruskan ke `injectable`, yang lalu memilih antara `DevEnv` (`@dev`) dan `ProdEnv` (`@prod`). Menambah environment baru berarti menambah kelas ber-anotasi di `lib/env.dart`, lalu generate ulang.
Karena `dev` dan `prod` memakai **nama database berbeda**, berpindah environment di perangkat yang sama tidak akan mencampur datanya.
---
## Dependency Injection
@@ -136,14 +215,14 @@ DI menggunakan `get_it` + `injectable`. Semua service, repository, dan BLoC dida
### Modul DI
| File | Isi |
|---|---|
| `di_firebase.dart` | `FirebaseMessaging`, `FlutterLocalNotificationsPlugin`, `DeviceInfoPlugin`, `PackageInfo` |
| `di_dio.dart` | `Dio` HTTP client |
| `di_shared_preferences.dart` | `SharedPreferences` |
| `di_database.dart` | `DatabaseHelper` (SQLite) |
| `di_auto_route.dart` | `AppRouter` |
| `di_connectivity.dart` | `Connectivity` |
| File | Isi |
| ---------------------------- | ----------------------------------------------------------------------------------------- |
| `di_firebase.dart` | `FirebaseMessaging`, `FlutterLocalNotificationsPlugin`, `DeviceInfoPlugin`, `PackageInfo` |
| `di_dio.dart` | `Dio` HTTP client |
| `di_shared_preferences.dart` | `SharedPreferences` |
| `di_database.dart` | `DatabaseHelper` (SQLite) |
| `di_auto_route.dart` | `AppRouter` |
| `di_connectivity.dart` | `Connectivity` |
Setelah menambah atau mengubah class yang menggunakan annotation injectable, jalankan:
@@ -210,6 +289,43 @@ App dikunci ke mode **landscape** (kiri & kanan) karena didesain untuk tablet PO
## Catatan Development
- Selalu pakai `fvm flutter` / `fvm dart`, bukan `flutter` / `dart` global
- Jangan edit `injection.config.dart` dan file `*.freezed.dart` / `*.g.dart` secara manual — file tersebut di-generate otomatis
- Setelah mengubah kelas `@freezed` atau anotasi DI, jalankan build_runner sebelum menjalankan app
- Gunakan `log()` dari `dart:developer` untuk logging, bukan `print()`
- `print()` dinonaktifkan di release mode
---
## Troubleshooting
### `build_runner` gagal: `Missing implementation of visitDotShorthandPropertyAccess`
Kamu sedang memakai Flutter yang terlalu baru. Cek:
```bash
fvm flutter --version # harus 3.41.9
flutter --version # ini Flutter global, boleh versi apa saja
```
Kalau versinya bukan 3.41.9, artinya perintahnya jalan tanpa `fvm`. Log build juga memberi petunjuk yang sama:
```
SDK language version 3.13.0 is newer than `analyzer` language version 3.9.0
```
Perbaikannya: `fvm use`, lalu ulangi dengan awalan `fvm`.
Menaikkan Flutter melewati 3.41.x mengharuskan seluruh stack codegen ikut naik — `freezed` 2 → 3/4, `build_runner` → 2.16, `injectable_generator` 2 → 3, `auto_route` 9 → 10. `freezed` 3 mewajibkan tiap kelas dideklarasikan `abstract`/`sealed` dan menghapus `when`/`map` (diganti pattern matching), jadi ini pekerjaan tersendiri, bukan sambilan.
### Project tidak bisa di-compile, banyak error `_$Something` tidak ditemukan
Kode generated-nya hilang atau belum dibuat. Jalankan:
```bash
fvm dart run build_runner build
```
### `fvm: command not found`
FVM terpasang di `~/AppData/Local/Pub/Cache/bin` (Windows) atau `~/.pub-cache/bin` (macOS/Linux) yang mungkin belum ada di `PATH`. Tambahkan direktori itu ke `PATH`, atau panggil dengan path lengkap.
+7
View File
@@ -21,3 +21,10 @@ analyzer:
- test/generated/**
- "**/**.g.dart"
- "**/**.freezed.dart"
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
+4
View File
@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
+2 -2
View File
@@ -18,8 +18,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
id("com.google.gms.google-services") version "4.4.4" apply false
}
+279
View File
@@ -0,0 +1,279 @@
# Integrasi Produk Timbangan — POS Mobile & Backoffice
**Migrasi:** `000089` · **Base URL:** `/api/v1` · **Kompatibilitas:** mundur penuh
Panduan untuk menjual produk per timbangan (ikan, daging, buah) dari sisi klien.
Alasan di balik setiap keputusan desain ada di [`rfc-weight-based-products.md`](./rfc-weight-based-products.md).
---
## 1. Konsep inti
**Satu penimbangan = satu baris order.**
Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Itu **dua
baris terpisah**, karena keduanya dua ikan berbeda yang ditimbang sendiri-sendiri.
| Bukan begini | Melainkan begini |
|---|---|
| `quantity: 9.8` | `quantity: 1, weight: 4.2`<br>`quantity: 1, weight: 5.6` |
| Dua ikan hilang jejaknya, dan `quantity` bertipe integer sehingga pecahan ditolak | Tiap penimbangan berdiri sendiri, bisa di-void atau dibayar terpisah |
Empat aturan yang berlaku di seluruh dokumen ini:
1. `quantity` untuk produk timbangan **selalu 1**. Backend memaksanya, dan database
menolak nilai lain lewat constraint `chk_order_items_weight_single_line`.
2. `weight` menyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram —
apa pun yang dipilih saat setup).
3. Harga baris dihitung `weight × unit_price`, bukan `quantity × unit_price`.
`unit_price` tetap berarti harga per satu satuan (per ons).
4. **Jangan pernah menggabungkan dua baris** produk timbangan menjadi satu, meski
produknya sama.
Untuk produk biasa tidak ada yang berubah: `weight` tidak dikirim, `quantity` tetap
cacah seperti sekarang.
---
## 2. Backoffice — setup produk
### 2.1 Pastikan satuannya ada
Satuan disimpan per organisasi. Buat sekali, pakai ulang untuk semua produk timbangan.
`POST /api/v1/units`
```json
{
"name": "Ons",
"abbreviation": "ons",
"is_active": true
}
```
`abbreviation` yang dipakai POS untuk mencetak `4,2 ons` di struk — isi dengan bentuk
pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/v1/units`.
### 2.2 Buat produk sebagai produk timbangan
`POST /api/v1/products`
```json
{
"category_id": "…",
"name": "Ikan Tude",
"price": 4500,
"cost": 3000,
"sell_by": "weight",
"unit_id": "<id satuan Ons>"
}
```
| Field | Tipe | Keterangan |
|---|---|---|
| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain **ditolak** dengan pesan jelas. |
| `unit_id` | UUID | **Wajib** saat `sell_by: "weight"`, ditolak backend bila kosong. Opsional untuk produk satuan. |
| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. |
Pada `PUT /api/v1/products/:id`, `unit_id` **tidak perlu dikirim ulang** bila produknya
sudah punya satuan — mengubah `sell_by` menjadi `"weight"` saja sudah cukup. Yang ditolak
adalah kondisi akhirnya: produk yang dijual per timbangan tanpa satuan.
Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan
ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`,
`/products/:id`).
### 2.3 Catatan UI
- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit`
ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per
cacah — tapi akan membingungkan pengguna yang melihat riwayatnya.
- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. Backend
juga menolaknya, tapi ditangkap di form lebih baik daripada baru gagal saat simpan.
- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*.
- Untuk produk satuan, pemilih satuan boleh disembunyikan — `unit_id` opsional dan belum
dikonsumsi apa pun di POS.
---
## 3. POS Mobile — transaksi
### 3.1 Bentuk input mengikuti `sell_by`
| `sell_by` | Input di POS | Yang dikirim |
|---|---|---|
| `"unit"` | Stepper − / + seperti sekarang | `quantity: n`, tanpa `weight` |
| `"weight"` | Papan angka desimal, satuan dari `unit` produk | `quantity: 1` + `weight: 4.2` |
### 3.2 Mengirim order
`POST /api/v1/orders`
```json
{
"outlet_id": "…",
"user_id": "…",
"order_type": "dine_in",
"order_items": [
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 4.2 },
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 5.6 },
{ "product_id": "<nasi-goreng>", "quantity": 2 }
]
}
```
Bentuk yang sama berlaku untuk `POST /api/v1/orders/:id/add-items` dan untuk pemesanan
mandiri `POST /api/v1/self-order/orders`.
**Presisi.** Berat dibulatkan backend ke 3 desimal. Kirim `4.2` atau `4.237`; angka di
bawah `0.001` membulat ke nol dan ditolak.
**Harga.** Field `unit_price` pada request **diabaikan** — harga selalu diambil backend
dari master produk. Jangan mengirim harga hasil hitungan sendiri.
---
## 4. Menampilkan baris
Setiap `order_items[]` di response membawa empat field tambahan:
```json
{
"product_name": "Ikan Tude",
"quantity": 1,
"weight": 4.2,
"unit_id": "…",
"unit_name": "Ons",
"unit_abbreviation": "ons",
"unit_price": 4500,
"total_price": 18900
}
```
Semuanya `null` atau absen untuk produk biasa, jadi cabangkan tampilan pada `weight`:
| Kondisi | Tampilkan |
|---|---|
| `weight == null` | `2 × Rp 25.000` |
| `weight != null` | `4,2 ons × Rp 4.500` |
**Jangan menampilkan `quantity` untuk baris berbobot** — nilainya selalu 1 dan akan
terbaca seperti "satu ons". Gunakan `weight` dengan `unit_abbreviation`, dan pakai koma
desimal sesuai format Indonesia.
---
## 5. Void, refund, split bill
Baris berbobot bersifat **utuh**: dibatalkan seluruhnya atau tidak sama sekali. Karena
`quantity`-nya 1, semua endpoint cukup dikirimi `1`, dan backend menghitung nilai
rupiahnya dari `weight`.
| Aksi | Endpoint | Field untuk baris berbobot |
|---|---|---|
| Void per item | `POST /orders/void` | `items[].quantity: 1` |
| Refund per item | `POST /orders/:id/refund` | `order_items[].refund_quantity: 1` (atau kosongkan) |
| Split bill per item | `POST /orders/split-bill` | `items[].quantity: 1` = bayar baris itu penuh |
```json
{
"order_id": "…",
"reason": "Salah timbang",
"type": "ITEM",
"items": [
{ "order_item_id": "<baris 4,2 ons>", "quantity": 1 }
]
}
```
Untuk split bill, baris berbobot hanya bisa berstatus belum dibayar atau lunas — tidak
ada nilai di antaranya. Sembunyikan stepper jumlah pada baris berbobot, ganti dengan
tombol pilih baris.
**Batasan yang disengaja.** Mengembalikan *sebagian berat* — 1 ons dari baris 4,2 ons —
tidak didukung. Koreksi salah timbang ditangani dengan void baris itu lalu input ulang,
sehingga jejak auditnya tetap jujur.
---
## 6. Referensi error
Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `900`:
```json
{
"success": false,
"data": null,
"errors": [
{ "code": "900", "entity": "ORDER",
"cause": "product Ikan Tude is sold by weight and requires a weight" }
]
}
```
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|---|---|---|
| `… is sold by weight and requires a weight` | Produk `sell_by: "weight"` dikirim tanpa `weight` | Wajibkan input timbangan sebelum item masuk keranjang |
| `… is not sold by weight and must not carry a weight` | `weight` dikirim untuk produk satuan | Kirim `weight` hanya bila `sell_by == "weight"` |
| `weight for … must be greater than 0` | Berat nol, negatif, atau membulat ke nol | Validasi minimal `0.001` di keypad |
| `quantity for … must be at least 1` | Produk satuan dengan `quantity` ≤ 0 | Perilaku lama, tidak berubah |
Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir.
### Setup produk (Backoffice)
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|---|---|---|
| `unit_id is required when sell_by is 'weight'` | Produk timbangan dibuat tanpa satuan | Wajibkan pemilih satuan saat Timbangan dipilih |
| `sell_by must be either 'unit' or 'weight'` | Nilai `sell_by` di luar dua itu | Kirim persis `"unit"` atau `"weight"` |
| `product '…' is sold by weight and requires a unit_id` | Update membuat produk jadi timbangan tanpa satuan | Kirim `unit_id` bersama perubahan `sell_by` |
---
## 7. Kompatibilitas mundur
- Semua produk lama otomatis `sell_by: "unit"`. Perilakunya identik dengan sebelumnya.
- `weight` opsional di request. Klien yang tidak mengenalnya tetap berfungsi penuh untuk
produk satuan.
- Field baru di response semuanya `omitempty` — tidak muncul untuk baris biasa, jadi
parser lama tidak terganggu.
- `quantity` tetap **integer** di seluruh API. Tidak ada field yang berubah tipe.
Yang tidak berfungsi di klien lama hanyalah menjual produk timbangan itu sendiri —
permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam.
---
## 8. Batasan yang diketahui
- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`,
bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu
diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat).
- **Stok belum otomatis berkurang** saat penjualan — untuk produk timbangan maupun
produk biasa. Pengurangan stok belum tersambung di backend, jadi jangan menampilkan
sisa stok yang mengandaikan itu berjalan.
---
## 9. Checklist per tim
**Backoffice Website**
- [ ] CRUD satuan tersedia di menu master data
- [ ] Form produk punya pilihan cara jual: Satuan / Timbangan
- [ ] Pemilih satuan menjadi wajib saat Timbangan dipilih
- [ ] Label harga ikut satuan — "Harga per ons"
- [ ] `sell_by` dikunci untuk produk yang sudah bertransaksi
- [ ] Daftar produk menandai mana yang dijual per timbangan
- [ ] Laporan harian menampilkan kolom Berat
**POS Mobile**
- [ ] Menu membaca `sell_by` tiap produk
- [ ] Papan angka desimal untuk produk timbangan
- [ ] Kirim `quantity: 1` + `weight`
- [ ] Dua penimbangan menjadi dua baris, tidak digabung
- [ ] Keranjang & struk menampilkan `4,2 ons × Rp 4.500`
- [ ] Void & refund baris berbobot bersifat utuh
- [ ] Split bill: pilih baris, bukan stepper jumlah
- [ ] Pesan error validasi ditampilkan ke kasir
@@ -6,6 +6,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';
import '../../../common/service/device_info_service.dart';
import '../../../common/service/unit_registry.dart';
import '../../../common/service/fcm_service.dart';
import '../../../domain/auth/auth.dart';
@@ -18,11 +19,13 @@ class LoginFormBloc extends Bloc<LoginFormEvent, LoginFormState> {
final IAuthRepository _authRepository;
final FcmService _fcmService;
final DeviceInfoService _deviceInfoService;
final UnitRegistry _unitRegistry;
LoginFormBloc(
this._authRepository,
this._fcmService,
this._deviceInfoService,
this._unitRegistry,
) : super(LoginFormState.initial()) {
on<LoginFormEvent>(_onLoginFormEvent);
}
@@ -62,6 +65,12 @@ class LoginFormBloc extends Bloc<LoginFormEvent, LoginFormState> {
devicePayload: devicePayload,
);
// Master satuan diambil sekali per sesi supaya keranjang bisa
// menulis "4,2 ons" tanpa menunggu request saat kasir menimbang.
if (failureOrLogin.isRight()) {
await _unitRegistry.load(forceRefresh: true);
}
emit(
state.copyWith(
isSubmitting: false,
@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart' hide Order;
import 'package:uuid/uuid.dart';
import '../../../common/types/order_type.dart';
import '../../../domain/delivery/delivery.dart';
@@ -19,6 +20,33 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
CheckoutFormBloc() : super(CheckoutFormState.initial()) {
on<CheckoutFormEvent>(_onCheckoutFormEvent);
}
/// Baris berbobot dihitung `weight × harga`, baris cacah `quantity × harga`.
/// Satu tempat saja supaya total keranjang, subtotal, dan ringkasan tidak
/// pernah berbeda hitungan.
CheckoutFormState _recalculate(
CheckoutFormState state,
List<ProductQuantity> items,
) {
final totalQuantity = items.fold<int>(
0,
// Baris berbobot selalu satu penimbangan, jadi dihitung satu.
(sum, item) => sum + (item.isWeighted ? 1 : item.quantity),
);
final totalPrice = items.fold<double>(
0,
(sum, item) => sum + item.lineTotal,
);
return state.copyWith(
items: items,
totalQuantity: totalQuantity,
totalPrice: totalPrice.toInt(),
isLoading: false,
);
}
Future<void> _onCheckoutFormEvent(
CheckoutFormEvent event,
Emitter<CheckoutFormState> emit,
@@ -26,12 +54,21 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
return event.map(
addItem: (e) async {
final currentState = state;
// Produk timbangan tidak punya stepper — masuk lewat addWeightedItem
// setelah kasir memasukkan angka timbangan.
if (e.product.isSoldByWeight) {
log('⚖️ ${e.product.name} dijual per timbangan, butuh berat');
return;
}
emit(currentState.copyWith(isLoading: true));
List<ProductQuantity> items = [...currentState.items];
final index = items.indexWhere(
(element) =>
!element.isWeighted &&
element.product.id == e.product.id &&
element.variant?.id == e.variant?.id,
);
@@ -49,35 +86,47 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
quantity: 1,
variant: e.variant,
notes: '',
lineId: const Uuid().v4(),
),
);
}
log('🛒 Items updated: ${items.length} items total ${items.toList()}');
final totalQuantity = items.fold<int>(
0,
(sum, item) => sum + item.quantity,
);
emit(_recalculate(currentState, items));
},
addWeightedItem: (e) async {
final currentState = state;
emit(currentState.copyWith(isLoading: true));
final totalPrice = state.items.isEmpty
? 0.0
: state.items
.map(
(e) =>
(e.product.price * e.quantity) +
(e.variant?.priceModifier ?? 0),
)
.reduce((value, element) => value + element);
emit(
currentState.copyWith(
items: items,
totalQuantity: totalQuantity,
totalPrice: totalPrice.toInt(),
isLoading: false,
// Sengaja tidak digabung dengan baris lain: dua penimbangan adalah dua
// ikan berbeda, masing-masing bisa di-void atau dibayar terpisah.
final items = [
...currentState.items,
ProductQuantity(
product: e.product,
variant: e.variant,
quantity: 1,
notes: '',
lineId: const Uuid().v4(),
weight: e.weight,
),
);
];
log('⚖️ Penimbangan baru: ${e.product.name} ${e.weight}');
emit(_recalculate(currentState, items));
},
updateItemWeight: (e) async {
final currentState = state;
final items = [...currentState.items];
final index = items.indexWhere((item) => item.lineId == e.lineId);
if (index == -1) return;
items[index] = items[index].copyWith(weight: e.weight);
emit(_recalculate(currentState, items));
},
removeItem: (e) async {
final currentState = state;
@@ -86,6 +135,7 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
final index = items.indexWhere(
(element) =>
!element.isWeighted &&
element.product.id == e.product.id &&
element.variant?.id == e.variant?.id,
);
@@ -103,32 +153,18 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
}
}
final totalQuantity = items.fold<int>(
0,
(sum, item) => sum + item.quantity,
);
final totalPrice = items.fold<int>(
0,
(sum, item) =>
sum +
(item.quantity *
(item.variant?.priceModifier.toInt() ??
item.product.price.toInt())),
);
log('🗑️ Item removed. Total items: ${items.length}');
log(
'🗑️ Item removed. Total items: ${items.length}, totalQuantity: $totalQuantity, totalPrice: $totalPrice',
);
emit(_recalculate(currentState, items));
},
removeLine: (e) async {
final currentState = state;
final items = [...currentState.items]
..removeWhere((item) => item.lineId == e.lineId);
// Emit state baru
emit(
currentState.copyWith(
items: items,
totalQuantity: totalQuantity,
totalPrice: totalPrice,
isLoading: false,
),
);
log('🗑️ Baris dihapus. Sisa ${items.length} baris');
emit(_recalculate(currentState, items));
},
started: (e) async {
emit(CheckoutFormState.initial().copyWith(isLoading: true));
@@ -153,14 +189,13 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
);
}
},
updateItemNotes: (e) async {
updateLineNotes: (e) async {
final currentState = state;
// Clone list items agar tidak mutasi langsung
// Catatan diikat ke baris, bukan ke produk: dua penimbangan produk yang
// sama bisa punya catatan berbeda.
final items = [...currentState.items];
final index = items.indexWhere(
(element) => element.product.id == e.product.id,
);
final index = items.indexWhere((item) => item.lineId == e.lineId);
if (index != -1) {
items[index] = items[index].copyWith(notes: e.notes);
@@ -181,8 +216,8 @@ class CheckoutFormBloc extends Bloc<CheckoutFormEvent, CheckoutFormState> {
emit(state.copyWith(orderAdded: e.order));
},
customerNameChanged: (e) async {
emit(state.copyWith(customerName: e.name,));
}
emit(state.copyWith(customerName: e.name));
},
);
}
}
File diff suppressed because it is too large Load Diff
@@ -10,15 +10,32 @@ class CheckoutFormEvent with _$CheckoutFormEvent {
ProductVariant? variant,
) = _AddItem;
/// Satu penimbangan = satu baris baru. Dipanggil setiap kali kasir selesai
/// memasukkan angka timbangan, meski produknya sama dengan baris lain.
const factory CheckoutFormEvent.addWeightedItem(
Product product,
ProductVariant? variant,
double weight,
) = _AddWeightedItem;
/// Koreksi angka timbangan pada satu baris tertentu.
const factory CheckoutFormEvent.updateItemWeight(
String lineId,
double weight,
) = _UpdateItemWeight;
const factory CheckoutFormEvent.removeItem(
Product product,
ProductVariant? variant,
) = _RemoveItem;
const factory CheckoutFormEvent.updateItemNotes(
Product product,
/// Hapus satu baris utuh — satu-satunya cara membatalkan baris berbobot.
const factory CheckoutFormEvent.removeLine(String lineId) = _RemoveLine;
const factory CheckoutFormEvent.updateLineNotes(
String lineId,
String notes,
) = _UpdateItemNotes;
) = _UpdateLineNotes;
const factory CheckoutFormEvent.updateOrderType(OrderType orderType) =
_UpdateOrderType;
@@ -30,6 +47,6 @@ class CheckoutFormEvent with _$CheckoutFormEvent {
const factory CheckoutFormEvent.orderAddedItems(Order? order) =
_OrderAddedItems;
const factory CheckoutFormEvent.customerNameChanged(String name) =
const factory CheckoutFormEvent.customerNameChanged(String name) =
_CustomerNameChanged;
}
@@ -3,6 +3,7 @@ import 'package:dartz/dartz.dart' hide Order;
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart' hide Order;
import 'package:uuid/uuid.dart';
import '../../../common/types/order_type.dart';
import '../../../domain/customer/customer.dart';
@@ -59,15 +60,7 @@ class OrderFormBloc extends Bloc<OrderFormEvent, OrderFormState> {
notes: '',
customerName: state.customerName ?? "",
orderItems: e.items
.map(
(item) => OrderItemRequest(
productId: item.product.id,
productVariantId: item.variant?.id ?? "",
quantity: item.quantity,
unitPrice: item.product.price.toInt(),
notes: item.notes,
),
)
.map(OrderItemRequest.fromProductQuantity)
.toList(),
);
@@ -99,15 +92,7 @@ class OrderFormBloc extends Bloc<OrderFormEvent, OrderFormState> {
notes: '',
customerName: state.customerName ?? "",
orderItems: e.items
.map(
(item) => OrderItemRequest(
productId: item.product.id,
productVariantId: item.variant?.id ?? "",
quantity: item.quantity,
unitPrice: item.product.price.toInt(),
notes: item.notes,
),
)
.map(OrderItemRequest.fromProductQuantity)
.toList(),
);
@@ -123,34 +108,35 @@ class OrderFormBloc extends Bloc<OrderFormEvent, OrderFormState> {
addItemOrder: (e) async {
Either<OrderFailure, Order> failureOrAddItemOrder;
// Generate new idempotency key saat user intent (tap Add Items)
// Kalau sedang retry, pakai key yang sama
final idempotencyKey = state.addItemIdempotencyKey ?? const Uuid().v4();
emit(
state.copyWith(
isAddingItemOrder: true,
failureOrAddItemOrder: none(),
addItemIdempotencyKey: idempotencyKey,
),
);
final request = e.items
.map(
(item) => AddItemOrderRequest(
productId: item.product.id,
productVariantId: item.variant?.id ?? '',
quantity: item.quantity,
unitPrice: item.product.price.toInt(),
notes: item.notes,
),
)
.map(AddItemOrderRequest.fromProductQuantity)
.toList();
failureOrAddItemOrder = await _repository.addItemOrder(
id: e.orderId,
request: request,
idempotencyKey: idempotencyKey,
);
emit(
state.copyWith(
isAddingItemOrder: false,
failureOrAddItemOrder: optionOf(failureOrAddItemOrder),
// Clear key on success, keep on failure for retry
addItemIdempotencyKey:
failureOrAddItemOrder.isRight() ? null : idempotencyKey,
),
);
},
@@ -1584,6 +1584,11 @@ mixin _$OrderFormState {
bool get isCreatingWithPayment => throw _privateConstructorUsedError;
bool get isAddingItemOrder => throw _privateConstructorUsedError;
/// Idempotency key untuk add item order.
/// Di-generate saat user tap "Add Items", dipakai ulang saat retry,
/// di-clear setelah sukses.
String? get addItemIdempotencyKey => throw _privateConstructorUsedError;
/// Create a copy of OrderFormState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -1608,6 +1613,7 @@ abstract class $OrderFormStateCopyWith<$Res> {
bool isCreating,
bool isCreatingWithPayment,
bool isAddingItemOrder,
String? addItemIdempotencyKey,
});
$PaymentMethodCopyWith<$Res>? get paymentMethod;
@@ -1638,6 +1644,7 @@ class _$OrderFormStateCopyWithImpl<$Res, $Val extends OrderFormState>
Object? isCreating = null,
Object? isCreatingWithPayment = null,
Object? isAddingItemOrder = null,
Object? addItemIdempotencyKey = freezed,
}) {
return _then(
_value.copyWith(
@@ -1678,6 +1685,10 @@ class _$OrderFormStateCopyWithImpl<$Res, $Val extends OrderFormState>
? _value.isAddingItemOrder
: isAddingItemOrder // ignore: cast_nullable_to_non_nullable
as bool,
addItemIdempotencyKey: freezed == addItemIdempotencyKey
? _value.addItemIdempotencyKey
: addItemIdempotencyKey // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
@@ -1731,6 +1742,7 @@ abstract class _$$OrderFormStateImplCopyWith<$Res>
bool isCreating,
bool isCreatingWithPayment,
bool isAddingItemOrder,
String? addItemIdempotencyKey,
});
@override
@@ -1762,6 +1774,7 @@ class __$$OrderFormStateImplCopyWithImpl<$Res>
Object? isCreating = null,
Object? isCreatingWithPayment = null,
Object? isAddingItemOrder = null,
Object? addItemIdempotencyKey = freezed,
}) {
return _then(
_$OrderFormStateImpl(
@@ -1801,6 +1814,10 @@ class __$$OrderFormStateImplCopyWithImpl<$Res>
? _value.isAddingItemOrder
: isAddingItemOrder // ignore: cast_nullable_to_non_nullable
as bool,
addItemIdempotencyKey: freezed == addItemIdempotencyKey
? _value.addItemIdempotencyKey
: addItemIdempotencyKey // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
@@ -1821,6 +1838,7 @@ class _$OrderFormStateImpl
this.isCreating = false,
this.isCreatingWithPayment = false,
this.isAddingItemOrder = false,
this.addItemIdempotencyKey,
});
@override
@@ -1845,9 +1863,15 @@ class _$OrderFormStateImpl
@JsonKey()
final bool isAddingItemOrder;
/// Idempotency key untuk add item order.
/// Di-generate saat user tap "Add Items", dipakai ulang saat retry,
/// di-clear setelah sukses.
@override
final String? addItemIdempotencyKey;
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'OrderFormState(paymentMethod: $paymentMethod, customerName: $customerName, customer: $customer, failureOrCreateOrder: $failureOrCreateOrder, failureOrCreateOrderWithPayment: $failureOrCreateOrderWithPayment, failureOrAddItemOrder: $failureOrAddItemOrder, isCreating: $isCreating, isCreatingWithPayment: $isCreatingWithPayment, isAddingItemOrder: $isAddingItemOrder)';
return 'OrderFormState(paymentMethod: $paymentMethod, customerName: $customerName, customer: $customer, failureOrCreateOrder: $failureOrCreateOrder, failureOrCreateOrderWithPayment: $failureOrCreateOrderWithPayment, failureOrAddItemOrder: $failureOrAddItemOrder, isCreating: $isCreating, isCreatingWithPayment: $isCreatingWithPayment, isAddingItemOrder: $isAddingItemOrder, addItemIdempotencyKey: $addItemIdempotencyKey)';
}
@override
@@ -1868,7 +1892,10 @@ class _$OrderFormStateImpl
..add(DiagnosticsProperty('failureOrAddItemOrder', failureOrAddItemOrder))
..add(DiagnosticsProperty('isCreating', isCreating))
..add(DiagnosticsProperty('isCreatingWithPayment', isCreatingWithPayment))
..add(DiagnosticsProperty('isAddingItemOrder', isAddingItemOrder));
..add(DiagnosticsProperty('isAddingItemOrder', isAddingItemOrder))
..add(
DiagnosticsProperty('addItemIdempotencyKey', addItemIdempotencyKey),
);
}
@override
@@ -1897,7 +1924,9 @@ class _$OrderFormStateImpl
(identical(other.isCreatingWithPayment, isCreatingWithPayment) ||
other.isCreatingWithPayment == isCreatingWithPayment) &&
(identical(other.isAddingItemOrder, isAddingItemOrder) ||
other.isAddingItemOrder == isAddingItemOrder));
other.isAddingItemOrder == isAddingItemOrder) &&
(identical(other.addItemIdempotencyKey, addItemIdempotencyKey) ||
other.addItemIdempotencyKey == addItemIdempotencyKey));
}
@override
@@ -1912,6 +1941,7 @@ class _$OrderFormStateImpl
isCreating,
isCreatingWithPayment,
isAddingItemOrder,
addItemIdempotencyKey,
);
/// Create a copy of OrderFormState
@@ -1938,6 +1968,7 @@ abstract class _OrderFormState implements OrderFormState {
final bool isCreating,
final bool isCreatingWithPayment,
final bool isAddingItemOrder,
final String? addItemIdempotencyKey,
}) = _$OrderFormStateImpl;
@override
@@ -1959,6 +1990,12 @@ abstract class _OrderFormState implements OrderFormState {
@override
bool get isAddingItemOrder;
/// Idempotency key untuk add item order.
/// Di-generate saat user tap "Add Items", dipakai ulang saat retry,
/// di-clear setelah sukses.
@override
String? get addItemIdempotencyKey;
/// Create a copy of OrderFormState
/// with the given fields replaced by the non-null parameter values.
@override
@@ -13,6 +13,10 @@ class OrderFormState with _$OrderFormState {
@Default(false) bool isCreating,
@Default(false) bool isCreatingWithPayment,
@Default(false) bool isAddingItemOrder,
/// Idempotency key untuk add item order.
/// Di-generate saat user tap "Add Items", dipakai ulang saat retry,
/// di-clear setelah sukses.
String? addItemIdempotencyKey,
}) = _OrderFormState;
factory OrderFormState.initial() => OrderFormState(
@@ -4,6 +4,7 @@ import 'package:bloc/bloc.dart';
import 'package:dartz/dartz.dart' hide Order;
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart' hide Order;
import 'package:uuid/uuid.dart';
import '../../../common/types/split_type.dart';
import '../../../domain/order/order.dart';
@@ -38,7 +39,15 @@ class PaymentFormBloc extends Bloc<PaymentFormEvent, PaymentFormState> {
submitted: (e) async {
Either<OrderFailure, Payment> failureOrPayment;
emit(state.copyWith(isSubmitting: true, failureOrPayment: none()));
// Generate new idempotency key saat user intent (tap Bayar)
// Kalau sedang retry (isSubmitting sebelumnya gagal), pakai key yang sama
final idempotencyKey = state.idempotencyKey ?? const Uuid().v4();
emit(state.copyWith(
isSubmitting: true,
failureOrPayment: none(),
idempotencyKey: idempotencyKey,
));
final request = PaymentRequest(
orderId: state.order.id,
@@ -58,20 +67,32 @@ class PaymentFormBloc extends Bloc<PaymentFormEvent, PaymentFormState> {
.toList(),
);
failureOrPayment = await _repository.createPayment(request: request);
failureOrPayment = await _repository.createPayment(
request: request,
idempotencyKey: idempotencyKey,
);
emit(
state.copyWith(
isSubmitting: false,
failureOrPayment: optionOf(failureOrPayment),
// Clear key on success, keep on failure for retry
idempotencyKey: failureOrPayment.isRight() ? null : idempotencyKey,
),
);
},
submittedSplitBill: (e) async {
Either<OrderFailure, Payment> failureOrPayment;
// Generate new idempotency key untuk split bill
final idempotencyKey = state.splitBillIdempotencyKey ?? const Uuid().v4();
emit(
state.copyWith(isSubmitting: true, failureOrPaymentSplitBill: none()),
state.copyWith(
isSubmitting: true,
failureOrPaymentSplitBill: none(),
splitBillIdempotencyKey: idempotencyKey,
),
);
final request = PaymentSplitBillRequest(
@@ -93,12 +114,18 @@ class PaymentFormBloc extends Bloc<PaymentFormEvent, PaymentFormState> {
log(request.toString());
failureOrPayment = await _repository.createSplitBill(request);
failureOrPayment = await _repository.createSplitBill(
request,
idempotencyKey: idempotencyKey,
);
emit(
state.copyWith(
isSubmitting: false,
failureOrPaymentSplitBill: optionOf(failureOrPayment),
// Clear key on success, keep on failure for retry
splitBillIdempotencyKey:
failureOrPayment.isRight() ? null : idempotencyKey,
),
);
},
@@ -813,6 +813,14 @@ mixin _$PaymentFormState {
PaymentMethod? get paymentMethod => throw _privateConstructorUsedError;
bool get isSubmitting => throw _privateConstructorUsedError;
/// Idempotency key untuk payment submission.
/// Di-generate saat user tap "Bayar", dipakai ulang saat retry,
/// di-clear setelah sukses.
String? get idempotencyKey => throw _privateConstructorUsedError;
/// Idempotency key untuk split bill submission.
String? get splitBillIdempotencyKey => throw _privateConstructorUsedError;
/// Create a copy of PaymentFormState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -834,6 +842,8 @@ abstract class $PaymentFormStateCopyWith<$Res> {
Option<Either<OrderFailure, Payment>> failureOrPaymentSplitBill,
PaymentMethod? paymentMethod,
bool isSubmitting,
String? idempotencyKey,
String? splitBillIdempotencyKey,
});
$OrderCopyWith<$Res> get order;
@@ -861,6 +871,8 @@ class _$PaymentFormStateCopyWithImpl<$Res, $Val extends PaymentFormState>
Object? failureOrPaymentSplitBill = null,
Object? paymentMethod = freezed,
Object? isSubmitting = null,
Object? idempotencyKey = freezed,
Object? splitBillIdempotencyKey = freezed,
}) {
return _then(
_value.copyWith(
@@ -888,6 +900,14 @@ class _$PaymentFormStateCopyWithImpl<$Res, $Val extends PaymentFormState>
? _value.isSubmitting
: isSubmitting // ignore: cast_nullable_to_non_nullable
as bool,
idempotencyKey: freezed == idempotencyKey
? _value.idempotencyKey
: idempotencyKey // ignore: cast_nullable_to_non_nullable
as String?,
splitBillIdempotencyKey: freezed == splitBillIdempotencyKey
? _value.splitBillIdempotencyKey
: splitBillIdempotencyKey // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
@@ -934,6 +954,8 @@ abstract class _$$PaymentFormStateImplCopyWith<$Res>
Option<Either<OrderFailure, Payment>> failureOrPaymentSplitBill,
PaymentMethod? paymentMethod,
bool isSubmitting,
String? idempotencyKey,
String? splitBillIdempotencyKey,
});
@override
@@ -962,6 +984,8 @@ class __$$PaymentFormStateImplCopyWithImpl<$Res>
Object? failureOrPaymentSplitBill = null,
Object? paymentMethod = freezed,
Object? isSubmitting = null,
Object? idempotencyKey = freezed,
Object? splitBillIdempotencyKey = freezed,
}) {
return _then(
_$PaymentFormStateImpl(
@@ -989,6 +1013,14 @@ class __$$PaymentFormStateImplCopyWithImpl<$Res>
? _value.isSubmitting
: isSubmitting // ignore: cast_nullable_to_non_nullable
as bool,
idempotencyKey: freezed == idempotencyKey
? _value.idempotencyKey
: idempotencyKey // ignore: cast_nullable_to_non_nullable
as String?,
splitBillIdempotencyKey: freezed == splitBillIdempotencyKey
? _value.splitBillIdempotencyKey
: splitBillIdempotencyKey // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
@@ -1004,6 +1036,8 @@ class _$PaymentFormStateImpl implements _PaymentFormState {
required this.failureOrPaymentSplitBill,
this.paymentMethod,
this.isSubmitting = false,
this.idempotencyKey,
this.splitBillIdempotencyKey,
}) : _pendingItems = pendingItems;
@override
@@ -1026,9 +1060,19 @@ class _$PaymentFormStateImpl implements _PaymentFormState {
@JsonKey()
final bool isSubmitting;
/// Idempotency key untuk payment submission.
/// Di-generate saat user tap "Bayar", dipakai ulang saat retry,
/// di-clear setelah sukses.
@override
final String? idempotencyKey;
/// Idempotency key untuk split bill submission.
@override
final String? splitBillIdempotencyKey;
@override
String toString() {
return 'PaymentFormState(order: $order, pendingItems: $pendingItems, failureOrPayment: $failureOrPayment, failureOrPaymentSplitBill: $failureOrPaymentSplitBill, paymentMethod: $paymentMethod, isSubmitting: $isSubmitting)';
return 'PaymentFormState(order: $order, pendingItems: $pendingItems, failureOrPayment: $failureOrPayment, failureOrPaymentSplitBill: $failureOrPaymentSplitBill, paymentMethod: $paymentMethod, isSubmitting: $isSubmitting, idempotencyKey: $idempotencyKey, splitBillIdempotencyKey: $splitBillIdempotencyKey)';
}
@override
@@ -1051,7 +1095,14 @@ class _$PaymentFormStateImpl implements _PaymentFormState {
(identical(other.paymentMethod, paymentMethod) ||
other.paymentMethod == paymentMethod) &&
(identical(other.isSubmitting, isSubmitting) ||
other.isSubmitting == isSubmitting));
other.isSubmitting == isSubmitting) &&
(identical(other.idempotencyKey, idempotencyKey) ||
other.idempotencyKey == idempotencyKey) &&
(identical(
other.splitBillIdempotencyKey,
splitBillIdempotencyKey,
) ||
other.splitBillIdempotencyKey == splitBillIdempotencyKey));
}
@override
@@ -1063,6 +1114,8 @@ class _$PaymentFormStateImpl implements _PaymentFormState {
failureOrPaymentSplitBill,
paymentMethod,
isSubmitting,
idempotencyKey,
splitBillIdempotencyKey,
);
/// Create a copy of PaymentFormState
@@ -1086,6 +1139,8 @@ abstract class _PaymentFormState implements PaymentFormState {
failureOrPaymentSplitBill,
final PaymentMethod? paymentMethod,
final bool isSubmitting,
final String? idempotencyKey,
final String? splitBillIdempotencyKey,
}) = _$PaymentFormStateImpl;
@override
@@ -1101,6 +1156,16 @@ abstract class _PaymentFormState implements PaymentFormState {
@override
bool get isSubmitting;
/// Idempotency key untuk payment submission.
/// Di-generate saat user tap "Bayar", dipakai ulang saat retry,
/// di-clear setelah sukses.
@override
String? get idempotencyKey;
/// Idempotency key untuk split bill submission.
@override
String? get splitBillIdempotencyKey;
/// Create a copy of PaymentFormState
/// with the given fields replaced by the non-null parameter values.
@override
@@ -9,6 +9,12 @@ class PaymentFormState with _$PaymentFormState {
required Option<Either<OrderFailure, Payment>> failureOrPaymentSplitBill,
PaymentMethod? paymentMethod,
@Default(false) bool isSubmitting,
/// Idempotency key untuk payment submission.
/// Di-generate saat user tap "Bayar", dipakai ulang saat retry,
/// di-clear setelah sukses.
String? idempotencyKey,
/// Idempotency key untuk split bill submission.
String? splitBillIdempotencyKey,
}) = _PaymentFormState;
factory PaymentFormState.initial() => PaymentFormState(
@@ -1,5 +1,4 @@
import 'package:bloc/bloc.dart';
import 'package:dartz/dartz.dart' hide Order;
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart' hide Order;
@@ -21,136 +20,63 @@ class PrintStruckBloc extends Bloc<PrintStruckEvent, PrintStruckState> {
Future<void> _onPrintStruckEvent(
PrintStruckEvent event,
Emitter<PrintStruckState> emit,
) {
return event.map(
order: (e) async {
Either<PrinterFailure, Unit> failureOrSuccess;
) => event.map(
order: (e) => _print(emit, e.order, _stationsForOrder(e.order)),
payment: (e) => _print(emit, e.order, const [PrintStation.receipt]),
cashier: (e) => _print(emit, e.order, const [PrintStation.cashier]),
checker: (e) => _print(emit, e.order, const [PrintStation.checker]),
kitchen: (e) => _print(emit, e.order, const [PrintStation.kitchen]),
bar: (e) => _print(emit, e.order, const [PrintStation.bar]),
voided: (e) => _print(emit, e.order, const [PrintStation.voided]),
splitBill: (e) => _print(emit, e.order, const [PrintStation.splitBill]),
retryFailed: (_) {
final order = state.order;
final failed = [
for (final station in state.failedStations) station.station,
];
emit(state.copyWith(isPrinting: true, failureOrPrintStruck: none()));
if (order == null || failed.isEmpty) return Future.value();
failureOrSuccess = await _printerRepository.printStruckOrder(
order: e.order,
);
return _print(emit, order, failed);
},
);
emit(
state.copyWith(
isPrinting: false,
failureOrPrintStruck: optionOf(failureOrSuccess),
),
);
},
cashier: (e) async {
Either<PrinterFailure, Unit> failureOrSuccess;
/// Struk pelanggan hanya ikut dicetak kalau ordernya sudah dibayar.
List<PrintStation> _stationsForOrder(Order order) => [
if (order.totalPaid > 0) PrintStation.receipt,
PrintStation.cashier,
PrintStation.checker,
PrintStation.kitchen,
PrintStation.bar,
];
emit(state.copyWith(isPrinting: true, failureOrPrintStruck: none()));
Future<void> _print(
Emitter<PrintStruckState> emit,
Order order,
List<PrintStation> stations,
) async {
// Tombol yang tertekan dua kali tidak boleh memulai job kedua — repository
// akan mengantrikannya dan progres yang tampil jadi membingungkan.
if (state.isPrinting) return;
failureOrSuccess = await _printerRepository.printStruckCashier(
order: e.order,
);
emit(
state.copyWith(
isPrinting: false,
failureOrPrintStruck: optionOf(failureOrSuccess),
),
);
},
payment: (e) async {
Either<PrinterFailure, Unit> failureOrSuccess;
emit(state.copyWith(isPrinting: true, failureOrPrintStruck: none()));
failureOrSuccess = await _printerRepository.printStruckPayment(
order: e.order,
);
emit(
state.copyWith(
isPrinting: false,
failureOrPrintStruck: optionOf(failureOrSuccess),
),
);
},
voided: (e) async {
Either<PrinterFailure, Unit> failureOrSuccess;
emit(state.copyWith(isPrinting: true, failureOrPrintStruck: none()));
failureOrSuccess = await _printerRepository.printStruckVoid(
order: e.order,
);
emit(
state.copyWith(
isPrinting: false,
failureOrPrintStruck: optionOf(failureOrSuccess),
),
);
},
splitBill: (e) async {
Either<PrinterFailure, Unit> failureOrSuccess;
emit(state.copyWith(isPrinting: true, failureOrPrintStruck: none()));
failureOrSuccess = await _printerRepository.printStruckSplitBill(
order: e.order,
);
emit(
state.copyWith(
isPrinting: false,
failureOrPrintStruck: optionOf(failureOrSuccess),
),
);
},
kitchen: (e) async {
Either<PrinterFailure, Unit> failureOrSuccess;
emit(state.copyWith(isPrinting: true, failureOrPrintStruck: none()));
failureOrSuccess = await _printerRepository.printStruckKitchen(
order: e.order,
);
emit(
state.copyWith(
isPrinting: false,
failureOrPrintStruck: optionOf(failureOrSuccess),
),
);
},
bar: (e) async {
Either<PrinterFailure, Unit> failureOrSuccess;
emit(state.copyWith(isPrinting: true, failureOrPrintStruck: none()));
failureOrSuccess = await _printerRepository.printStruckBar(
order: e.order,
);
emit(
state.copyWith(
isPrinting: false,
failureOrPrintStruck: optionOf(failureOrSuccess),
),
);
},
checker: (e) async {
Either<PrinterFailure, Unit> failureOrSuccess;
emit(state.copyWith(isPrinting: true, failureOrPrintStruck: none()));
failureOrSuccess = await _printerRepository.printStruckChecker(
order: e.order,
);
emit(
state.copyWith(
isPrinting: false,
failureOrPrintStruck: optionOf(failureOrSuccess),
),
);
},
emit(
state.copyWith(
isPrinting: true,
order: order,
jobId: state.jobId + 1,
stations: [
for (final station in stations) PrintStationResult.pending(station),
],
),
);
await emit.forEach<List<PrintStationResult>>(
_printerRepository.printStations(order: order, stations: stations),
onData: (results) => state.copyWith(stations: results),
);
// Cetak bisa selesai setelah bloc ditutup (kasir keluar dari aplikasi).
if (isClosed) return;
emit(state.copyWith(isPrinting: false));
}
}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,9 @@ part of 'print_struck_bloc.dart';
@freezed
class PrintStruckEvent with _$PrintStruckEvent {
/// Cetak seluruh station untuk satu order.
const factory PrintStruckEvent.order(Order order) = _Order;
const factory PrintStruckEvent.cashier(Order order) = _Cashier;
const factory PrintStruckEvent.payment(Order order) = _Payment;
const factory PrintStruckEvent.voided(Order order) = _Voided;
@@ -10,4 +12,8 @@ class PrintStruckEvent with _$PrintStruckEvent {
const factory PrintStruckEvent.kitchen(Order order) = _Kitchen;
const factory PrintStruckEvent.bar(Order order) = _Bar;
const factory PrintStruckEvent.splitBill(Order order) = _SplitBill;
/// Ulangi hanya station yang gagal di job terakhir, tanpa mencetak ulang
/// station yang sudah keluar.
const factory PrintStruckEvent.retryFailed() = _RetryFailed;
}
@@ -2,11 +2,33 @@ part of 'print_struck_bloc.dart';
@freezed
class PrintStruckState with _$PrintStruckState {
factory PrintStruckState({
required Option<Either<PrinterFailure, Unit>> failureOrPrintStruck,
const PrintStruckState._();
const factory PrintStruckState({
/// Status seluruh station di job cetak terakhir, urut sesuai urutan cetak.
@Default(<PrintStationResult>[]) List<PrintStationResult> stations,
@Default(false) bool isPrinting,
/// Order yang sedang dicetak, disimpan supaya station yang gagal bisa
/// diulang tanpa perlu dikirim ulang dari halaman pemanggil.
Order? order,
/// Naik tiap job baru dimulai. UI memakainya untuk tahu kapan harus
/// membuka dialog progres.
@Default(0) int jobId,
}) = _PrintStruckState;
factory PrintStruckState.initial() =>
PrintStruckState(failureOrPrintStruck: none());
factory PrintStruckState.initial() => const PrintStruckState();
List<PrintStationResult> get failedStations =>
stations.where((station) => station.isFailed).toList();
bool get hasFailure => failedStations.isNotEmpty;
/// Station yang benar-benar dicetak — yang dilewati tidak ikut dihitung.
int get plannedCount =>
stations.where((station) => !station.isSkipped).length;
int get successCount =>
stations.where((station) => station.isSuccess).length;
}
@@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart';
import 'package:dartz/dartz.dart' hide Order;
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart' hide Order;
import 'package:uuid/uuid.dart';
import '../../../common/data/refund_data.dart';
import '../../../domain/order/order.dart';
@@ -34,7 +35,15 @@ class RefundFormBloc extends Bloc<RefundFormEvent, RefundFormState> {
submitted: (e) async {
Either<OrderFailure, Unit>? failureOrRefund;
emit(state.copyWith(isSubmitting: true, failureOrRefund: none()));
// Generate new idempotency key saat user intent (tap Refund)
// Kalau sedang retry, pakai key yang sama
final idempotencyKey = state.idempotencyKey ?? const Uuid().v4();
emit(state.copyWith(
isSubmitting: true,
failureOrRefund: none(),
idempotencyKey: idempotencyKey,
));
failureOrRefund = await _repository.refundOrder(
id: state.order.id,
@@ -42,12 +51,15 @@ class RefundFormBloc extends Bloc<RefundFormEvent, RefundFormState> {
? state.reason
: state.refundReason?.value ?? '',
refundAmount: state.order.totalAmount,
idempotencyKey: idempotencyKey,
);
emit(
state.copyWith(
isSubmitting: false,
failureOrRefund: optionOf(failureOrRefund),
// Clear key on success, keep on failure for retry
idempotencyKey: failureOrRefund.isRight() ? null : idempotencyKey,
),
);
},
@@ -692,6 +692,11 @@ mixin _$RefundFormState {
throw _privateConstructorUsedError;
bool get isSubmitting => throw _privateConstructorUsedError;
/// Idempotency key untuk refund submission.
/// Di-generate saat user tap "Refund", dipakai ulang saat retry,
/// di-clear setelah sukses.
String? get idempotencyKey => throw _privateConstructorUsedError;
/// Create a copy of RefundFormState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -712,6 +717,7 @@ abstract class $RefundFormStateCopyWith<$Res> {
RefundReason? refundReason,
Option<Either<OrderFailure, Unit>> failureOrRefund,
bool isSubmitting,
String? idempotencyKey,
});
$OrderCopyWith<$Res> get order;
@@ -737,6 +743,7 @@ class _$RefundFormStateCopyWithImpl<$Res, $Val extends RefundFormState>
Object? refundReason = freezed,
Object? failureOrRefund = null,
Object? isSubmitting = null,
Object? idempotencyKey = freezed,
}) {
return _then(
_value.copyWith(
@@ -760,6 +767,10 @@ class _$RefundFormStateCopyWithImpl<$Res, $Val extends RefundFormState>
? _value.isSubmitting
: isSubmitting // ignore: cast_nullable_to_non_nullable
as bool,
idempotencyKey: freezed == idempotencyKey
? _value.idempotencyKey
: idempotencyKey // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
@@ -791,6 +802,7 @@ abstract class _$$RefundFormStateImplCopyWith<$Res>
RefundReason? refundReason,
Option<Either<OrderFailure, Unit>> failureOrRefund,
bool isSubmitting,
String? idempotencyKey,
});
@override
@@ -816,6 +828,7 @@ class __$$RefundFormStateImplCopyWithImpl<$Res>
Object? refundReason = freezed,
Object? failureOrRefund = null,
Object? isSubmitting = null,
Object? idempotencyKey = freezed,
}) {
return _then(
_$RefundFormStateImpl(
@@ -839,6 +852,10 @@ class __$$RefundFormStateImplCopyWithImpl<$Res>
? _value.isSubmitting
: isSubmitting // ignore: cast_nullable_to_non_nullable
as bool,
idempotencyKey: freezed == idempotencyKey
? _value.idempotencyKey
: idempotencyKey // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
@@ -853,6 +870,7 @@ class _$RefundFormStateImpl implements _RefundFormState {
this.refundReason,
required this.failureOrRefund,
this.isSubmitting = false,
this.idempotencyKey,
});
@override
@@ -867,9 +885,15 @@ class _$RefundFormStateImpl implements _RefundFormState {
@JsonKey()
final bool isSubmitting;
/// Idempotency key untuk refund submission.
/// Di-generate saat user tap "Refund", dipakai ulang saat retry,
/// di-clear setelah sukses.
@override
final String? idempotencyKey;
@override
String toString() {
return 'RefundFormState(order: $order, reason: $reason, refundReason: $refundReason, failureOrRefund: $failureOrRefund, isSubmitting: $isSubmitting)';
return 'RefundFormState(order: $order, reason: $reason, refundReason: $refundReason, failureOrRefund: $failureOrRefund, isSubmitting: $isSubmitting, idempotencyKey: $idempotencyKey)';
}
@override
@@ -884,7 +908,9 @@ class _$RefundFormStateImpl implements _RefundFormState {
(identical(other.failureOrRefund, failureOrRefund) ||
other.failureOrRefund == failureOrRefund) &&
(identical(other.isSubmitting, isSubmitting) ||
other.isSubmitting == isSubmitting));
other.isSubmitting == isSubmitting) &&
(identical(other.idempotencyKey, idempotencyKey) ||
other.idempotencyKey == idempotencyKey));
}
@override
@@ -895,6 +921,7 @@ class _$RefundFormStateImpl implements _RefundFormState {
refundReason,
failureOrRefund,
isSubmitting,
idempotencyKey,
);
/// Create a copy of RefundFormState
@@ -916,6 +943,7 @@ abstract class _RefundFormState implements RefundFormState {
final RefundReason? refundReason,
required final Option<Either<OrderFailure, Unit>> failureOrRefund,
final bool isSubmitting,
final String? idempotencyKey,
}) = _$RefundFormStateImpl;
@override
@@ -929,6 +957,12 @@ abstract class _RefundFormState implements RefundFormState {
@override
bool get isSubmitting;
/// Idempotency key untuk refund submission.
/// Di-generate saat user tap "Refund", dipakai ulang saat retry,
/// di-clear setelah sukses.
@override
String? get idempotencyKey;
/// Create a copy of RefundFormState
/// with the given fields replaced by the non-null parameter values.
@override
@@ -8,6 +8,10 @@ class RefundFormState with _$RefundFormState {
RefundReason? refundReason,
required Option<Either<OrderFailure, Unit>> failureOrRefund,
@Default(false) bool isSubmitting,
/// Idempotency key untuk refund submission.
/// Di-generate saat user tap "Refund", dipakai ulang saat retry,
/// di-clear setelah sukses.
String? idempotencyKey,
}) = _RefundFormState;
factory RefundFormState.initial() => RefundFormState(
@@ -67,7 +67,11 @@ class SplitBillFormBloc extends Bloc<SplitBillFormEvent, SplitBillFormState> {
final item = state.pendingItems.firstWhere(
(item) => item.id == entry.key,
);
newTotal += item.unitPrice.toInt() * entry.value;
// Baris berbobot dibayar utuh: nilainya weight x harga, bukan
// quantity x harga.
newTotal += item.isWeighted
? item.totalPrice
: item.unitPrice.toInt() * entry.value;
}
emit(
@@ -3,6 +3,7 @@ import 'package:dartz/dartz.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';
import '../../../common/service/unit_registry.dart';
import '../../../domain/category/category.dart';
import '../../../domain/product/product.dart';
@@ -14,9 +15,13 @@ part 'sync_setting_bloc.freezed.dart';
class SyncSettingBloc extends Bloc<SyncSettingEvent, SyncSettingState> {
final IProductRepository _productRepository;
final ICategoryRepository _categoryRepository;
final UnitRegistry _unitRegistry;
SyncSettingBloc(this._productRepository, this._categoryRepository)
: super(SyncSettingState.initial()) {
SyncSettingBloc(
this._productRepository,
this._categoryRepository,
this._unitRegistry,
) : super(SyncSettingState.initial()) {
on<SyncSettingEvent>(_onSyncSettingEvent);
}
@@ -97,6 +102,10 @@ class SyncSettingBloc extends Bloc<SyncSettingEvent, SyncSettingState> {
),
);
// Satuan lebih dulu: produk timbangan butuh singkatannya untuk bisa
// ditulis "4,2 ons" begitu selesai disinkronkan.
await _unitRegistry.load(forceRefresh: true);
// Sync categories first
final categoryResult = await _categoryRepository.syncAllCategories();
@@ -169,6 +178,8 @@ class SyncSettingBloc extends Bloc<SyncSettingEvent, SyncSettingState> {
Future<void> _onSyncProducts(Emitter<SyncSettingState> emit) async {
emit(state.copyWith(isLoading: true, failureOptionSyncProduct: none()));
await _unitRegistry.load(forceRefresh: true);
final result = await _productRepository.syncAllProducts();
result.fold(
@@ -3,6 +3,7 @@ import 'package:collection/collection.dart';
import 'package:dartz/dartz.dart' hide Order;
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart' hide Order;
import 'package:uuid/uuid.dart';
import '../../../common/types/void_type.dart';
import '../../../domain/order/order.dart';
@@ -54,7 +55,10 @@ class VoidFormBloc extends Bloc<VoidFormEvent, VoidFormState> {
(item) => item.id == entry.key,
);
if (item == null) continue;
newTotal += item.unitPrice.toInt() * entry.value;
// Baris berbobot di-void utuh; rupiahnya sudah weight x harga.
newTotal += item.isWeighted
? item.totalPrice
: item.unitPrice.toInt() * entry.value;
}
emit(
@@ -91,7 +95,13 @@ class VoidFormBloc extends Bloc<VoidFormEvent, VoidFormState> {
productVariantName: originalItem.productVariantName,
quantity: voidQty,
unitPrice: originalItem.unitPrice,
totalPrice: (originalItem.unitPrice) * voidQty,
totalPrice: originalItem.isWeighted
? originalItem.totalPrice
: (originalItem.unitPrice) * voidQty,
weight: originalItem.weight,
unitId: originalItem.unitId,
unitName: originalItem.unitName,
unitAbbreviation: originalItem.unitAbbreviation,
modifiers: originalItem.modifiers,
notes: originalItem.notes,
status: originalItem.status,
@@ -99,18 +109,28 @@ class VoidFormBloc extends Bloc<VoidFormEvent, VoidFormState> {
updatedAt: originalItem.updatedAt,
printerType: originalItem.printerType,
paidQuantity: originalItem.paidQuantity,
printToChecker: originalItem.printToChecker,
),
);
});
}
emit(state.copyWith(isSubmitting: true, failureOrVoid: none()));
// Generate new idempotency key saat user intent (tap Void)
// Kalau sedang retry, pakai key yang sama
final idempotencyKey = state.idempotencyKey ?? const Uuid().v4();
emit(state.copyWith(
isSubmitting: true,
failureOrVoid: none(),
idempotencyKey: idempotencyKey,
));
failureOrVoid = await _repository.voidOrder(
orderId: state.order.id,
reason: state.voidReason ?? '',
orderItems: voidItems,
type: state.voidType.toStringType(),
idempotencyKey: idempotencyKey,
);
emit(
@@ -118,6 +138,8 @@ class VoidFormBloc extends Bloc<VoidFormEvent, VoidFormState> {
isSubmitting: false,
failureOrVoid: optionOf(failureOrVoid),
voidItems: state.voidType.isItem ? voidItems : state.pendingItems,
// Clear key on success, keep on failure for retry
idempotencyKey: failureOrVoid.isRight() ? null : idempotencyKey,
),
);
},
@@ -1256,6 +1256,11 @@ mixin _$VoidFormState {
throw _privateConstructorUsedError;
bool get isSubmitting => throw _privateConstructorUsedError;
/// Idempotency key untuk void submission.
/// Di-generate saat user tap "Void", dipakai ulang saat retry,
/// di-clear setelah sukses atau clearState.
String? get idempotencyKey => throw _privateConstructorUsedError;
/// Create a copy of VoidFormState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -1280,6 +1285,7 @@ abstract class $VoidFormStateCopyWith<$Res> {
int totalPriceVoid,
Option<Either<OrderFailure, Unit>> failureOrVoid,
bool isSubmitting,
String? idempotencyKey,
});
$OrderCopyWith<$Res> get order;
@@ -1309,6 +1315,7 @@ class _$VoidFormStateCopyWithImpl<$Res, $Val extends VoidFormState>
Object? totalPriceVoid = null,
Object? failureOrVoid = null,
Object? isSubmitting = null,
Object? idempotencyKey = freezed,
}) {
return _then(
_value.copyWith(
@@ -1348,6 +1355,10 @@ class _$VoidFormStateCopyWithImpl<$Res, $Val extends VoidFormState>
? _value.isSubmitting
: isSubmitting // ignore: cast_nullable_to_non_nullable
as bool,
idempotencyKey: freezed == idempotencyKey
? _value.idempotencyKey
: idempotencyKey // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
@@ -1383,6 +1394,7 @@ abstract class _$$VoidFormStateImplCopyWith<$Res>
int totalPriceVoid,
Option<Either<OrderFailure, Unit>> failureOrVoid,
bool isSubmitting,
String? idempotencyKey,
});
@override
@@ -1412,6 +1424,7 @@ class __$$VoidFormStateImplCopyWithImpl<$Res>
Object? totalPriceVoid = null,
Object? failureOrVoid = null,
Object? isSubmitting = null,
Object? idempotencyKey = freezed,
}) {
return _then(
_$VoidFormStateImpl(
@@ -1451,6 +1464,10 @@ class __$$VoidFormStateImplCopyWithImpl<$Res>
? _value.isSubmitting
: isSubmitting // ignore: cast_nullable_to_non_nullable
as bool,
idempotencyKey: freezed == idempotencyKey
? _value.idempotencyKey
: idempotencyKey // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
@@ -1469,6 +1486,7 @@ class _$VoidFormStateImpl implements _VoidFormState {
required this.totalPriceVoid,
required this.failureOrVoid,
this.isSubmitting = false,
this.idempotencyKey,
}) : _pendingItems = pendingItems,
_voidItems = voidItems,
_selectedItemQuantities = selectedItemQuantities;
@@ -1512,9 +1530,15 @@ class _$VoidFormStateImpl implements _VoidFormState {
@JsonKey()
final bool isSubmitting;
/// Idempotency key untuk void submission.
/// Di-generate saat user tap "Void", dipakai ulang saat retry,
/// di-clear setelah sukses atau clearState.
@override
final String? idempotencyKey;
@override
String toString() {
return 'VoidFormState(order: $order, pendingItems: $pendingItems, voidItems: $voidItems, voidType: $voidType, selectedItemQuantities: $selectedItemQuantities, voidReason: $voidReason, totalPriceVoid: $totalPriceVoid, failureOrVoid: $failureOrVoid, isSubmitting: $isSubmitting)';
return 'VoidFormState(order: $order, pendingItems: $pendingItems, voidItems: $voidItems, voidType: $voidType, selectedItemQuantities: $selectedItemQuantities, voidReason: $voidReason, totalPriceVoid: $totalPriceVoid, failureOrVoid: $failureOrVoid, isSubmitting: $isSubmitting, idempotencyKey: $idempotencyKey)';
}
@override
@@ -1544,7 +1568,9 @@ class _$VoidFormStateImpl implements _VoidFormState {
(identical(other.failureOrVoid, failureOrVoid) ||
other.failureOrVoid == failureOrVoid) &&
(identical(other.isSubmitting, isSubmitting) ||
other.isSubmitting == isSubmitting));
other.isSubmitting == isSubmitting) &&
(identical(other.idempotencyKey, idempotencyKey) ||
other.idempotencyKey == idempotencyKey));
}
@override
@@ -1559,6 +1585,7 @@ class _$VoidFormStateImpl implements _VoidFormState {
totalPriceVoid,
failureOrVoid,
isSubmitting,
idempotencyKey,
);
/// Create a copy of VoidFormState
@@ -1581,6 +1608,7 @@ abstract class _VoidFormState implements VoidFormState {
required final int totalPriceVoid,
required final Option<Either<OrderFailure, Unit>> failureOrVoid,
final bool isSubmitting,
final String? idempotencyKey,
}) = _$VoidFormStateImpl;
@override
@@ -1602,6 +1630,12 @@ abstract class _VoidFormState implements VoidFormState {
@override
bool get isSubmitting;
/// Idempotency key untuk void submission.
/// Di-generate saat user tap "Void", dipakai ulang saat retry,
/// di-clear setelah sukses atau clearState.
@override
String? get idempotencyKey;
/// Create a copy of VoidFormState
/// with the given fields replaced by the non-null parameter values.
@override
@@ -12,6 +12,10 @@ class VoidFormState with _$VoidFormState {
required int totalPriceVoid,
required Option<Either<OrderFailure, Unit>> failureOrVoid,
@Default(false) bool isSubmitting,
/// Idempotency key untuk void submission.
/// Di-generate saat user tap "Void", dipakai ulang saat retry,
/// di-clear setelah sukses atau clearState.
String? idempotencyKey,
}) = _VoidFormState;
factory VoidFormState.initial() => VoidFormState(
+2
View File
@@ -16,6 +16,7 @@ import 'interceptors/bad_network_interceptor.dart';
import 'interceptors/bad_request_interceptor.dart';
import 'interceptors/connection_timeout_interceptor.dart';
import 'interceptors/crashlytic_interceptor.dart';
import 'interceptors/idempotency_interceptor.dart';
import 'interceptors/internal_server_interceptor.dart';
import 'interceptors/not_found_interceptor.dart';
import 'interceptors/unauthorized_interceptor.dart';
@@ -30,6 +31,7 @@ class ApiClient {
_dio.options.connectTimeout = const Duration(seconds: 20);
_dio.options.validateStatus = (status) =>
status != null && status >= 200 && status < 500;
_dio.interceptors.add(IdempotencyInterceptor(_dio));
_dio.interceptors.add(BadNetworkErrorInterceptor());
_dio.interceptors.add(BadRequestErrorInterceptor());
_dio.interceptors.add(InternalServerErrorInterceptor());
@@ -0,0 +1,104 @@
import 'package:dio/dio.dart';
import 'package:uuid/uuid.dart';
/// Interceptor yang secara otomatis menambahkan header X-Idempotency-Key
/// untuk endpoint-endpoint kritis yang memerlukan idempotency.
///
/// Key sebaiknya di-generate di level BLoC/Cubit saat user trigger action,
/// lalu dikirim via headers. Interceptor ini hanya berfungsi sebagai fallback
/// jika key belum di-set.
///
/// Jika response 409 dengan error code `request_in_progress`, interceptor
/// akan retry setelah delay 1-2 detik.
class IdempotencyInterceptor extends Interceptor {
static const _headerKey = 'X-Idempotency-Key';
static const _replayHeader = 'X-Idempotent-Replay';
// Endpoints that require idempotency keys (exact match)
static const _idempotentPaths = [
'/api/v1/payments',
'/api/v1/orders/void',
];
// Endpoints that require idempotency keys (pattern match)
static final _idempotentPathPatterns = [
RegExp(r'/api/v1/orders/.+/add-items$'),
RegExp(r'/api/v1/orders/.+/refund$'),
RegExp(r'/api/v1/payments/.+/refund$'),
];
static const _maxRetries = 3;
static const _retryDelay = Duration(seconds: 2);
final Dio _dio;
IdempotencyInterceptor(this._dio);
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (options.method == 'POST' && _requiresIdempotencyKey(options.path)) {
// Gunakan key yang sudah di-set dari BLoC, atau generate baru sebagai fallback
options.headers[_headerKey] ??= const Uuid().v4();
}
handler.next(options);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
// Log jika response adalah replay dari request sebelumnya
final isReplay = response.headers.value(_replayHeader);
if (isReplay == 'true') {
// Response ini adalah hasil dari request pertama yang sudah diproses.
// Treat as success — tidak perlu perlakuan khusus.
}
handler.next(response);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
final response = err.response;
final options = err.requestOptions;
if (response == null) {
handler.next(err);
return;
}
// Handle 409 Conflict with request_in_progress
if (response.statusCode == 409 &&
_isRequestInProgress(response.data) &&
_requiresIdempotencyKey(options.path)) {
final retryCount = options.extra['_idempotency_retry_count'] ?? 0;
if (retryCount < _maxRetries) {
await Future.delayed(_retryDelay);
options.extra['_idempotency_retry_count'] = retryCount + 1;
try {
final retryResponse = await _dio.fetch(options);
handler.resolve(retryResponse);
} on DioException catch (e) {
handler.next(e);
}
return;
}
}
handler.next(err);
}
bool _requiresIdempotencyKey(String path) {
if (_idempotentPaths.any((p) => path.endsWith(p))) return true;
if (_idempotentPathPatterns.any((r) => r.hasMatch(path))) return true;
return false;
}
bool _isRequestInProgress(dynamic data) {
if (data is Map<String, dynamic>) {
final errorCode = data['error_code'] ?? data['code'] ?? '';
return errorCode == 'request_in_progress';
}
return false;
}
}
@@ -2,4 +2,5 @@ class LocalStorageKey {
static const String token = 'token';
static const String user = 'user';
static const String outlet = 'outlet';
static const String units = 'units';
}
+25 -3
View File
@@ -17,7 +17,7 @@ class DatabaseHelper {
return await openDatabase(
path,
version: 1, // Updated version for categories table
version: 3,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@@ -38,10 +38,15 @@ class DatabaseHelper {
business_type TEXT,
image_url TEXT,
printer_type TEXT,
print_to_checker INTEGER DEFAULT 0,
metadata TEXT,
is_active INTEGER,
created_at TEXT,
updated_at TEXT
updated_at TEXT,
sell_by TEXT DEFAULT 'unit',
unit_id TEXT,
unit_name TEXT,
unit_abbreviation TEXT
)
''');
@@ -107,7 +112,24 @@ class DatabaseHelper {
await db.execute('CREATE INDEX idx_printers_type ON printers(type)');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
await db.execute(
'ALTER TABLE products ADD COLUMN print_to_checker INTEGER DEFAULT 0',
);
}
// Produk timbangan: cara jual dan satuan ikut disimpan lokal supaya POS
// tetap bisa menampilkan "4,2 ons" saat offline.
if (oldVersion < 3) {
await db.execute(
"ALTER TABLE products ADD COLUMN sell_by TEXT DEFAULT 'unit'",
);
await db.execute('ALTER TABLE products ADD COLUMN unit_id TEXT');
await db.execute('ALTER TABLE products ADD COLUMN unit_name TEXT');
await db.execute('ALTER TABLE products ADD COLUMN unit_abbreviation TEXT');
}
}
Future<void> close() async {
final db = await database;
@@ -13,3 +13,10 @@ extension DoubleExt on double {
decimalDigits: 0,
).format(this);
}
extension WeightExt on double {
/// Format berat gaya Indonesia: koma desimal, tanpa nol di belakang.
/// `4.2` → `4,2`, `4.237` → `4,237`, `5.0` → `5`.
String get weightFormat =>
NumberFormat('#,##0.###', 'id').format(this);
}
+22
View File
@@ -75,3 +75,25 @@ int generateRandomNumber() {
final random = Random();
return 1 + random.nextInt(10000);
}
/// Ambil pesan validasi dari amplop error standar backend.
///
/// Untuk produk timbangan pesannya menyebut nama produk — misalnya
/// "product Ikan Tude is sold by weight and requires a weight" — jadi layak
/// ditampilkan apa adanya ke kasir alih-alih diganti pesan generik.
String? extractApiErrorCause(dynamic responseData) {
if (responseData is! Map) return null;
final errors = responseData['errors'];
if (errors is! List || errors.isEmpty) return null;
final causes = errors
.whereType<Map>()
.map((e) => (e['cause'] ?? e['message'])?.toString())
.where((cause) => cause != null && cause.isNotEmpty)
.cast<String>()
.toList();
if (causes.isEmpty) return null;
return causes.join('\n');
}
+48
View File
@@ -0,0 +1,48 @@
import 'dart:developer';
import 'package:injectable/injectable.dart';
import '../../domain/unit/unit.dart';
/// Kamus satuan yang dipakai untuk menerjemahkan `unit_id` sebuah produk
/// menjadi singkatan yang dicetak di keranjang dan struk (`4,2 ons`).
///
/// Response produk hanya dijamin membawa `unit_id`; nama dan singkatannya
/// datang dari `GET /api/v1/units`. Registry ini menjembatani keduanya tanpa
/// memaksa setiap pemanggil menunggu request.
@lazySingleton
class UnitRegistry {
final IUnitRepository _repository;
final _logName = 'UnitRegistry';
Map<String, ProductUnit> _unitsById = {};
UnitRegistry(this._repository);
bool get isEmpty => _unitsById.isEmpty;
ProductUnit? byId(String? id) {
if (id == null || id.isEmpty) return null;
return _unitsById[id];
}
String abbreviationOf(String? id) => byId(id)?.abbreviation ?? '';
String nameOf(String? id) => byId(id)?.name ?? '';
/// Muat satuan ke memori. Aman dipanggil berulang; [forceRefresh] menembak
/// server ulang saat pengguna melakukan sinkronisasi manual.
Future<void> load({bool forceRefresh = false}) async {
final result = await _repository.getUnits(forceRefresh: forceRefresh);
result.fold(
(failure) => log('⚠️ Gagal memuat satuan: $failure', name: _logName),
(units) {
_unitsById = {for (final unit in units) unit.id: unit};
log('📏 ${units.length} satuan siap dipakai', name: _logName);
},
);
}
void clear() => _unitsById = {};
}
+19
View File
@@ -0,0 +1,19 @@
/// Cara sebuah produk dijual.
///
/// Lihat `docs/integration-weight-based-products.md` — backend hanya menerima
/// dua nilai ini, dan produk lama otomatis dianggap [SellBy.unit].
class SellBy {
const SellBy._();
/// Dijual per cacah. `quantity` integer, `weight` tidak dikirim.
static const String unit = 'unit';
/// Dijual per timbangan. `quantity` selalu 1, `weight` wajib.
static const String weight = 'weight';
static bool isWeight(String? value) => value == weight;
/// Nilai apa pun di luar dua yang dikenal diperlakukan sebagai [unit],
/// sama seperti perilaku backend untuk produk lama.
static String normalize(String? value) => value == weight ? weight : unit;
}
+1
View File
@@ -4,6 +4,7 @@ class ApiPath {
static const String outlets = '/api/v1/outlets';
static const String categories = '/api/v1/categories';
static const String products = '/api/v1/products';
static const String units = '/api/v1/units';
static const String tables = '/api/v1/tables';
static const String customers = '/api/v1/customers';
static const String paymentMethods = '/api/v1/payment-methods';
@@ -8,6 +8,9 @@ class AddItemOrderRequest with _$AddItemOrderRequest {
required int quantity,
required int unitPrice,
required String notes,
/// Hanya diisi untuk produk `sell_by: "weight"`.
double? weight,
}) = _AddItemOrderRequest;
factory AddItemOrderRequest.empty() => const AddItemOrderRequest(
@@ -17,4 +20,14 @@ class AddItemOrderRequest with _$AddItemOrderRequest {
unitPrice: 0,
notes: '',
);
factory AddItemOrderRequest.fromProductQuantity(ProductQuantity item) =>
AddItemOrderRequest(
productId: item.product.id,
productVariantId: item.variant?.id ?? '',
quantity: item.isWeighted ? 1 : item.quantity,
unitPrice: item.product.price.toInt(),
notes: item.notes,
weight: item.weight,
);
}
+74 -16
View File
@@ -125,6 +125,7 @@ class Order with _$Order {
createdAt: DateTime.now().subtract(const Duration(hours: 1)),
updatedAt: DateTime.now(),
printerType: 'Barista',
printToChecker: false,
paidQuantity: 2,
categoryId: 'CAT-001',
categoryName: 'Minuman',
@@ -145,6 +146,28 @@ class Order with _$Order {
createdAt: DateTime.now().subtract(const Duration(hours: 1)),
updatedAt: DateTime.now(),
printerType: 'Kitchen',
printToChecker: false,
paidQuantity: 1,
categoryId: 'CAT-002',
categoryName: 'Makanan',
),
OrderItem(
id: 'ITEM-003',
orderId: 'ORD-001',
productId: 'PROD-003',
productName: 'Pasta',
productVariantId: 'VAR-002',
productVariantName: '',
quantity: 1,
unitPrice: 50000,
totalPrice: 50000,
modifiers: [],
notes: '',
status: 'Served',
createdAt: DateTime.now().subtract(const Duration(hours: 1)),
updatedAt: DateTime.now(),
printerType: 'Kitchen',
printToChecker: false,
paidQuantity: 1,
categoryId: 'CAT-002',
categoryName: 'Makanan',
@@ -174,19 +197,19 @@ class Order with _$Order {
);
// For Add Item Order
factory Order.fromLastOrder(Order order, List<ProductQuantity> products) =>
order.copyWith(
orderItems: products
.map((e) => OrderItem.fromProductQuantity(e))
.toList(),
totalAmount: products
.map((e) => e.product.price.toInt() * e.quantity)
.reduce((value, element) => value + element),
subtotal: products
.map((e) => e.product.price.toInt() * e.quantity)
.reduce((value, element) => value + element),
metadata: order.metadata,
);
factory Order.fromLastOrder(Order order, List<ProductQuantity> products) {
final subtotal = products.fold<int>(
0,
(sum, item) => sum + item.lineTotal.toInt(),
);
return order.copyWith(
orderItems: products.map(OrderItem.fromProductQuantity).toList(),
totalAmount: subtotal,
subtotal: subtotal,
metadata: order.metadata,
);
}
List<OrderItem> get orderItemNotCancelled =>
orderItems.where((item) => item.status != 'cancelled').toList();
@@ -194,6 +217,8 @@ class Order with _$Order {
@freezed
class OrderItem with _$OrderItem {
const OrderItem._();
const factory OrderItem({
required String id,
required String orderId,
@@ -210,9 +235,16 @@ class OrderItem with _$OrderItem {
required DateTime createdAt,
required DateTime updatedAt,
required String printerType,
required bool printToChecker,
required int paidQuantity,
required String categoryId,
required String categoryName,
/// Angka timbangan baris ini. `null` untuk produk cacah.
double? weight,
@Default('') String unitId,
@Default('') String unitName,
@Default('') String unitAbbreviation,
}) = _OrderItem;
factory OrderItem.empty() => OrderItem(
@@ -231,6 +263,7 @@ class OrderItem with _$OrderItem {
createdAt: DateTime(1970),
updatedAt: DateTime(1970),
printerType: '',
printToChecker: false,
paidQuantity: 0,
categoryId: '',
categoryName: '',
@@ -244,18 +277,43 @@ class OrderItem with _$OrderItem {
productName: productQuantity.product.name,
productVariantId: productQuantity.variant?.id ?? '',
productVariantName: productQuantity.variant?.name ?? '',
quantity: productQuantity.quantity,
quantity: productQuantity.isWeighted ? 1 : productQuantity.quantity,
unitPrice: productQuantity.product.price.toInt(),
totalPrice: (productQuantity.product.price * productQuantity.quantity)
.toInt(),
totalPrice: productQuantity.lineTotal.toInt(),
modifiers: [],
notes: productQuantity.notes,
status: 'pending',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
printerType: productQuantity.product.printerType,
printToChecker: productQuantity.product.printToChecker,
paidQuantity: 0,
categoryId: '',
categoryName: '',
weight: productQuantity.weight,
unitId: productQuantity.product.unitId,
unitName: productQuantity.product.unitName,
unitAbbreviation: productQuantity.product.unitAbbreviation,
);
/// Baris hasil penimbangan — utuh, tidak bisa dipecah sebagian.
bool get isWeighted => weight != null && weight! > 0;
/// Singkatan satuan untuk ditampilkan; jatuh ke nama panjang bila kosong.
String get unitLabel =>
unitAbbreviation.isNotEmpty ? unitAbbreviation : unitName;
/// `4,2 ons` untuk baris berbobot, `2` untuk baris biasa.
///
/// Jangan menampilkan [quantity] mentah pada baris berbobot — nilainya selalu
/// 1 dan terbaca seperti "satu ons".
String get quantityLabel {
if (!isWeighted) return quantity.toString();
final value = weight!.weightFormat;
return unitLabel.isEmpty ? value : '$value $unitLabel';
}
/// `4,2 ons x Rp 4.500` atau `2 x Rp 25.000`.
String get quantityPriceLabel =>
'$quantityLabel x ${unitPrice.currencyFormatRpV2}';
}
@@ -33,6 +33,10 @@ class OrderItemRequest with _$OrderItemRequest {
required int quantity,
required int unitPrice,
required String notes,
/// Hanya diisi untuk produk `sell_by: "weight"`; produk cacah tidak boleh
/// mengirim berat sama sekali.
double? weight,
}) = _OrderItemRequest;
factory OrderItemRequest.empty() => const OrderItemRequest(
@@ -42,4 +46,16 @@ class OrderItemRequest with _$OrderItemRequest {
unitPrice: 0,
notes: '',
);
/// Bentuk baris dari isi keranjang: produk timbangan selalu `quantity: 1`
/// ditambah `weight`, produk cacah tetap seperti sebelumnya.
factory OrderItemRequest.fromProductQuantity(ProductQuantity item) =>
OrderItemRequest(
productId: item.product.id,
productVariantId: item.variant?.id ?? '',
quantity: item.isWeighted ? 1 : item.quantity,
unitPrice: item.product.price.toInt(),
notes: item.notes,
weight: item.weight,
);
}
+1
View File
@@ -3,6 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import '../../common/api/api_failure.dart';
import '../../common/types/split_type.dart';
import '../../common/extension/extension.dart';
import '../product/product.dart';
part 'order.freezed.dart';
+187 -11
View File
@@ -1002,10 +1002,17 @@ mixin _$OrderItem {
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
String get printerType => throw _privateConstructorUsedError;
bool get printToChecker => throw _privateConstructorUsedError;
int get paidQuantity => throw _privateConstructorUsedError;
String get categoryId => throw _privateConstructorUsedError;
String get categoryName => throw _privateConstructorUsedError;
/// Angka timbangan baris ini. `null` untuk produk cacah.
double? get weight => throw _privateConstructorUsedError;
String get unitId => throw _privateConstructorUsedError;
String get unitName => throw _privateConstructorUsedError;
String get unitAbbreviation => throw _privateConstructorUsedError;
/// Create a copy of OrderItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -1034,9 +1041,14 @@ abstract class $OrderItemCopyWith<$Res> {
DateTime createdAt,
DateTime updatedAt,
String printerType,
bool printToChecker,
int paidQuantity,
String categoryId,
String categoryName,
double? weight,
String unitId,
String unitName,
String unitAbbreviation,
});
}
@@ -1070,9 +1082,14 @@ class _$OrderItemCopyWithImpl<$Res, $Val extends OrderItem>
Object? createdAt = null,
Object? updatedAt = null,
Object? printerType = null,
Object? printToChecker = null,
Object? paidQuantity = null,
Object? categoryId = null,
Object? categoryName = null,
Object? weight = freezed,
Object? unitId = null,
Object? unitName = null,
Object? unitAbbreviation = null,
}) {
return _then(
_value.copyWith(
@@ -1136,6 +1153,10 @@ class _$OrderItemCopyWithImpl<$Res, $Val extends OrderItem>
? _value.printerType
: printerType // ignore: cast_nullable_to_non_nullable
as String,
printToChecker: null == printToChecker
? _value.printToChecker
: printToChecker // ignore: cast_nullable_to_non_nullable
as bool,
paidQuantity: null == paidQuantity
? _value.paidQuantity
: paidQuantity // ignore: cast_nullable_to_non_nullable
@@ -1148,6 +1169,22 @@ class _$OrderItemCopyWithImpl<$Res, $Val extends OrderItem>
? _value.categoryName
: categoryName // ignore: cast_nullable_to_non_nullable
as String,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
unitId: null == unitId
? _value.unitId
: unitId // ignore: cast_nullable_to_non_nullable
as String,
unitName: null == unitName
? _value.unitName
: unitName // ignore: cast_nullable_to_non_nullable
as String,
unitAbbreviation: null == unitAbbreviation
? _value.unitAbbreviation
: unitAbbreviation // ignore: cast_nullable_to_non_nullable
as String,
)
as $Val,
);
@@ -1179,9 +1216,14 @@ abstract class _$$OrderItemImplCopyWith<$Res>
DateTime createdAt,
DateTime updatedAt,
String printerType,
bool printToChecker,
int paidQuantity,
String categoryId,
String categoryName,
double? weight,
String unitId,
String unitName,
String unitAbbreviation,
});
}
@@ -1214,9 +1256,14 @@ class __$$OrderItemImplCopyWithImpl<$Res>
Object? createdAt = null,
Object? updatedAt = null,
Object? printerType = null,
Object? printToChecker = null,
Object? paidQuantity = null,
Object? categoryId = null,
Object? categoryName = null,
Object? weight = freezed,
Object? unitId = null,
Object? unitName = null,
Object? unitAbbreviation = null,
}) {
return _then(
_$OrderItemImpl(
@@ -1280,6 +1327,10 @@ class __$$OrderItemImplCopyWithImpl<$Res>
? _value.printerType
: printerType // ignore: cast_nullable_to_non_nullable
as String,
printToChecker: null == printToChecker
? _value.printToChecker
: printToChecker // ignore: cast_nullable_to_non_nullable
as bool,
paidQuantity: null == paidQuantity
? _value.paidQuantity
: paidQuantity // ignore: cast_nullable_to_non_nullable
@@ -1292,6 +1343,22 @@ class __$$OrderItemImplCopyWithImpl<$Res>
? _value.categoryName
: categoryName // ignore: cast_nullable_to_non_nullable
as String,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
unitId: null == unitId
? _value.unitId
: unitId // ignore: cast_nullable_to_non_nullable
as String,
unitName: null == unitName
? _value.unitName
: unitName // ignore: cast_nullable_to_non_nullable
as String,
unitAbbreviation: null == unitAbbreviation
? _value.unitAbbreviation
: unitAbbreviation // ignore: cast_nullable_to_non_nullable
as String,
),
);
}
@@ -1299,7 +1366,7 @@ class __$$OrderItemImplCopyWithImpl<$Res>
/// @nodoc
class _$OrderItemImpl implements _OrderItem {
class _$OrderItemImpl extends _OrderItem {
const _$OrderItemImpl({
required this.id,
required this.orderId,
@@ -1316,10 +1383,16 @@ class _$OrderItemImpl implements _OrderItem {
required this.createdAt,
required this.updatedAt,
required this.printerType,
required this.printToChecker,
required this.paidQuantity,
required this.categoryId,
required this.categoryName,
}) : _modifiers = modifiers;
this.weight,
this.unitId = '',
this.unitName = '',
this.unitAbbreviation = '',
}) : _modifiers = modifiers,
super._();
@override
final String id;
@@ -1358,15 +1431,30 @@ class _$OrderItemImpl implements _OrderItem {
@override
final String printerType;
@override
final bool printToChecker;
@override
final int paidQuantity;
@override
final String categoryId;
@override
final String categoryName;
/// Angka timbangan baris ini. `null` untuk produk cacah.
@override
final double? weight;
@override
@JsonKey()
final String unitId;
@override
@JsonKey()
final String unitName;
@override
@JsonKey()
final String unitAbbreviation;
@override
String toString() {
return 'OrderItem(id: $id, orderId: $orderId, productId: $productId, productName: $productName, productVariantId: $productVariantId, productVariantName: $productVariantName, quantity: $quantity, unitPrice: $unitPrice, totalPrice: $totalPrice, modifiers: $modifiers, notes: $notes, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, printerType: $printerType, paidQuantity: $paidQuantity, categoryId: $categoryId, categoryName: $categoryName)';
return 'OrderItem(id: $id, orderId: $orderId, productId: $productId, productName: $productName, productVariantId: $productVariantId, productVariantName: $productVariantName, quantity: $quantity, unitPrice: $unitPrice, totalPrice: $totalPrice, modifiers: $modifiers, notes: $notes, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, printerType: $printerType, printToChecker: $printToChecker, paidQuantity: $paidQuantity, categoryId: $categoryId, categoryName: $categoryName, weight: $weight, unitId: $unitId, unitName: $unitName, unitAbbreviation: $unitAbbreviation)';
}
@override
@@ -1402,16 +1490,24 @@ class _$OrderItemImpl implements _OrderItem {
other.updatedAt == updatedAt) &&
(identical(other.printerType, printerType) ||
other.printerType == printerType) &&
(identical(other.printToChecker, printToChecker) ||
other.printToChecker == printToChecker) &&
(identical(other.paidQuantity, paidQuantity) ||
other.paidQuantity == paidQuantity) &&
(identical(other.categoryId, categoryId) ||
other.categoryId == categoryId) &&
(identical(other.categoryName, categoryName) ||
other.categoryName == categoryName));
other.categoryName == categoryName) &&
(identical(other.weight, weight) || other.weight == weight) &&
(identical(other.unitId, unitId) || other.unitId == unitId) &&
(identical(other.unitName, unitName) ||
other.unitName == unitName) &&
(identical(other.unitAbbreviation, unitAbbreviation) ||
other.unitAbbreviation == unitAbbreviation));
}
@override
int get hashCode => Object.hash(
int get hashCode => Object.hashAll([
runtimeType,
id,
orderId,
@@ -1428,10 +1524,15 @@ class _$OrderItemImpl implements _OrderItem {
createdAt,
updatedAt,
printerType,
printToChecker,
paidQuantity,
categoryId,
categoryName,
);
weight,
unitId,
unitName,
unitAbbreviation,
]);
/// Create a copy of OrderItem
/// with the given fields replaced by the non-null parameter values.
@@ -1442,7 +1543,7 @@ class _$OrderItemImpl implements _OrderItem {
__$$OrderItemImplCopyWithImpl<_$OrderItemImpl>(this, _$identity);
}
abstract class _OrderItem implements OrderItem {
abstract class _OrderItem extends OrderItem {
const factory _OrderItem({
required final String id,
required final String orderId,
@@ -1459,10 +1560,16 @@ abstract class _OrderItem implements OrderItem {
required final DateTime createdAt,
required final DateTime updatedAt,
required final String printerType,
required final bool printToChecker,
required final int paidQuantity,
required final String categoryId,
required final String categoryName,
final double? weight,
final String unitId,
final String unitName,
final String unitAbbreviation,
}) = _$OrderItemImpl;
const _OrderItem._() : super._();
@override
String get id;
@@ -1495,12 +1602,24 @@ abstract class _OrderItem implements OrderItem {
@override
String get printerType;
@override
bool get printToChecker;
@override
int get paidQuantity;
@override
String get categoryId;
@override
String get categoryName;
/// Angka timbangan baris ini. `null` untuk produk cacah.
@override
double? get weight;
@override
String get unitId;
@override
String get unitName;
@override
String get unitAbbreviation;
/// Create a copy of OrderItem
/// with the given fields replaced by the non-null parameter values.
@override
@@ -1825,6 +1944,10 @@ mixin _$OrderItemRequest {
int get unitPrice => throw _privateConstructorUsedError;
String get notes => throw _privateConstructorUsedError;
/// Hanya diisi untuk produk `sell_by: "weight"`; produk cacah tidak boleh
/// mengirim berat sama sekali.
double? get weight => throw _privateConstructorUsedError;
/// Create a copy of OrderItemRequest
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -1845,6 +1968,7 @@ abstract class $OrderItemRequestCopyWith<$Res> {
int quantity,
int unitPrice,
String notes,
double? weight,
});
}
@@ -1868,6 +1992,7 @@ class _$OrderItemRequestCopyWithImpl<$Res, $Val extends OrderItemRequest>
Object? quantity = null,
Object? unitPrice = null,
Object? notes = null,
Object? weight = freezed,
}) {
return _then(
_value.copyWith(
@@ -1891,6 +2016,10 @@ class _$OrderItemRequestCopyWithImpl<$Res, $Val extends OrderItemRequest>
? _value.notes
: notes // ignore: cast_nullable_to_non_nullable
as String,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
)
as $Val,
);
@@ -1912,6 +2041,7 @@ abstract class _$$OrderItemRequestImplCopyWith<$Res>
int quantity,
int unitPrice,
String notes,
double? weight,
});
}
@@ -1934,6 +2064,7 @@ class __$$OrderItemRequestImplCopyWithImpl<$Res>
Object? quantity = null,
Object? unitPrice = null,
Object? notes = null,
Object? weight = freezed,
}) {
return _then(
_$OrderItemRequestImpl(
@@ -1957,6 +2088,10 @@ class __$$OrderItemRequestImplCopyWithImpl<$Res>
? _value.notes
: notes // ignore: cast_nullable_to_non_nullable
as String,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
),
);
}
@@ -1971,6 +2106,7 @@ class _$OrderItemRequestImpl implements _OrderItemRequest {
required this.quantity,
required this.unitPrice,
required this.notes,
this.weight,
});
@override
@@ -1984,9 +2120,14 @@ class _$OrderItemRequestImpl implements _OrderItemRequest {
@override
final String notes;
/// Hanya diisi untuk produk `sell_by: "weight"`; produk cacah tidak boleh
/// mengirim berat sama sekali.
@override
final double? weight;
@override
String toString() {
return 'OrderItemRequest(productId: $productId, productVariantId: $productVariantId, quantity: $quantity, unitPrice: $unitPrice, notes: $notes)';
return 'OrderItemRequest(productId: $productId, productVariantId: $productVariantId, quantity: $quantity, unitPrice: $unitPrice, notes: $notes, weight: $weight)';
}
@override
@@ -2002,7 +2143,8 @@ class _$OrderItemRequestImpl implements _OrderItemRequest {
other.quantity == quantity) &&
(identical(other.unitPrice, unitPrice) ||
other.unitPrice == unitPrice) &&
(identical(other.notes, notes) || other.notes == notes));
(identical(other.notes, notes) || other.notes == notes) &&
(identical(other.weight, weight) || other.weight == weight));
}
@override
@@ -2013,6 +2155,7 @@ class _$OrderItemRequestImpl implements _OrderItemRequest {
quantity,
unitPrice,
notes,
weight,
);
/// Create a copy of OrderItemRequest
@@ -2034,6 +2177,7 @@ abstract class _OrderItemRequest implements OrderItemRequest {
required final int quantity,
required final int unitPrice,
required final String notes,
final double? weight,
}) = _$OrderItemRequestImpl;
@override
@@ -2047,6 +2191,11 @@ abstract class _OrderItemRequest implements OrderItemRequest {
@override
String get notes;
/// Hanya diisi untuk produk `sell_by: "weight"`; produk cacah tidak boleh
/// mengirim berat sama sekali.
@override
double? get weight;
/// Create a copy of OrderItemRequest
/// with the given fields replaced by the non-null parameter values.
@override
@@ -3426,6 +3575,9 @@ mixin _$AddItemOrderRequest {
int get unitPrice => throw _privateConstructorUsedError;
String get notes => throw _privateConstructorUsedError;
/// Hanya diisi untuk produk `sell_by: "weight"`.
double? get weight => throw _privateConstructorUsedError;
/// Create a copy of AddItemOrderRequest
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -3446,6 +3598,7 @@ abstract class $AddItemOrderRequestCopyWith<$Res> {
int quantity,
int unitPrice,
String notes,
double? weight,
});
}
@@ -3469,6 +3622,7 @@ class _$AddItemOrderRequestCopyWithImpl<$Res, $Val extends AddItemOrderRequest>
Object? quantity = null,
Object? unitPrice = null,
Object? notes = null,
Object? weight = freezed,
}) {
return _then(
_value.copyWith(
@@ -3492,6 +3646,10 @@ class _$AddItemOrderRequestCopyWithImpl<$Res, $Val extends AddItemOrderRequest>
? _value.notes
: notes // ignore: cast_nullable_to_non_nullable
as String,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
)
as $Val,
);
@@ -3513,6 +3671,7 @@ abstract class _$$AddItemOrderRequestImplCopyWith<$Res>
int quantity,
int unitPrice,
String notes,
double? weight,
});
}
@@ -3535,6 +3694,7 @@ class __$$AddItemOrderRequestImplCopyWithImpl<$Res>
Object? quantity = null,
Object? unitPrice = null,
Object? notes = null,
Object? weight = freezed,
}) {
return _then(
_$AddItemOrderRequestImpl(
@@ -3558,6 +3718,10 @@ class __$$AddItemOrderRequestImplCopyWithImpl<$Res>
? _value.notes
: notes // ignore: cast_nullable_to_non_nullable
as String,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
),
);
}
@@ -3572,6 +3736,7 @@ class _$AddItemOrderRequestImpl implements _AddItemOrderRequest {
required this.quantity,
required this.unitPrice,
required this.notes,
this.weight,
});
@override
@@ -3585,9 +3750,13 @@ class _$AddItemOrderRequestImpl implements _AddItemOrderRequest {
@override
final String notes;
/// Hanya diisi untuk produk `sell_by: "weight"`.
@override
final double? weight;
@override
String toString() {
return 'AddItemOrderRequest(productId: $productId, productVariantId: $productVariantId, quantity: $quantity, unitPrice: $unitPrice, notes: $notes)';
return 'AddItemOrderRequest(productId: $productId, productVariantId: $productVariantId, quantity: $quantity, unitPrice: $unitPrice, notes: $notes, weight: $weight)';
}
@override
@@ -3603,7 +3772,8 @@ class _$AddItemOrderRequestImpl implements _AddItemOrderRequest {
other.quantity == quantity) &&
(identical(other.unitPrice, unitPrice) ||
other.unitPrice == unitPrice) &&
(identical(other.notes, notes) || other.notes == notes));
(identical(other.notes, notes) || other.notes == notes) &&
(identical(other.weight, weight) || other.weight == weight));
}
@override
@@ -3614,6 +3784,7 @@ class _$AddItemOrderRequestImpl implements _AddItemOrderRequest {
quantity,
unitPrice,
notes,
weight,
);
/// Create a copy of AddItemOrderRequest
@@ -3635,6 +3806,7 @@ abstract class _AddItemOrderRequest implements AddItemOrderRequest {
required final int quantity,
required final int unitPrice,
required final String notes,
final double? weight,
}) = _$AddItemOrderRequestImpl;
@override
@@ -3648,6 +3820,10 @@ abstract class _AddItemOrderRequest implements AddItemOrderRequest {
@override
String get notes;
/// Hanya diisi untuk produk `sell_by: "weight"`.
@override
double? get weight;
/// Create a copy of AddItemOrderRequest
/// with the given fields replaced by the non-null parameter values.
@override
@@ -24,10 +24,12 @@ abstract class IOrderRepository {
Future<Either<OrderFailure, Order>> addItemOrder({
required String id,
required List<AddItemOrderRequest> request,
String? idempotencyKey,
});
Future<Either<OrderFailure, Payment>> createPayment({
required PaymentRequest request,
String? idempotencyKey,
});
Future<Either<OrderFailure, Unit>> voidOrder({
@@ -35,15 +37,18 @@ abstract class IOrderRepository {
required String reason,
String type = "ITEM", // TYPE: ALL, ITEM
required List<OrderItem> orderItems,
String? idempotencyKey,
});
Future<Either<OrderFailure, Payment>> createSplitBill(
PaymentSplitBillRequest request,
);
PaymentSplitBillRequest request, {
String? idempotencyKey,
});
Future<Either<OrderFailure, Unit>> refundOrder({
required String id,
required String reason,
required int refundAmount,
String? idempotencyKey,
});
}
@@ -0,0 +1,83 @@
part of '../printer.dart';
/// Satu tujuan cetak dalam sekali proses print.
///
/// Perhatikan kasir, void, dan split bill sengaja menumpang printer `receipt`
/// — ketiganya tidak punya printer sendiri di halaman pengaturan.
enum PrintStation {
receipt,
cashier,
checker,
kitchen,
bar,
voided,
splitBill;
String get label => switch (this) {
PrintStation.receipt => 'Struk Customer',
PrintStation.cashier => 'Struk Kasir',
PrintStation.checker => 'Checker',
PrintStation.kitchen => 'Kitchen',
PrintStation.bar => 'Bar',
PrintStation.voided => 'Struk Void',
PrintStation.splitBill => 'Struk Split Bill',
};
/// Kode printer di tabel `printers`.
String get printerCode => switch (this) {
PrintStation.receipt ||
PrintStation.cashier ||
PrintStation.voided ||
PrintStation.splitBill => 'receipt',
PrintStation.checker => 'checker',
PrintStation.kitchen => 'kitchen',
PrintStation.bar => 'bar',
};
/// Station yang hanya mencetak item dengan `printerType` tertentu.
/// Station lain mencetak seluruh item order.
String? get itemPrinterType => switch (this) {
PrintStation.kitchen => 'kitchen',
PrintStation.bar => 'bar',
_ => null,
};
}
enum PrintStationStatus {
/// Belum dijalankan.
pending,
/// Sedang dikirim ke printer.
printing,
/// Berhasil dicetak.
success,
/// Gagal — lihat `message`.
failed,
/// Sengaja dilewati, misal tidak ada item untuk station tersebut.
/// Ini bukan kegagalan dan tidak perlu dicetak ulang.
skipped,
}
@freezed
class PrintStationResult with _$PrintStationResult {
const PrintStationResult._();
const factory PrintStationResult({
required PrintStation station,
required PrintStationStatus status,
String? message,
@Default(Duration.zero) Duration elapsed,
}) = _PrintStationResult;
factory PrintStationResult.pending(PrintStation station) =>
PrintStationResult(station: station, status: PrintStationStatus.pending);
bool get isSuccess => status == PrintStationStatus.success;
bool get isFailed => status == PrintStationStatus.failed;
bool get isSkipped => status == PrintStationStatus.skipped;
bool get isFinished => isSuccess || isFailed || isSkipped;
}
+1
View File
@@ -9,5 +9,6 @@ import '../order/order.dart';
part 'printer.freezed.dart';
part 'entities/printer_entity.dart';
part 'entities/print_station_result.dart';
part 'failures/printer_failure.dart';
part 'repositories/i_printer_repository.dart';
+210
View File
@@ -307,6 +307,216 @@ abstract class _Printer implements Printer {
throw _privateConstructorUsedError;
}
/// @nodoc
mixin _$PrintStationResult {
PrintStation get station => throw _privateConstructorUsedError;
PrintStationStatus get status => throw _privateConstructorUsedError;
String? get message => throw _privateConstructorUsedError;
Duration get elapsed => throw _privateConstructorUsedError;
/// Create a copy of PrintStationResult
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$PrintStationResultCopyWith<PrintStationResult> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $PrintStationResultCopyWith<$Res> {
factory $PrintStationResultCopyWith(
PrintStationResult value,
$Res Function(PrintStationResult) then,
) = _$PrintStationResultCopyWithImpl<$Res, PrintStationResult>;
@useResult
$Res call({
PrintStation station,
PrintStationStatus status,
String? message,
Duration elapsed,
});
}
/// @nodoc
class _$PrintStationResultCopyWithImpl<$Res, $Val extends PrintStationResult>
implements $PrintStationResultCopyWith<$Res> {
_$PrintStationResultCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of PrintStationResult
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? station = null,
Object? status = null,
Object? message = freezed,
Object? elapsed = null,
}) {
return _then(
_value.copyWith(
station: null == station
? _value.station
: station // ignore: cast_nullable_to_non_nullable
as PrintStation,
status: null == status
? _value.status
: status // ignore: cast_nullable_to_non_nullable
as PrintStationStatus,
message: freezed == message
? _value.message
: message // ignore: cast_nullable_to_non_nullable
as String?,
elapsed: null == elapsed
? _value.elapsed
: elapsed // ignore: cast_nullable_to_non_nullable
as Duration,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$PrintStationResultImplCopyWith<$Res>
implements $PrintStationResultCopyWith<$Res> {
factory _$$PrintStationResultImplCopyWith(
_$PrintStationResultImpl value,
$Res Function(_$PrintStationResultImpl) then,
) = __$$PrintStationResultImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
PrintStation station,
PrintStationStatus status,
String? message,
Duration elapsed,
});
}
/// @nodoc
class __$$PrintStationResultImplCopyWithImpl<$Res>
extends _$PrintStationResultCopyWithImpl<$Res, _$PrintStationResultImpl>
implements _$$PrintStationResultImplCopyWith<$Res> {
__$$PrintStationResultImplCopyWithImpl(
_$PrintStationResultImpl _value,
$Res Function(_$PrintStationResultImpl) _then,
) : super(_value, _then);
/// Create a copy of PrintStationResult
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? station = null,
Object? status = null,
Object? message = freezed,
Object? elapsed = null,
}) {
return _then(
_$PrintStationResultImpl(
station: null == station
? _value.station
: station // ignore: cast_nullable_to_non_nullable
as PrintStation,
status: null == status
? _value.status
: status // ignore: cast_nullable_to_non_nullable
as PrintStationStatus,
message: freezed == message
? _value.message
: message // ignore: cast_nullable_to_non_nullable
as String?,
elapsed: null == elapsed
? _value.elapsed
: elapsed // ignore: cast_nullable_to_non_nullable
as Duration,
),
);
}
}
/// @nodoc
class _$PrintStationResultImpl extends _PrintStationResult {
const _$PrintStationResultImpl({
required this.station,
required this.status,
this.message,
this.elapsed = Duration.zero,
}) : super._();
@override
final PrintStation station;
@override
final PrintStationStatus status;
@override
final String? message;
@override
@JsonKey()
final Duration elapsed;
@override
String toString() {
return 'PrintStationResult(station: $station, status: $status, message: $message, elapsed: $elapsed)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$PrintStationResultImpl &&
(identical(other.station, station) || other.station == station) &&
(identical(other.status, status) || other.status == status) &&
(identical(other.message, message) || other.message == message) &&
(identical(other.elapsed, elapsed) || other.elapsed == elapsed));
}
@override
int get hashCode =>
Object.hash(runtimeType, station, status, message, elapsed);
/// Create a copy of PrintStationResult
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$PrintStationResultImplCopyWith<_$PrintStationResultImpl> get copyWith =>
__$$PrintStationResultImplCopyWithImpl<_$PrintStationResultImpl>(
this,
_$identity,
);
}
abstract class _PrintStationResult extends PrintStationResult {
const factory _PrintStationResult({
required final PrintStation station,
required final PrintStationStatus status,
final String? message,
final Duration elapsed,
}) = _$PrintStationResultImpl;
const _PrintStationResult._() : super._();
@override
PrintStation get station;
@override
PrintStationStatus get status;
@override
String? get message;
@override
Duration get elapsed;
/// Create a copy of PrintStationResult
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$PrintStationResultImplCopyWith<_$PrintStationResultImpl> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
mixin _$PrinterFailure {
@optionalTypeArgs
@@ -15,25 +15,17 @@ abstract class IPrinterRepository {
List<int> printValue,
);
Future<Either<PrinterFailure, Unit>> printStruckOrder({required Order order});
Future<Either<PrinterFailure, Unit>> printStruckSaveOrder({
required Order order,
});
Future<Either<PrinterFailure, Unit>> printStruckCashier({
required Order order,
});
Future<Either<PrinterFailure, Unit>> printStruckPayment({
required Order order,
});
Future<Either<PrinterFailure, Unit>> printStruckChecker({
required Order order,
});
Future<Either<PrinterFailure, Unit>> printStruckKitchen({
required Order order,
});
Future<Either<PrinterFailure, Unit>> printStruckBar({required Order order});
Future<Either<PrinterFailure, Unit>> printStruckVoid({required Order order});
Future<Either<PrinterFailure, Unit>> printStruckSplitBill({
/// Mencetak [stations] untuk [order], melaporkan status tiap station.
///
/// Setiap emit berisi daftar lengkap semua station, jadi UI cukup
/// menggambar ulang apa adanya.
///
/// Satu station yang gagal **tidak** membatalkan station lainnya — semuanya
/// tetap dicoba. Station Bluetooth dijalankan berurutan karena radionya
/// hanya satu, station Network dijalankan paralel karena tidak berebut
/// sumber daya dengan Bluetooth.
Stream<List<PrintStationResult>> printStations({
required Order order,
required List<PrintStation> stations,
});
}
@@ -21,6 +21,8 @@ class ListProduct with _$ListProduct {
@freezed
class Product with _$Product {
const Product._();
const factory Product({
required String id,
required String organizationId,
@@ -33,11 +35,17 @@ class Product with _$Product {
required String businessType,
required String imageUrl,
required String printerType,
required bool printToChecker,
required Map<String, dynamic> metadata,
required bool isActive,
required String createdAt,
required String updatedAt,
required List<ProductVariant> variants,
/// `unit` (default) atau `weight`. Lihat [SellBy].
@Default(SellBy.unit) String sellBy,
@Default('') String unitId,
@Default('') String unitName,
@Default('') String unitAbbreviation,
}) = _Product;
factory Product.empty() => const Product(
@@ -52,12 +60,25 @@ class Product with _$Product {
businessType: '',
imageUrl: '',
printerType: '',
printToChecker: false,
metadata: {},
isActive: false,
createdAt: '',
updatedAt: '',
variants: [],
);
/// Produk yang dijual per timbangan: `quantity` selalu 1, `weight` wajib.
bool get isSoldByWeight => SellBy.isWeight(sellBy);
/// Singkatan satuan untuk ditampilkan — `ons`. Jatuh ke nama panjang bila
/// backend hanya mengirim itu.
String get unitLabel =>
unitAbbreviation.isNotEmpty ? unitAbbreviation : unitName;
/// Label harga mengikuti satuan: "Harga per ons".
String get priceUnitLabel =>
isSoldByWeight && unitLabel.isNotEmpty ? 'per $unitLabel' : '';
}
@freezed
@@ -1,14 +1,53 @@
part of '../product.dart';
/// Satu baris keranjang.
///
/// Untuk produk timbangan, **satu penimbangan = satu baris**: dua kali menimbang
/// Ikan Tude menghasilkan dua baris terpisah dengan [quantity] 1 dan [weight]
/// masing-masing. Karena itu baris dibedakan lewat [lineId], bukan lewat
/// kombinasi produk + varian.
@freezed
class ProductQuantity with _$ProductQuantity {
const ProductQuantity._();
const factory ProductQuantity({
required Product product,
ProductVariant? variant,
required int quantity,
required String notes,
@Default('') String lineId,
/// Angka timbangan dalam satuan produk. `null` untuk produk cacah.
double? weight,
}) = _ProductQuantity;
factory ProductQuantity.empty() =>
ProductQuantity(product: Product.empty(), quantity: 0, notes: '');
bool get isWeighted => weight != null;
double get unitPrice => product.price + (variant?.priceModifier ?? 0);
/// Harga baris: `weight × unit_price` untuk timbangan, `quantity × unit_price`
/// untuk produk cacah.
double get lineTotal =>
isWeighted ? unitPrice * weight! : unitPrice * quantity;
/// `4,2 ons` atau `2` — dipakai di kolom Qty keranjang.
String get quantityLabel {
if (!isWeighted) return quantity.toString();
final label = product.unitLabel;
final value = weight!.weightFormat;
return label.isEmpty ? value : '$value $label';
}
}
extension ProductQuantityListExt on List<ProductQuantity> {
/// Total keranjang: baris berbobot pakai `weight × harga`, baris cacah pakai
/// `quantity × harga`.
double get cartTotal => fold<double>(0, (sum, item) => sum + item.lineTotal);
/// Baris berbobot dihitung satu — satu penimbangan, satu barang.
int get cartItemCount =>
fold<int>(0, (sum, item) => sum + (item.isWeighted ? 1 : item.quantity));
}
+2
View File
@@ -3,6 +3,8 @@ import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../common/api/api_failure.dart';
import '../../common/extension/extension.dart';
import '../../common/types/sell_by.dart';
part 'product.freezed.dart';
+199 -18
View File
@@ -279,12 +279,19 @@ mixin _$Product {
String get businessType => throw _privateConstructorUsedError;
String get imageUrl => throw _privateConstructorUsedError;
String get printerType => throw _privateConstructorUsedError;
bool get printToChecker => throw _privateConstructorUsedError;
Map<String, dynamic> get metadata => throw _privateConstructorUsedError;
bool get isActive => throw _privateConstructorUsedError;
String get createdAt => throw _privateConstructorUsedError;
String get updatedAt => throw _privateConstructorUsedError;
List<ProductVariant> get variants => throw _privateConstructorUsedError;
/// `unit` (default) atau `weight`. Lihat [SellBy].
String get sellBy => throw _privateConstructorUsedError;
String get unitId => throw _privateConstructorUsedError;
String get unitName => throw _privateConstructorUsedError;
String get unitAbbreviation => throw _privateConstructorUsedError;
/// Create a copy of Product
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -308,11 +315,16 @@ abstract class $ProductCopyWith<$Res> {
String businessType,
String imageUrl,
String printerType,
bool printToChecker,
Map<String, dynamic> metadata,
bool isActive,
String createdAt,
String updatedAt,
List<ProductVariant> variants,
String sellBy,
String unitId,
String unitName,
String unitAbbreviation,
});
}
@@ -342,11 +354,16 @@ class _$ProductCopyWithImpl<$Res, $Val extends Product>
Object? businessType = null,
Object? imageUrl = null,
Object? printerType = null,
Object? printToChecker = null,
Object? metadata = null,
Object? isActive = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? variants = null,
Object? sellBy = null,
Object? unitId = null,
Object? unitName = null,
Object? unitAbbreviation = null,
}) {
return _then(
_value.copyWith(
@@ -394,6 +411,10 @@ class _$ProductCopyWithImpl<$Res, $Val extends Product>
? _value.printerType
: printerType // ignore: cast_nullable_to_non_nullable
as String,
printToChecker: null == printToChecker
? _value.printToChecker
: printToChecker // ignore: cast_nullable_to_non_nullable
as bool,
metadata: null == metadata
? _value.metadata
: metadata // ignore: cast_nullable_to_non_nullable
@@ -414,6 +435,22 @@ class _$ProductCopyWithImpl<$Res, $Val extends Product>
? _value.variants
: variants // ignore: cast_nullable_to_non_nullable
as List<ProductVariant>,
sellBy: null == sellBy
? _value.sellBy
: sellBy // ignore: cast_nullable_to_non_nullable
as String,
unitId: null == unitId
? _value.unitId
: unitId // ignore: cast_nullable_to_non_nullable
as String,
unitName: null == unitName
? _value.unitName
: unitName // ignore: cast_nullable_to_non_nullable
as String,
unitAbbreviation: null == unitAbbreviation
? _value.unitAbbreviation
: unitAbbreviation // ignore: cast_nullable_to_non_nullable
as String,
)
as $Val,
);
@@ -440,11 +477,16 @@ abstract class _$$ProductImplCopyWith<$Res> implements $ProductCopyWith<$Res> {
String businessType,
String imageUrl,
String printerType,
bool printToChecker,
Map<String, dynamic> metadata,
bool isActive,
String createdAt,
String updatedAt,
List<ProductVariant> variants,
String sellBy,
String unitId,
String unitName,
String unitAbbreviation,
});
}
@@ -473,11 +515,16 @@ class __$$ProductImplCopyWithImpl<$Res>
Object? businessType = null,
Object? imageUrl = null,
Object? printerType = null,
Object? printToChecker = null,
Object? metadata = null,
Object? isActive = null,
Object? createdAt = null,
Object? updatedAt = null,
Object? variants = null,
Object? sellBy = null,
Object? unitId = null,
Object? unitName = null,
Object? unitAbbreviation = null,
}) {
return _then(
_$ProductImpl(
@@ -525,6 +572,10 @@ class __$$ProductImplCopyWithImpl<$Res>
? _value.printerType
: printerType // ignore: cast_nullable_to_non_nullable
as String,
printToChecker: null == printToChecker
? _value.printToChecker
: printToChecker // ignore: cast_nullable_to_non_nullable
as bool,
metadata: null == metadata
? _value._metadata
: metadata // ignore: cast_nullable_to_non_nullable
@@ -545,6 +596,22 @@ class __$$ProductImplCopyWithImpl<$Res>
? _value._variants
: variants // ignore: cast_nullable_to_non_nullable
as List<ProductVariant>,
sellBy: null == sellBy
? _value.sellBy
: sellBy // ignore: cast_nullable_to_non_nullable
as String,
unitId: null == unitId
? _value.unitId
: unitId // ignore: cast_nullable_to_non_nullable
as String,
unitName: null == unitName
? _value.unitName
: unitName // ignore: cast_nullable_to_non_nullable
as String,
unitAbbreviation: null == unitAbbreviation
? _value.unitAbbreviation
: unitAbbreviation // ignore: cast_nullable_to_non_nullable
as String,
),
);
}
@@ -552,7 +619,7 @@ class __$$ProductImplCopyWithImpl<$Res>
/// @nodoc
class _$ProductImpl with DiagnosticableTreeMixin implements _Product {
class _$ProductImpl extends _Product with DiagnosticableTreeMixin {
const _$ProductImpl({
required this.id,
required this.organizationId,
@@ -565,13 +632,19 @@ class _$ProductImpl with DiagnosticableTreeMixin implements _Product {
required this.businessType,
required this.imageUrl,
required this.printerType,
required this.printToChecker,
required final Map<String, dynamic> metadata,
required this.isActive,
required this.createdAt,
required this.updatedAt,
required final List<ProductVariant> variants,
this.sellBy = SellBy.unit,
this.unitId = '',
this.unitName = '',
this.unitAbbreviation = '',
}) : _metadata = metadata,
_variants = variants;
_variants = variants,
super._();
@override
final String id;
@@ -595,6 +668,8 @@ class _$ProductImpl with DiagnosticableTreeMixin implements _Product {
final String imageUrl;
@override
final String printerType;
@override
final bool printToChecker;
final Map<String, dynamic> _metadata;
@override
Map<String, dynamic> get metadata {
@@ -617,9 +692,23 @@ class _$ProductImpl with DiagnosticableTreeMixin implements _Product {
return EqualUnmodifiableListView(_variants);
}
/// `unit` (default) atau `weight`. Lihat [SellBy].
@override
@JsonKey()
final String sellBy;
@override
@JsonKey()
final String unitId;
@override
@JsonKey()
final String unitName;
@override
@JsonKey()
final String unitAbbreviation;
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'Product(id: $id, organizationId: $organizationId, categoryId: $categoryId, sku: $sku, name: $name, description: $description, price: $price, cost: $cost, businessType: $businessType, imageUrl: $imageUrl, printerType: $printerType, metadata: $metadata, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt, variants: $variants)';
return 'Product(id: $id, organizationId: $organizationId, categoryId: $categoryId, sku: $sku, name: $name, description: $description, price: $price, cost: $cost, businessType: $businessType, imageUrl: $imageUrl, printerType: $printerType, printToChecker: $printToChecker, metadata: $metadata, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt, variants: $variants, sellBy: $sellBy, unitId: $unitId, unitName: $unitName, unitAbbreviation: $unitAbbreviation)';
}
@override
@@ -638,11 +727,16 @@ class _$ProductImpl with DiagnosticableTreeMixin implements _Product {
..add(DiagnosticsProperty('businessType', businessType))
..add(DiagnosticsProperty('imageUrl', imageUrl))
..add(DiagnosticsProperty('printerType', printerType))
..add(DiagnosticsProperty('printToChecker', printToChecker))
..add(DiagnosticsProperty('metadata', metadata))
..add(DiagnosticsProperty('isActive', isActive))
..add(DiagnosticsProperty('createdAt', createdAt))
..add(DiagnosticsProperty('updatedAt', updatedAt))
..add(DiagnosticsProperty('variants', variants));
..add(DiagnosticsProperty('variants', variants))
..add(DiagnosticsProperty('sellBy', sellBy))
..add(DiagnosticsProperty('unitId', unitId))
..add(DiagnosticsProperty('unitName', unitName))
..add(DiagnosticsProperty('unitAbbreviation', unitAbbreviation));
}
@override
@@ -667,6 +761,8 @@ class _$ProductImpl with DiagnosticableTreeMixin implements _Product {
other.imageUrl == imageUrl) &&
(identical(other.printerType, printerType) ||
other.printerType == printerType) &&
(identical(other.printToChecker, printToChecker) ||
other.printToChecker == printToChecker) &&
const DeepCollectionEquality().equals(other._metadata, _metadata) &&
(identical(other.isActive, isActive) ||
other.isActive == isActive) &&
@@ -674,11 +770,17 @@ class _$ProductImpl with DiagnosticableTreeMixin implements _Product {
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
const DeepCollectionEquality().equals(other._variants, _variants));
const DeepCollectionEquality().equals(other._variants, _variants) &&
(identical(other.sellBy, sellBy) || other.sellBy == sellBy) &&
(identical(other.unitId, unitId) || other.unitId == unitId) &&
(identical(other.unitName, unitName) ||
other.unitName == unitName) &&
(identical(other.unitAbbreviation, unitAbbreviation) ||
other.unitAbbreviation == unitAbbreviation));
}
@override
int get hashCode => Object.hash(
int get hashCode => Object.hashAll([
runtimeType,
id,
organizationId,
@@ -691,12 +793,17 @@ class _$ProductImpl with DiagnosticableTreeMixin implements _Product {
businessType,
imageUrl,
printerType,
printToChecker,
const DeepCollectionEquality().hash(_metadata),
isActive,
createdAt,
updatedAt,
const DeepCollectionEquality().hash(_variants),
);
sellBy,
unitId,
unitName,
unitAbbreviation,
]);
/// Create a copy of Product
/// with the given fields replaced by the non-null parameter values.
@@ -707,7 +814,7 @@ class _$ProductImpl with DiagnosticableTreeMixin implements _Product {
__$$ProductImplCopyWithImpl<_$ProductImpl>(this, _$identity);
}
abstract class _Product implements Product {
abstract class _Product extends Product {
const factory _Product({
required final String id,
required final String organizationId,
@@ -720,12 +827,18 @@ abstract class _Product implements Product {
required final String businessType,
required final String imageUrl,
required final String printerType,
required final bool printToChecker,
required final Map<String, dynamic> metadata,
required final bool isActive,
required final String createdAt,
required final String updatedAt,
required final List<ProductVariant> variants,
final String sellBy,
final String unitId,
final String unitName,
final String unitAbbreviation,
}) = _$ProductImpl;
const _Product._() : super._();
@override
String get id;
@@ -750,6 +863,8 @@ abstract class _Product implements Product {
@override
String get printerType;
@override
bool get printToChecker;
@override
Map<String, dynamic> get metadata;
@override
bool get isActive;
@@ -760,6 +875,16 @@ abstract class _Product implements Product {
@override
List<ProductVariant> get variants;
/// `unit` (default) atau `weight`. Lihat [SellBy].
@override
String get sellBy;
@override
String get unitId;
@override
String get unitName;
@override
String get unitAbbreviation;
/// Create a copy of Product
/// with the given fields replaced by the non-null parameter values.
@override
@@ -1098,6 +1223,10 @@ mixin _$ProductQuantity {
ProductVariant? get variant => throw _privateConstructorUsedError;
int get quantity => throw _privateConstructorUsedError;
String get notes => throw _privateConstructorUsedError;
String get lineId => throw _privateConstructorUsedError;
/// Angka timbangan dalam satuan produk. `null` untuk produk cacah.
double? get weight => throw _privateConstructorUsedError;
/// Create a copy of ProductQuantity
/// with the given fields replaced by the non-null parameter values.
@@ -1118,6 +1247,8 @@ abstract class $ProductQuantityCopyWith<$Res> {
ProductVariant? variant,
int quantity,
String notes,
String lineId,
double? weight,
});
$ProductCopyWith<$Res> get product;
@@ -1143,6 +1274,8 @@ class _$ProductQuantityCopyWithImpl<$Res, $Val extends ProductQuantity>
Object? variant = freezed,
Object? quantity = null,
Object? notes = null,
Object? lineId = null,
Object? weight = freezed,
}) {
return _then(
_value.copyWith(
@@ -1162,6 +1295,14 @@ class _$ProductQuantityCopyWithImpl<$Res, $Val extends ProductQuantity>
? _value.notes
: notes // ignore: cast_nullable_to_non_nullable
as String,
lineId: null == lineId
? _value.lineId
: lineId // ignore: cast_nullable_to_non_nullable
as String,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
)
as $Val,
);
@@ -1206,6 +1347,8 @@ abstract class _$$ProductQuantityImplCopyWith<$Res>
ProductVariant? variant,
int quantity,
String notes,
String lineId,
double? weight,
});
@override
@@ -1232,6 +1375,8 @@ class __$$ProductQuantityImplCopyWithImpl<$Res>
Object? variant = freezed,
Object? quantity = null,
Object? notes = null,
Object? lineId = null,
Object? weight = freezed,
}) {
return _then(
_$ProductQuantityImpl(
@@ -1251,6 +1396,14 @@ class __$$ProductQuantityImplCopyWithImpl<$Res>
? _value.notes
: notes // ignore: cast_nullable_to_non_nullable
as String,
lineId: null == lineId
? _value.lineId
: lineId // ignore: cast_nullable_to_non_nullable
as String,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
),
);
}
@@ -1258,15 +1411,16 @@ class __$$ProductQuantityImplCopyWithImpl<$Res>
/// @nodoc
class _$ProductQuantityImpl
with DiagnosticableTreeMixin
implements _ProductQuantity {
class _$ProductQuantityImpl extends _ProductQuantity
with DiagnosticableTreeMixin {
const _$ProductQuantityImpl({
required this.product,
this.variant,
required this.quantity,
required this.notes,
});
this.lineId = '',
this.weight,
}) : super._();
@override
final Product product;
@@ -1276,10 +1430,17 @@ class _$ProductQuantityImpl
final int quantity;
@override
final String notes;
@override
@JsonKey()
final String lineId;
/// Angka timbangan dalam satuan produk. `null` untuk produk cacah.
@override
final double? weight;
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'ProductQuantity(product: $product, variant: $variant, quantity: $quantity, notes: $notes)';
return 'ProductQuantity(product: $product, variant: $variant, quantity: $quantity, notes: $notes, lineId: $lineId, weight: $weight)';
}
@override
@@ -1290,7 +1451,9 @@ class _$ProductQuantityImpl
..add(DiagnosticsProperty('product', product))
..add(DiagnosticsProperty('variant', variant))
..add(DiagnosticsProperty('quantity', quantity))
..add(DiagnosticsProperty('notes', notes));
..add(DiagnosticsProperty('notes', notes))
..add(DiagnosticsProperty('lineId', lineId))
..add(DiagnosticsProperty('weight', weight));
}
@override
@@ -1302,12 +1465,21 @@ class _$ProductQuantityImpl
(identical(other.variant, variant) || other.variant == variant) &&
(identical(other.quantity, quantity) ||
other.quantity == quantity) &&
(identical(other.notes, notes) || other.notes == notes));
(identical(other.notes, notes) || other.notes == notes) &&
(identical(other.lineId, lineId) || other.lineId == lineId) &&
(identical(other.weight, weight) || other.weight == weight));
}
@override
int get hashCode =>
Object.hash(runtimeType, product, variant, quantity, notes);
int get hashCode => Object.hash(
runtimeType,
product,
variant,
quantity,
notes,
lineId,
weight,
);
/// Create a copy of ProductQuantity
/// with the given fields replaced by the non-null parameter values.
@@ -1321,13 +1493,16 @@ class _$ProductQuantityImpl
);
}
abstract class _ProductQuantity implements ProductQuantity {
abstract class _ProductQuantity extends ProductQuantity {
const factory _ProductQuantity({
required final Product product,
final ProductVariant? variant,
required final int quantity,
required final String notes,
final String lineId,
final double? weight,
}) = _$ProductQuantityImpl;
const _ProductQuantity._() : super._();
@override
Product get product;
@@ -1337,6 +1512,12 @@ abstract class _ProductQuantity implements ProductQuantity {
int get quantity;
@override
String get notes;
@override
String get lineId;
/// Angka timbangan dalam satuan produk. `null` untuk produk cacah.
@override
double? get weight;
/// Create a copy of ProductQuantity
/// with the given fields replaced by the non-null parameter values.
+23
View File
@@ -0,0 +1,23 @@
part of '../unit.dart';
/// Satuan timbangan milik organisasi — ons, kg, gram.
///
/// [abbreviation] yang dipakai POS untuk mencetak `4,2 ons` di struk.
@freezed
class ProductUnit with _$ProductUnit {
const factory ProductUnit({
required String id,
required String organizationId,
required String name,
required String abbreviation,
required bool isActive,
}) = _ProductUnit;
factory ProductUnit.empty() => const ProductUnit(
id: '',
organizationId: '',
name: '',
abbreviation: '',
isActive: false,
);
}
@@ -0,0 +1,12 @@
part of '../unit.dart';
@freezed
sealed class UnitFailure with _$UnitFailure {
const factory UnitFailure.serverError(ApiFailure failure) = _ServerError;
const factory UnitFailure.unexpectedError() = _UnexpectedError;
const factory UnitFailure.empty() = _Empty;
const factory UnitFailure.localStorageError(String erroMessage) =
_LocalStorageError;
const factory UnitFailure.dynamicErrorMessage(String erroMessage) =
_DynamicErrorMessage;
}
@@ -0,0 +1,12 @@
part of '../unit.dart';
abstract class IUnitRepository {
/// Ambil satuan dari server lalu simpan ke cache lokal.
/// Bila server tidak terjangkau, kembalikan isi cache supaya POS tetap bisa
/// mencetak `4,2 ons` saat offline.
Future<Either<UnitFailure, List<ProductUnit>>> getUnits({
bool forceRefresh = false,
});
Future<List<ProductUnit>> cachedUnits();
}
+10
View File
@@ -0,0 +1,10 @@
import 'package:dartz/dartz.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../common/api/api_failure.dart';
part 'unit.freezed.dart';
part 'entities/unit_entity.dart';
part 'failures/unit_failure.dart';
part 'repositories/i_unit_repository.dart';
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -10,18 +10,18 @@ abstract class Env {
@dev
class DevEnv implements Env {
@override
String get baseUrl => 'https://api-pos.apskel.id';
String get baseUrl => 'https://enaklo-pos-api.altru.id';
@override
String get dbName => "apskel_pos_dev.db"; // example value
String get dbName => "apskel_pos_staging.db";
}
@Injectable(as: Env)
@prod
class ProdEnv implements Env {
@override
String get baseUrl => 'https://api-pos.apskel.id';
String get baseUrl => 'https://enaklo-pos-api.altru.id';
@override
String get dbName => "apskel_pos_dev.db";
String get dbName => "apskel_pos_prod.db";
}
@@ -18,6 +18,15 @@ class OrderRemoteDataProvider {
final _logName = 'OrderRemoteDataProvider';
OrderRemoteDataProvider(this._apiClient);
/// Helper untuk merge auth header dengan idempotency key
Map<String, dynamic> _buildHeaders({String? idempotencyKey}) {
final headers = getAuthorizationHeader();
if (idempotencyKey != null) {
headers['X-Idempotency-Key'] = idempotencyKey;
}
return headers;
}
Future<DC<OrderFailure, ListOrderDto>> fetchOrders({
int page = 1,
int limit = 10,
@@ -46,6 +55,12 @@ class OrderRemoteDataProvider {
);
if (response.data['success'] == false) {
// Pesan validasi backend menyebut nama produk, jadi diteruskan apa
// adanya ke kasir alih-alih diganti pesan generik.
final cause = extractApiErrorCause(response.data);
if (cause != null) {
return DC.error(OrderFailure.dynamicErrorMessage(cause));
}
return DC.error(const OrderFailure.unexpectedError());
}
@@ -68,6 +83,12 @@ class OrderRemoteDataProvider {
);
if (response.data['success'] == false) {
// Pesan validasi backend menyebut nama produk, jadi diteruskan apa
// adanya ke kasir alih-alih diganti pesan generik.
final cause = extractApiErrorCause(response.data);
if (cause != null) {
return DC.error(OrderFailure.dynamicErrorMessage(cause));
}
return DC.error(const OrderFailure.unexpectedError());
}
@@ -93,6 +114,12 @@ class OrderRemoteDataProvider {
);
if (response.data['success'] == false) {
// Pesan validasi backend menyebut nama produk, jadi diteruskan apa
// adanya ke kasir alih-alih diganti pesan generik.
final cause = extractApiErrorCause(response.data);
if (cause != null) {
return DC.error(OrderFailure.dynamicErrorMessage(cause));
}
return DC.error(const OrderFailure.unexpectedError());
}
@@ -119,6 +146,12 @@ class OrderRemoteDataProvider {
);
if (response.data['success'] == false) {
// Pesan validasi backend menyebut nama produk, jadi diteruskan apa
// adanya ke kasir alih-alih diganti pesan generik.
final cause = extractApiErrorCause(response.data);
if (cause != null) {
return DC.error(OrderFailure.dynamicErrorMessage(cause));
}
return DC.error(const OrderFailure.unexpectedError());
} else {
final order = OrderDto.fromJson(
@@ -151,16 +184,23 @@ class OrderRemoteDataProvider {
}
Future<DC<OrderFailure, PaymentDto>> storePayment(
PaymentRequestDto request,
) async {
PaymentRequestDto request, {
String? idempotencyKey,
}) async {
try {
final response = await _apiClient.post(
ApiPath.payments,
data: request.toJson(),
headers: getAuthorizationHeader(),
headers: _buildHeaders(idempotencyKey: idempotencyKey),
);
if (response.data['success'] == false) {
// Pesan validasi backend menyebut nama produk, jadi diteruskan apa
// adanya ke kasir alih-alih diganti pesan generik.
final cause = extractApiErrorCause(response.data);
if (cause != null) {
return DC.error(OrderFailure.dynamicErrorMessage(cause));
}
return DC.error(const OrderFailure.unexpectedError());
}
@@ -178,6 +218,7 @@ class OrderRemoteDataProvider {
Future<DC<OrderFailure, OrderDto>> addItemOrder({
required String id,
required List<AddItemOrderRequestDto> request,
String? idempotencyKey,
}) async {
try {
final response = await _apiClient.post(
@@ -186,10 +227,16 @@ class OrderRemoteDataProvider {
'notes': '',
'order_items': request.map((e) => e.toRequest()).toList(),
},
headers: getAuthorizationHeader(),
headers: _buildHeaders(idempotencyKey: idempotencyKey),
);
if (response.data['success'] == false) {
// Pesan validasi backend menyebut nama produk, jadi diteruskan apa
// adanya ke kasir alih-alih diganti pesan generik.
final cause = extractApiErrorCause(response.data);
if (cause != null) {
return DC.error(OrderFailure.dynamicErrorMessage(cause));
}
return DC.error(const OrderFailure.unexpectedError());
}
@@ -209,6 +256,7 @@ class OrderRemoteDataProvider {
required String reason,
String type = "ITEM", // TYPE: ALL, ITEM
required List<OrderItemDto> orderItems,
String? idempotencyKey,
}) async {
try {
final response = await _apiClient.post(
@@ -223,10 +271,16 @@ class OrderRemoteDataProvider {
)
.toList(),
},
headers: getAuthorizationHeader(),
headers: _buildHeaders(idempotencyKey: idempotencyKey),
);
if (response.data['success'] == false) {
// Pesan validasi backend menyebut nama produk, jadi diteruskan apa
// adanya ke kasir alih-alih diganti pesan generik.
final cause = extractApiErrorCause(response.data);
if (cause != null) {
return DC.error(OrderFailure.dynamicErrorMessage(cause));
}
return DC.error(const OrderFailure.unexpectedError());
}
@@ -238,17 +292,24 @@ class OrderRemoteDataProvider {
}
Future<DC<OrderFailure, PaymentDto>> createSplitBill(
PaymentSplitBillRequestDto request,
) async {
PaymentSplitBillRequestDto request, {
String? idempotencyKey,
}) async {
log(request.toRequest().toString());
try {
final response = await _apiClient.post(
"${ApiPath.orders}/split-bill",
data: request.toRequest(),
headers: getAuthorizationHeader(),
headers: _buildHeaders(idempotencyKey: idempotencyKey),
);
if (response.data['success'] == false) {
// Pesan validasi backend menyebut nama produk, jadi diteruskan apa
// adanya ke kasir alih-alih diganti pesan generik.
final cause = extractApiErrorCause(response.data);
if (cause != null) {
return DC.error(OrderFailure.dynamicErrorMessage(cause));
}
return DC.error(const OrderFailure.unexpectedError());
}
@@ -267,15 +328,22 @@ class OrderRemoteDataProvider {
required String id,
required String reason,
required int refundAmount,
String? idempotencyKey,
}) async {
try {
final response = await _apiClient.post(
'${ApiPath.orders}/$id/refund',
data: {'refund_amount': refundAmount, 'reason': reason},
headers: getAuthorizationHeader(),
headers: _buildHeaders(idempotencyKey: idempotencyKey),
);
if (response.data['success'] == false) {
// Pesan validasi backend menyebut nama produk, jadi diteruskan apa
// adanya ke kasir alih-alih diganti pesan generik.
final cause = extractApiErrorCause(response.data);
if (cause != null) {
return DC.error(OrderFailure.dynamicErrorMessage(cause));
}
return DC.error(const OrderFailure.unexpectedError());
}
@@ -10,6 +10,7 @@ class AddItemOrderRequestDto with _$AddItemOrderRequestDto {
@JsonKey(name: 'quantity') int? quantity,
@JsonKey(name: 'unit_price') int? unitPrice,
@JsonKey(name: 'notes') String? notes,
@JsonKey(name: 'weight') double? weight,
}) = _AddItemOrderRequestDto;
factory AddItemOrderRequestDto.fromJson(Map<String, dynamic> json) =>
@@ -21,6 +22,7 @@ class AddItemOrderRequestDto with _$AddItemOrderRequestDto {
quantity: quantity ?? 0,
unitPrice: unitPrice ?? 0,
notes: notes ?? '',
weight: weight,
);
factory AddItemOrderRequestDto.fromDomain(
@@ -31,6 +33,7 @@ class AddItemOrderRequestDto with _$AddItemOrderRequestDto {
quantity: addItemOrderRequest.quantity,
unitPrice: addItemOrderRequest.unitPrice,
notes: addItemOrderRequest.notes,
weight: addItemOrderRequest.weight,
);
Map<String, dynamic> toRequest() {
@@ -45,6 +48,11 @@ class AddItemOrderRequestDto with _$AddItemOrderRequestDto {
data['product_variant_id'] = productvariantId;
}
// Produk satuan ditolak backend bila membawa berat.
if (weight != null) {
data['weight'] = weight;
}
return data;
}
}
@@ -114,9 +114,14 @@ class OrderItemDto with _$OrderItemDto {
@JsonKey(name: "created_at") String? createdAt,
@JsonKey(name: "updated_at") String? updatedAt,
@JsonKey(name: "printer_type") String? printerType,
@JsonKey(name: "print_to_checker") bool? printToChecker,
@JsonKey(name: "paid_quantity") int? paidQuantity,
@JsonKey(name: "category_id") String? categoryId,
@JsonKey(name: "category_name") String? categoryName,
@JsonKey(name: "weight") double? weight,
@JsonKey(name: "unit_id") String? unitId,
@JsonKey(name: "unit_name") String? unitName,
@JsonKey(name: "unit_abbreviation") String? unitAbbreviation,
}) = _OrderItemDto;
factory OrderItemDto.fromJson(Map<String, dynamic> json) =>
@@ -139,9 +144,14 @@ class OrderItemDto with _$OrderItemDto {
createdAt: createdAt != null ? DateTime.parse(createdAt!) : DateTime(1970),
updatedAt: updatedAt != null ? DateTime.parse(updatedAt!) : DateTime(1970),
printerType: printerType ?? '',
printToChecker: printToChecker ?? false,
paidQuantity: paidQuantity ?? 0,
categoryId: categoryId ?? '',
categoryName: categoryName ?? '',
weight: weight,
unitId: unitId ?? '',
unitName: unitName ?? '',
unitAbbreviation: unitAbbreviation ?? '',
);
factory OrderItemDto.fromDomain(OrderItem orderItem) => OrderItemDto(
@@ -160,8 +170,13 @@ class OrderItemDto with _$OrderItemDto {
createdAt: orderItem.createdAt.toIso8601String(),
updatedAt: orderItem.updatedAt.toIso8601String(),
printerType: orderItem.printerType,
printToChecker: orderItem.printToChecker,
paidQuantity: orderItem.paidQuantity,
categoryId: orderItem.categoryId,
categoryName: orderItem.categoryName,
weight: orderItem.weight,
unitId: orderItem.unitId,
unitName: orderItem.unitName,
unitAbbreviation: orderItem.unitAbbreviation,
);
}
@@ -75,6 +75,7 @@ class OrderItemRequestDto with _$OrderItemRequestDto {
@JsonKey(name: "quantity") int? quantity,
@JsonKey(name: "unit_price") int? unitPrice,
@JsonKey(name: "notes") String? notes,
@JsonKey(name: "weight") double? weight,
}) = _OrderItemRequestDto;
factory OrderItemRequestDto.fromJson(Map<String, dynamic> json) =>
@@ -87,6 +88,7 @@ class OrderItemRequestDto with _$OrderItemRequestDto {
quantity: quantity ?? 0,
unitPrice: unitPrice ?? 0,
notes: notes ?? '',
weight: weight,
);
factory OrderItemRequestDto.fromDomain(OrderItemRequest request) =>
@@ -96,6 +98,7 @@ class OrderItemRequestDto with _$OrderItemRequestDto {
quantity: request.quantity,
unitPrice: request.unitPrice,
notes: request.notes,
weight: request.weight,
);
Map<String, dynamic> toRequest() {
@@ -110,6 +113,12 @@ class OrderItemRequestDto with _$OrderItemRequestDto {
data["product_variant_id"] = productVariantId;
}
// Produk satuan ditolak backend bila membawa berat, jadi field ini hanya
// ikut saat memang ada hasil timbangan.
if (weight != null) {
data["weight"] = weight;
}
return data;
}
}
@@ -1169,12 +1169,22 @@ mixin _$OrderItemDto {
String? get updatedAt => throw _privateConstructorUsedError;
@JsonKey(name: "printer_type")
String? get printerType => throw _privateConstructorUsedError;
@JsonKey(name: "print_to_checker")
bool? get printToChecker => throw _privateConstructorUsedError;
@JsonKey(name: "paid_quantity")
int? get paidQuantity => throw _privateConstructorUsedError;
@JsonKey(name: "category_id")
String? get categoryId => throw _privateConstructorUsedError;
@JsonKey(name: "category_name")
String? get categoryName => throw _privateConstructorUsedError;
@JsonKey(name: "weight")
double? get weight => throw _privateConstructorUsedError;
@JsonKey(name: "unit_id")
String? get unitId => throw _privateConstructorUsedError;
@JsonKey(name: "unit_name")
String? get unitName => throw _privateConstructorUsedError;
@JsonKey(name: "unit_abbreviation")
String? get unitAbbreviation => throw _privateConstructorUsedError;
/// Serializes this OrderItemDto to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@@ -1209,9 +1219,14 @@ abstract class $OrderItemDtoCopyWith<$Res> {
@JsonKey(name: "created_at") String? createdAt,
@JsonKey(name: "updated_at") String? updatedAt,
@JsonKey(name: "printer_type") String? printerType,
@JsonKey(name: "print_to_checker") bool? printToChecker,
@JsonKey(name: "paid_quantity") int? paidQuantity,
@JsonKey(name: "category_id") String? categoryId,
@JsonKey(name: "category_name") String? categoryName,
@JsonKey(name: "weight") double? weight,
@JsonKey(name: "unit_id") String? unitId,
@JsonKey(name: "unit_name") String? unitName,
@JsonKey(name: "unit_abbreviation") String? unitAbbreviation,
});
}
@@ -1245,9 +1260,14 @@ class _$OrderItemDtoCopyWithImpl<$Res, $Val extends OrderItemDto>
Object? createdAt = freezed,
Object? updatedAt = freezed,
Object? printerType = freezed,
Object? printToChecker = freezed,
Object? paidQuantity = freezed,
Object? categoryId = freezed,
Object? categoryName = freezed,
Object? weight = freezed,
Object? unitId = freezed,
Object? unitName = freezed,
Object? unitAbbreviation = freezed,
}) {
return _then(
_value.copyWith(
@@ -1311,6 +1331,10 @@ class _$OrderItemDtoCopyWithImpl<$Res, $Val extends OrderItemDto>
? _value.printerType
: printerType // ignore: cast_nullable_to_non_nullable
as String?,
printToChecker: freezed == printToChecker
? _value.printToChecker
: printToChecker // ignore: cast_nullable_to_non_nullable
as bool?,
paidQuantity: freezed == paidQuantity
? _value.paidQuantity
: paidQuantity // ignore: cast_nullable_to_non_nullable
@@ -1323,6 +1347,22 @@ class _$OrderItemDtoCopyWithImpl<$Res, $Val extends OrderItemDto>
? _value.categoryName
: categoryName // ignore: cast_nullable_to_non_nullable
as String?,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
unitId: freezed == unitId
? _value.unitId
: unitId // ignore: cast_nullable_to_non_nullable
as String?,
unitName: freezed == unitName
? _value.unitName
: unitName // ignore: cast_nullable_to_non_nullable
as String?,
unitAbbreviation: freezed == unitAbbreviation
? _value.unitAbbreviation
: unitAbbreviation // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
@@ -1354,9 +1394,14 @@ abstract class _$$OrderItemDtoImplCopyWith<$Res>
@JsonKey(name: "created_at") String? createdAt,
@JsonKey(name: "updated_at") String? updatedAt,
@JsonKey(name: "printer_type") String? printerType,
@JsonKey(name: "print_to_checker") bool? printToChecker,
@JsonKey(name: "paid_quantity") int? paidQuantity,
@JsonKey(name: "category_id") String? categoryId,
@JsonKey(name: "category_name") String? categoryName,
@JsonKey(name: "weight") double? weight,
@JsonKey(name: "unit_id") String? unitId,
@JsonKey(name: "unit_name") String? unitName,
@JsonKey(name: "unit_abbreviation") String? unitAbbreviation,
});
}
@@ -1389,9 +1434,14 @@ class __$$OrderItemDtoImplCopyWithImpl<$Res>
Object? createdAt = freezed,
Object? updatedAt = freezed,
Object? printerType = freezed,
Object? printToChecker = freezed,
Object? paidQuantity = freezed,
Object? categoryId = freezed,
Object? categoryName = freezed,
Object? weight = freezed,
Object? unitId = freezed,
Object? unitName = freezed,
Object? unitAbbreviation = freezed,
}) {
return _then(
_$OrderItemDtoImpl(
@@ -1455,6 +1505,10 @@ class __$$OrderItemDtoImplCopyWithImpl<$Res>
? _value.printerType
: printerType // ignore: cast_nullable_to_non_nullable
as String?,
printToChecker: freezed == printToChecker
? _value.printToChecker
: printToChecker // ignore: cast_nullable_to_non_nullable
as bool?,
paidQuantity: freezed == paidQuantity
? _value.paidQuantity
: paidQuantity // ignore: cast_nullable_to_non_nullable
@@ -1467,6 +1521,22 @@ class __$$OrderItemDtoImplCopyWithImpl<$Res>
? _value.categoryName
: categoryName // ignore: cast_nullable_to_non_nullable
as String?,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
unitId: freezed == unitId
? _value.unitId
: unitId // ignore: cast_nullable_to_non_nullable
as String?,
unitName: freezed == unitName
? _value.unitName
: unitName // ignore: cast_nullable_to_non_nullable
as String?,
unitAbbreviation: freezed == unitAbbreviation
? _value.unitAbbreviation
: unitAbbreviation // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
@@ -1491,9 +1561,14 @@ class _$OrderItemDtoImpl extends _OrderItemDto {
@JsonKey(name: "created_at") this.createdAt,
@JsonKey(name: "updated_at") this.updatedAt,
@JsonKey(name: "printer_type") this.printerType,
@JsonKey(name: "print_to_checker") this.printToChecker,
@JsonKey(name: "paid_quantity") this.paidQuantity,
@JsonKey(name: "category_id") this.categoryId,
@JsonKey(name: "category_name") this.categoryName,
@JsonKey(name: "weight") this.weight,
@JsonKey(name: "unit_id") this.unitId,
@JsonKey(name: "unit_name") this.unitName,
@JsonKey(name: "unit_abbreviation") this.unitAbbreviation,
}) : _modifiers = modifiers,
super._();
@@ -1554,6 +1629,9 @@ class _$OrderItemDtoImpl extends _OrderItemDto {
@JsonKey(name: "printer_type")
final String? printerType;
@override
@JsonKey(name: "print_to_checker")
final bool? printToChecker;
@override
@JsonKey(name: "paid_quantity")
final int? paidQuantity;
@override
@@ -1562,10 +1640,22 @@ class _$OrderItemDtoImpl extends _OrderItemDto {
@override
@JsonKey(name: "category_name")
final String? categoryName;
@override
@JsonKey(name: "weight")
final double? weight;
@override
@JsonKey(name: "unit_id")
final String? unitId;
@override
@JsonKey(name: "unit_name")
final String? unitName;
@override
@JsonKey(name: "unit_abbreviation")
final String? unitAbbreviation;
@override
String toString() {
return 'OrderItemDto(id: $id, orderId: $orderId, productId: $productId, productName: $productName, productVariantId: $productVariantId, productVariantName: $productVariantName, quantity: $quantity, unitPrice: $unitPrice, totalPrice: $totalPrice, modifiers: $modifiers, notes: $notes, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, printerType: $printerType, paidQuantity: $paidQuantity, categoryId: $categoryId, categoryName: $categoryName)';
return 'OrderItemDto(id: $id, orderId: $orderId, productId: $productId, productName: $productName, productVariantId: $productVariantId, productVariantName: $productVariantName, quantity: $quantity, unitPrice: $unitPrice, totalPrice: $totalPrice, modifiers: $modifiers, notes: $notes, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, printerType: $printerType, printToChecker: $printToChecker, paidQuantity: $paidQuantity, categoryId: $categoryId, categoryName: $categoryName, weight: $weight, unitId: $unitId, unitName: $unitName, unitAbbreviation: $unitAbbreviation)';
}
@override
@@ -1601,17 +1691,25 @@ class _$OrderItemDtoImpl extends _OrderItemDto {
other.updatedAt == updatedAt) &&
(identical(other.printerType, printerType) ||
other.printerType == printerType) &&
(identical(other.printToChecker, printToChecker) ||
other.printToChecker == printToChecker) &&
(identical(other.paidQuantity, paidQuantity) ||
other.paidQuantity == paidQuantity) &&
(identical(other.categoryId, categoryId) ||
other.categoryId == categoryId) &&
(identical(other.categoryName, categoryName) ||
other.categoryName == categoryName));
other.categoryName == categoryName) &&
(identical(other.weight, weight) || other.weight == weight) &&
(identical(other.unitId, unitId) || other.unitId == unitId) &&
(identical(other.unitName, unitName) ||
other.unitName == unitName) &&
(identical(other.unitAbbreviation, unitAbbreviation) ||
other.unitAbbreviation == unitAbbreviation));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
int get hashCode => Object.hashAll([
runtimeType,
id,
orderId,
@@ -1628,10 +1726,15 @@ class _$OrderItemDtoImpl extends _OrderItemDto {
createdAt,
updatedAt,
printerType,
printToChecker,
paidQuantity,
categoryId,
categoryName,
);
weight,
unitId,
unitName,
unitAbbreviation,
]);
/// Create a copy of OrderItemDto
/// with the given fields replaced by the non-null parameter values.
@@ -1664,9 +1767,14 @@ abstract class _OrderItemDto extends OrderItemDto {
@JsonKey(name: "created_at") final String? createdAt,
@JsonKey(name: "updated_at") final String? updatedAt,
@JsonKey(name: "printer_type") final String? printerType,
@JsonKey(name: "print_to_checker") final bool? printToChecker,
@JsonKey(name: "paid_quantity") final int? paidQuantity,
@JsonKey(name: "category_id") final String? categoryId,
@JsonKey(name: "category_name") final String? categoryName,
@JsonKey(name: "weight") final double? weight,
@JsonKey(name: "unit_id") final String? unitId,
@JsonKey(name: "unit_name") final String? unitName,
@JsonKey(name: "unit_abbreviation") final String? unitAbbreviation,
}) = _$OrderItemDtoImpl;
const _OrderItemDto._() : super._();
@@ -1719,6 +1827,9 @@ abstract class _OrderItemDto extends OrderItemDto {
@JsonKey(name: "printer_type")
String? get printerType;
@override
@JsonKey(name: "print_to_checker")
bool? get printToChecker;
@override
@JsonKey(name: "paid_quantity")
int? get paidQuantity;
@override
@@ -1727,6 +1838,18 @@ abstract class _OrderItemDto extends OrderItemDto {
@override
@JsonKey(name: "category_name")
String? get categoryName;
@override
@JsonKey(name: "weight")
double? get weight;
@override
@JsonKey(name: "unit_id")
String? get unitId;
@override
@JsonKey(name: "unit_name")
String? get unitName;
@override
@JsonKey(name: "unit_abbreviation")
String? get unitAbbreviation;
/// Create a copy of OrderItemDto
/// with the given fields replaced by the non-null parameter values.
@@ -2111,6 +2234,8 @@ mixin _$OrderItemRequestDto {
int? get unitPrice => throw _privateConstructorUsedError;
@JsonKey(name: "notes")
String? get notes => throw _privateConstructorUsedError;
@JsonKey(name: "weight")
double? get weight => throw _privateConstructorUsedError;
/// Serializes this OrderItemRequestDto to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@@ -2135,6 +2260,7 @@ abstract class $OrderItemRequestDtoCopyWith<$Res> {
@JsonKey(name: "quantity") int? quantity,
@JsonKey(name: "unit_price") int? unitPrice,
@JsonKey(name: "notes") String? notes,
@JsonKey(name: "weight") double? weight,
});
}
@@ -2158,6 +2284,7 @@ class _$OrderItemRequestDtoCopyWithImpl<$Res, $Val extends OrderItemRequestDto>
Object? quantity = freezed,
Object? unitPrice = freezed,
Object? notes = freezed,
Object? weight = freezed,
}) {
return _then(
_value.copyWith(
@@ -2181,6 +2308,10 @@ class _$OrderItemRequestDtoCopyWithImpl<$Res, $Val extends OrderItemRequestDto>
? _value.notes
: notes // ignore: cast_nullable_to_non_nullable
as String?,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
)
as $Val,
);
@@ -2202,6 +2333,7 @@ abstract class _$$OrderItemRequestDtoImplCopyWith<$Res>
@JsonKey(name: "quantity") int? quantity,
@JsonKey(name: "unit_price") int? unitPrice,
@JsonKey(name: "notes") String? notes,
@JsonKey(name: "weight") double? weight,
});
}
@@ -2224,6 +2356,7 @@ class __$$OrderItemRequestDtoImplCopyWithImpl<$Res>
Object? quantity = freezed,
Object? unitPrice = freezed,
Object? notes = freezed,
Object? weight = freezed,
}) {
return _then(
_$OrderItemRequestDtoImpl(
@@ -2247,6 +2380,10 @@ class __$$OrderItemRequestDtoImplCopyWithImpl<$Res>
? _value.notes
: notes // ignore: cast_nullable_to_non_nullable
as String?,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
),
);
}
@@ -2261,6 +2398,7 @@ class _$OrderItemRequestDtoImpl extends _OrderItemRequestDto {
@JsonKey(name: "quantity") this.quantity,
@JsonKey(name: "unit_price") this.unitPrice,
@JsonKey(name: "notes") this.notes,
@JsonKey(name: "weight") this.weight,
}) : super._();
factory _$OrderItemRequestDtoImpl.fromJson(Map<String, dynamic> json) =>
@@ -2281,10 +2419,13 @@ class _$OrderItemRequestDtoImpl extends _OrderItemRequestDto {
@override
@JsonKey(name: "notes")
final String? notes;
@override
@JsonKey(name: "weight")
final double? weight;
@override
String toString() {
return 'OrderItemRequestDto(productId: $productId, productVariantId: $productVariantId, quantity: $quantity, unitPrice: $unitPrice, notes: $notes)';
return 'OrderItemRequestDto(productId: $productId, productVariantId: $productVariantId, quantity: $quantity, unitPrice: $unitPrice, notes: $notes, weight: $weight)';
}
@override
@@ -2300,7 +2441,8 @@ class _$OrderItemRequestDtoImpl extends _OrderItemRequestDto {
other.quantity == quantity) &&
(identical(other.unitPrice, unitPrice) ||
other.unitPrice == unitPrice) &&
(identical(other.notes, notes) || other.notes == notes));
(identical(other.notes, notes) || other.notes == notes) &&
(identical(other.weight, weight) || other.weight == weight));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -2312,6 +2454,7 @@ class _$OrderItemRequestDtoImpl extends _OrderItemRequestDto {
quantity,
unitPrice,
notes,
weight,
);
/// Create a copy of OrderItemRequestDto
@@ -2338,6 +2481,7 @@ abstract class _OrderItemRequestDto extends OrderItemRequestDto {
@JsonKey(name: "quantity") final int? quantity,
@JsonKey(name: "unit_price") final int? unitPrice,
@JsonKey(name: "notes") final String? notes,
@JsonKey(name: "weight") final double? weight,
}) = _$OrderItemRequestDtoImpl;
const _OrderItemRequestDto._() : super._();
@@ -2359,6 +2503,9 @@ abstract class _OrderItemRequestDto extends OrderItemRequestDto {
@override
@JsonKey(name: "notes")
String? get notes;
@override
@JsonKey(name: "weight")
double? get weight;
/// Create a copy of OrderItemRequestDto
/// with the given fields replaced by the non-null parameter values.
@@ -4000,6 +4147,8 @@ mixin _$AddItemOrderRequestDto {
int? get unitPrice => throw _privateConstructorUsedError;
@JsonKey(name: 'notes')
String? get notes => throw _privateConstructorUsedError;
@JsonKey(name: 'weight')
double? get weight => throw _privateConstructorUsedError;
/// Serializes this AddItemOrderRequestDto to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@@ -4024,6 +4173,7 @@ abstract class $AddItemOrderRequestDtoCopyWith<$Res> {
@JsonKey(name: 'quantity') int? quantity,
@JsonKey(name: 'unit_price') int? unitPrice,
@JsonKey(name: 'notes') String? notes,
@JsonKey(name: 'weight') double? weight,
});
}
@@ -4050,6 +4200,7 @@ class _$AddItemOrderRequestDtoCopyWithImpl<
Object? quantity = freezed,
Object? unitPrice = freezed,
Object? notes = freezed,
Object? weight = freezed,
}) {
return _then(
_value.copyWith(
@@ -4073,6 +4224,10 @@ class _$AddItemOrderRequestDtoCopyWithImpl<
? _value.notes
: notes // ignore: cast_nullable_to_non_nullable
as String?,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
)
as $Val,
);
@@ -4094,6 +4249,7 @@ abstract class _$$AddItemOrderRequestDtoImplCopyWith<$Res>
@JsonKey(name: 'quantity') int? quantity,
@JsonKey(name: 'unit_price') int? unitPrice,
@JsonKey(name: 'notes') String? notes,
@JsonKey(name: 'weight') double? weight,
});
}
@@ -4117,6 +4273,7 @@ class __$$AddItemOrderRequestDtoImplCopyWithImpl<$Res>
Object? quantity = freezed,
Object? unitPrice = freezed,
Object? notes = freezed,
Object? weight = freezed,
}) {
return _then(
_$AddItemOrderRequestDtoImpl(
@@ -4140,6 +4297,10 @@ class __$$AddItemOrderRequestDtoImplCopyWithImpl<$Res>
? _value.notes
: notes // ignore: cast_nullable_to_non_nullable
as String?,
weight: freezed == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as double?,
),
);
}
@@ -4154,6 +4315,7 @@ class _$AddItemOrderRequestDtoImpl extends _AddItemOrderRequestDto {
@JsonKey(name: 'quantity') this.quantity,
@JsonKey(name: 'unit_price') this.unitPrice,
@JsonKey(name: 'notes') this.notes,
@JsonKey(name: 'weight') this.weight,
}) : super._();
factory _$AddItemOrderRequestDtoImpl.fromJson(Map<String, dynamic> json) =>
@@ -4174,10 +4336,13 @@ class _$AddItemOrderRequestDtoImpl extends _AddItemOrderRequestDto {
@override
@JsonKey(name: 'notes')
final String? notes;
@override
@JsonKey(name: 'weight')
final double? weight;
@override
String toString() {
return 'AddItemOrderRequestDto(productId: $productId, productvariantId: $productvariantId, quantity: $quantity, unitPrice: $unitPrice, notes: $notes)';
return 'AddItemOrderRequestDto(productId: $productId, productvariantId: $productvariantId, quantity: $quantity, unitPrice: $unitPrice, notes: $notes, weight: $weight)';
}
@override
@@ -4193,7 +4358,8 @@ class _$AddItemOrderRequestDtoImpl extends _AddItemOrderRequestDto {
other.quantity == quantity) &&
(identical(other.unitPrice, unitPrice) ||
other.unitPrice == unitPrice) &&
(identical(other.notes, notes) || other.notes == notes));
(identical(other.notes, notes) || other.notes == notes) &&
(identical(other.weight, weight) || other.weight == weight));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -4205,6 +4371,7 @@ class _$AddItemOrderRequestDtoImpl extends _AddItemOrderRequestDto {
quantity,
unitPrice,
notes,
weight,
);
/// Create a copy of AddItemOrderRequestDto
@@ -4232,6 +4399,7 @@ abstract class _AddItemOrderRequestDto extends AddItemOrderRequestDto {
@JsonKey(name: 'quantity') final int? quantity,
@JsonKey(name: 'unit_price') final int? unitPrice,
@JsonKey(name: 'notes') final String? notes,
@JsonKey(name: 'weight') final double? weight,
}) = _$AddItemOrderRequestDtoImpl;
const _AddItemOrderRequestDto._() : super._();
@@ -4253,6 +4421,9 @@ abstract class _AddItemOrderRequestDto extends AddItemOrderRequestDto {
@override
@JsonKey(name: 'notes')
String? get notes;
@override
@JsonKey(name: 'weight')
double? get weight;
/// Create a copy of AddItemOrderRequestDto
/// with the given fields replaced by the non-null parameter values.
@@ -109,9 +109,14 @@ _$OrderItemDtoImpl _$$OrderItemDtoImplFromJson(Map<String, dynamic> json) =>
createdAt: json['created_at'] as String?,
updatedAt: json['updated_at'] as String?,
printerType: json['printer_type'] as String?,
printToChecker: json['print_to_checker'] as bool?,
paidQuantity: (json['paid_quantity'] as num?)?.toInt(),
categoryId: json['category_id'] as String?,
categoryName: json['category_name'] as String?,
weight: (json['weight'] as num?)?.toDouble(),
unitId: json['unit_id'] as String?,
unitName: json['unit_name'] as String?,
unitAbbreviation: json['unit_abbreviation'] as String?,
);
Map<String, dynamic> _$$OrderItemDtoImplToJson(_$OrderItemDtoImpl instance) =>
@@ -131,9 +136,14 @@ Map<String, dynamic> _$$OrderItemDtoImplToJson(_$OrderItemDtoImpl instance) =>
'created_at': instance.createdAt,
'updated_at': instance.updatedAt,
'printer_type': instance.printerType,
'print_to_checker': instance.printToChecker,
'paid_quantity': instance.paidQuantity,
'category_id': instance.categoryId,
'category_name': instance.categoryName,
'weight': instance.weight,
'unit_id': instance.unitId,
'unit_name': instance.unitName,
'unit_abbreviation': instance.unitAbbreviation,
};
_$OrderRequestDtoImpl _$$OrderRequestDtoImplFromJson(
@@ -172,6 +182,7 @@ _$OrderItemRequestDtoImpl _$$OrderItemRequestDtoImplFromJson(
quantity: (json['quantity'] as num?)?.toInt(),
unitPrice: (json['unit_price'] as num?)?.toInt(),
notes: json['notes'] as String?,
weight: (json['weight'] as num?)?.toDouble(),
);
Map<String, dynamic> _$$OrderItemRequestDtoImplToJson(
@@ -182,6 +193,7 @@ Map<String, dynamic> _$$OrderItemRequestDtoImplToJson(
'quantity': instance.quantity,
'unit_price': instance.unitPrice,
'notes': instance.notes,
'weight': instance.weight,
};
_$PaymentRequestDtoImpl _$$PaymentRequestDtoImplFromJson(
@@ -315,6 +327,7 @@ _$AddItemOrderRequestDtoImpl _$$AddItemOrderRequestDtoImplFromJson(
quantity: (json['quantity'] as num?)?.toInt(),
unitPrice: (json['unit_price'] as num?)?.toInt(),
notes: json['notes'] as String?,
weight: (json['weight'] as num?)?.toDouble(),
);
Map<String, dynamic> _$$AddItemOrderRequestDtoImplToJson(
@@ -325,4 +338,5 @@ Map<String, dynamic> _$$AddItemOrderRequestDtoImplToJson(
'quantity': instance.quantity,
'unit_price': instance.unitPrice,
'notes': instance.notes,
'weight': instance.weight,
};
@@ -110,6 +110,7 @@ class OrderRepository implements IOrderRepository {
Future<Either<OrderFailure, Order>> addItemOrder({
required String id,
required List<AddItemOrderRequest> request,
String? idempotencyKey,
}) async {
try {
final result = await _dataProvider.addItemOrder(
@@ -117,6 +118,7 @@ class OrderRepository implements IOrderRepository {
request: request
.map((e) => AddItemOrderRequestDto.fromDomain(e))
.toList(),
idempotencyKey: idempotencyKey,
);
if (result.hasError) {
@@ -134,10 +136,12 @@ class OrderRepository implements IOrderRepository {
@override
Future<Either<OrderFailure, Payment>> createPayment({
required PaymentRequest request,
String? idempotencyKey,
}) async {
try {
final result = await _dataProvider.storePayment(
PaymentRequestDto.fromDomain(request),
idempotencyKey: idempotencyKey,
);
if (result.hasError) {
@@ -158,6 +162,7 @@ class OrderRepository implements IOrderRepository {
required String reason,
String type = "ITEM",
required List<OrderItem> orderItems,
String? idempotencyKey,
}) async {
try {
final result = await _dataProvider.voidOrder(
@@ -165,6 +170,7 @@ class OrderRepository implements IOrderRepository {
reason: reason,
type: type,
orderItems: orderItems.map((e) => OrderItemDto.fromDomain(e)).toList(),
idempotencyKey: idempotencyKey,
);
if (result.hasError) {
@@ -180,11 +186,13 @@ class OrderRepository implements IOrderRepository {
@override
Future<Either<OrderFailure, Payment>> createSplitBill(
PaymentSplitBillRequest request,
) async {
PaymentSplitBillRequest request, {
String? idempotencyKey,
}) async {
try {
final result = await _dataProvider.createSplitBill(
PaymentSplitBillRequestDto.fromDomain(request),
idempotencyKey: idempotencyKey,
);
if (result.hasError) {
@@ -204,12 +212,14 @@ class OrderRepository implements IOrderRepository {
required String id,
required String reason,
required int refundAmount,
String? idempotencyKey,
}) async {
try {
final result = await _dataProvider.refundPayment(
id: id,
reason: reason,
refundAmount: refundAmount,
idempotencyKey: idempotencyKey,
);
if (result.hasError) {
return left(result.error!);
@@ -0,0 +1,271 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
/// Kenapa sebuah cetakan jaringan dianggap gagal.
enum NetworkPrintFailure {
/// Socket tidak bisa dibuka — IP salah, printer mati, atau beda jaringan.
unreachable,
/// Printer menjawab bahwa kertasnya habis.
paperOut,
/// Printer menjawab bahwa cover-nya terbuka.
coverOpen,
/// Printer menjawab sedang offline / dalam kondisi error.
offline,
/// Koneksi terbuka tapi pengiriman data putus di tengah jalan.
writeFailed,
}
class NetworkPrintResult {
final NetworkPrintFailure? failure;
/// Printer membalas perintah status. Printer clone yang tidak mendukung
/// `DLE EOT` membuat ini `false`, dan pemeriksaan kondisi kertas dilewati.
final bool statusSupported;
/// Kertas menipis. Bukan kegagalan — cetakan tetap keluar, tapi layak
/// diberitahukan ke kasir.
final bool paperNearEnd;
const NetworkPrintResult({
this.failure,
this.statusSupported = false,
this.paperNearEnd = false,
});
bool get isSuccess => failure == null;
}
/// Mencetak ke printer thermal jaringan lewat raw socket port 9100.
///
/// Berbeda dari `flutter_esc_pos_network` yang hanya mengirim byte lalu menutup
/// koneksi, klien ini menanyakan kondisi printer dengan `DLE EOT` dan membaca
/// jawabannya. Tanpa itu, kertas habis atau cover terbuka tidak bisa dibedakan
/// dari cetakan yang berhasil — byte tetap diterima socket dan semuanya tampak
/// sukses.
class NetworkPrinterClient {
static const _logName = 'NetworkPrinterClient';
/// Perintah status real-time ESC/POS: `DLE EOT n`.
static const _statusPrinter = [0x10, 0x04, 1];
static const _statusOffline = [0x10, 0x04, 2];
static const _statusPaper = [0x10, 0x04, 4];
/// Jeda menunggu balasan status. Sengaja pendek: printer yang tidak
/// mendukung `DLE EOT` tidak akan pernah menjawab, dan kita tidak boleh
/// menahan kasir hanya untuk menunggu jawaban yang tidak akan datang.
static const _statusTimeout = Duration(milliseconds: 800);
/// Host yang terbukti tidak menjawab `DLE EOT`. Tanpa catatan ini, printer
/// clone membuat setiap cetakan membayar timeout di atas — mahal untuk kasir
/// yang sedang antre. Cukup dibuktikan sekali per sesi aplikasi.
final Set<String> _statusUnsupported = {};
Future<NetworkPrintResult> printTicket({
required String host,
required List<int> data,
int port = 9100,
Duration connectTimeout = const Duration(seconds: 10),
}) async {
Socket? socket;
try {
socket = await Socket.connect(host, port, timeout: connectTimeout);
} catch (e) {
log('Tidak bisa membuka socket ke $host:$port', name: _logName, error: e);
return const NetworkPrintResult(
failure: NetworkPrintFailure.unreachable,
);
}
final reader = _SocketReader();
late StreamSubscription<List<int>> subscription;
try {
subscription = socket.listen(
reader.add,
onError: (Object e) => log('Socket error', name: _logName, error: e),
cancelOnError: false,
);
final before = await _readStatus(socket, reader, host);
if (before.answered) {
final blocking = before.blockingFailure;
if (blocking != null) {
log('Printer $host menolak: ${blocking.name}', name: _logName);
return NetworkPrintResult(failure: blocking, statusSupported: true);
}
}
socket.add(data);
await socket.flush();
// Kertas bisa habis di tengah struk. Cetakan yang terpotong sama
// tidak bergunanya dengan yang tidak keluar sama sekali, jadi kondisi
// ini dilaporkan sebagai gagal supaya kasir mencetak ulang.
final after = before.answered
? await _readStatus(socket, reader, host)
: const _PrinterStatus.unanswered();
if (after.answered) {
final blocking = after.blockingFailure;
if (blocking != null) {
log('Printer $host bermasalah setelah cetak: ${blocking.name}',
name: _logName);
return NetworkPrintResult(failure: blocking, statusSupported: true);
}
}
return NetworkPrintResult(
statusSupported: before.answered,
paperNearEnd: after.paperNearEnd || before.paperNearEnd,
);
} catch (e) {
log('Gagal mengirim data ke $host:$port', name: _logName, error: e);
return const NetworkPrintResult(
failure: NetworkPrintFailure.writeFailed,
statusSupported: false,
);
} finally {
await subscription.cancel().catchError((_) {});
try {
await socket.close();
} catch (_) {
// Printer kadang menutup duluan; tidak ada yang bisa diperbuat.
}
socket.destroy();
}
}
Future<_PrinterStatus> _readStatus(
Socket socket,
_SocketReader reader,
String host,
) async {
if (_statusUnsupported.contains(host)) {
return const _PrinterStatus.unanswered();
}
final printer = await _query(socket, reader, _statusPrinter);
// Printer yang tidak mendukung DLE EOT tidak menjawab sama sekali.
// Perlakukan sebagai "tidak diketahui", bukan sebagai error — kalau tidak,
// printer semacam itu jadi tidak bisa dipakai sama sekali.
if (printer == null) {
log(
'Printer $host tidak menjawab status; pemeriksaan kondisi kertas '
'dilewati untuk seterusnya',
name: _logName,
);
_statusUnsupported.add(host);
return const _PrinterStatus.unanswered();
}
final offline = await _query(socket, reader, _statusOffline);
final paper = await _query(socket, reader, _statusPaper);
return _PrinterStatus(
answered: true,
offline: printer & 0x08 != 0,
coverOpen: (offline ?? 0) & 0x04 != 0,
paperOut: ((offline ?? 0) & 0x20 != 0) || ((paper ?? 0) & 0x60 != 0),
paperNearEnd: (paper ?? 0) & 0x0C != 0,
);
}
/// Mengirim satu perintah status dan menunggu satu byte balasan.
/// `null` berarti printer tidak menjawab atau menjawab dengan format asing.
Future<int?> _query(
Socket socket,
_SocketReader reader,
List<int> command,
) async {
reader.clear();
socket.add(command);
await socket.flush();
final byte = await reader.readByte(_statusTimeout);
if (byte == null) return null;
// Balasan DLE EOT selalu punya pola tetap: bit0=0, bit1=1, bit4=1, bit7=0.
// Byte yang tidak cocok berarti bukan balasan status, jadi lebih aman
// dianggap tidak menjawab daripada salah tafsir.
if (byte & 0x93 != 0x12) {
log('Balasan status tidak dikenali: 0x${byte.toRadixString(16)}',
name: _logName);
return null;
}
return byte;
}
}
class _PrinterStatus {
final bool answered;
final bool offline;
final bool coverOpen;
final bool paperOut;
final bool paperNearEnd;
const _PrinterStatus({
required this.answered,
this.offline = false,
this.coverOpen = false,
this.paperOut = false,
this.paperNearEnd = false,
});
const _PrinterStatus.unanswered()
: answered = false,
offline = false,
coverOpen = false,
paperOut = false,
paperNearEnd = false;
/// Kondisi yang membuat struk tidak akan keluar. Diperiksa berurutan dari
/// yang paling spesifik supaya pesan ke kasir langsung menyebut tindakannya.
NetworkPrintFailure? get blockingFailure {
if (paperOut) return NetworkPrintFailure.paperOut;
if (coverOpen) return NetworkPrintFailure.coverOpen;
if (offline) return NetworkPrintFailure.offline;
return null;
}
}
/// Menampung byte yang datang dari socket dan membiarkannya dibaca satu per
/// satu dengan batas waktu. Socket adalah stream sekali-langganan, jadi
/// pembacaannya harus lewat satu subscription untuk seluruh sesi.
class _SocketReader {
final List<int> _buffer = [];
Completer<void>? _waiter;
void add(List<int> data) {
_buffer.addAll(data);
_waiter?.complete();
_waiter = null;
}
void clear() => _buffer.clear();
Future<int?> readByte(Duration timeout) async {
if (_buffer.isEmpty) {
final waiter = Completer<void>();
_waiter = waiter;
try {
await waiter.future.timeout(timeout);
} on TimeoutException {
_waiter = null;
return null;
}
}
if (_buffer.isEmpty) return null;
return _buffer.removeAt(0);
}
}
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,25 @@ class ListProductDto with _$ListProductDto {
);
}
/// Satuan yang menempel pada produk.
///
/// Backend hanya dijamin mengirim `unit_id`; sebagian response ikut menyertakan
/// objek `unit` lengkap. Kalau ada, dipakai langsung supaya POS tidak perlu
/// menunggu master satuan termuat.
@freezed
class ProductUnitDto with _$ProductUnitDto {
const ProductUnitDto._();
const factory ProductUnitDto({
@JsonKey(name: "id") String? id,
@JsonKey(name: "name") String? name,
@JsonKey(name: "abbreviation") String? abbreviation,
}) = _ProductUnitDto;
factory ProductUnitDto.fromJson(Map<String, dynamic> json) =>
_$ProductUnitDtoFromJson(json);
}
@freezed
class ProductDto with _$ProductDto {
const ProductDto._();
@@ -40,16 +59,34 @@ class ProductDto with _$ProductDto {
@JsonKey(name: "business_type") String? businessType,
@JsonKey(name: "image_url") String? imageUrl,
@JsonKey(name: "printer_type") String? printerType,
@JsonKey(name: "print_to_checker") bool? printToChecker,
@JsonKey(name: "metadata") Map<String, dynamic>? metadata,
@JsonKey(name: "is_active") bool? isActive,
@JsonKey(name: "created_at") String? createdAt,
@JsonKey(name: "updated_at") String? updatedAt,
@JsonKey(name: "variants") List<ProductVariantDto>? variants,
@JsonKey(name: "sell_by") String? sellBy,
@JsonKey(name: "unit_id") String? unitId,
@JsonKey(name: "unit_name") String? unitName,
@JsonKey(name: "unit_abbreviation") String? unitAbbreviation,
@JsonKey(name: "unit") ProductUnitDto? unit,
}) = _ProductDto;
factory ProductDto.fromJson(Map<String, dynamic> json) =>
_$ProductDtoFromJson(json);
String get _resolvedUnitId =>
(unitId?.isNotEmpty == true ? unitId : unit?.id) ?? '';
String get _resolvedUnitName =>
(unitName?.isNotEmpty == true ? unitName : unit?.name) ?? '';
String get _resolvedUnitAbbreviation =>
(unitAbbreviation?.isNotEmpty == true
? unitAbbreviation
: unit?.abbreviation) ??
'';
/// Mapping ke domain
Product toDomain() => Product(
id: id ?? '',
@@ -63,11 +100,16 @@ class ProductDto with _$ProductDto {
businessType: businessType ?? '',
imageUrl: imageUrl ?? '',
printerType: printerType ?? '',
printToChecker: printToChecker ?? false,
metadata: metadata ?? {},
isActive: isActive ?? false,
createdAt: createdAt ?? '',
updatedAt: updatedAt ?? '',
variants: variants?.map((v) => v.toDomain()).toList() ?? [],
sellBy: SellBy.normalize(sellBy),
unitId: _resolvedUnitId,
unitName: _resolvedUnitName,
unitAbbreviation: _resolvedUnitAbbreviation,
);
Map<String, dynamic> toMap() => {
@@ -82,10 +124,15 @@ class ProductDto with _$ProductDto {
'business_type': businessType,
'image_url': imageUrl,
'printer_type': printerType,
'print_to_checker': printToChecker == true ? 1 : 0,
'metadata': metadata != null ? jsonEncode(metadata) : null,
'is_active': isActive == true ? 1 : 0,
'created_at': createdAt,
'updated_at': updatedAt,
'sell_by': SellBy.normalize(sellBy),
'unit_id': _resolvedUnitId,
'unit_name': _resolvedUnitName,
'unit_abbreviation': _resolvedUnitAbbreviation,
};
factory ProductDto.fromMap(
@@ -103,6 +150,9 @@ class ProductDto with _$ProductDto {
businessType: map['business_type'] as String?,
imageUrl: map['image_url'] as String?,
printerType: map['printer_type'] as String?,
printToChecker: map['print_to_checker'] != null
? (map['print_to_checker'] as int) == 1
: null,
metadata: map['metadata'] != null
? jsonDecode(map['metadata'] as String) as Map<String, dynamic>
: null,
@@ -111,6 +161,10 @@ class ProductDto with _$ProductDto {
createdAt: map['created_at'] as String?,
updatedAt: map['updated_at'] as String?,
variants: variants,
sellBy: map['sell_by'] as String?,
unitId: map['unit_id'] as String?,
unitName: map['unit_name'] as String?,
unitAbbreviation: map['unit_abbreviation'] as String?,
);
factory ProductDto.fromDomain(Product product) => ProductDto(
@@ -125,6 +179,7 @@ class ProductDto with _$ProductDto {
businessType: product.businessType,
imageUrl: product.imageUrl,
printerType: product.printerType,
printToChecker: product.printToChecker,
metadata: product.metadata,
isActive: product.isActive,
createdAt: product.createdAt,
@@ -132,6 +187,10 @@ class ProductDto with _$ProductDto {
variants: product.variants
.map((v) => ProductVariantDto.fromDomain(v))
.toList(),
sellBy: product.sellBy,
unitId: product.unitId,
unitName: product.unitName,
unitAbbreviation: product.unitAbbreviation,
);
}
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../common/types/sell_by.dart';
import '../../domain/product/product.dart';
part 'product_dtos.freezed.dart';
@@ -293,6 +293,223 @@ abstract class _ListProductDto extends ListProductDto {
throw _privateConstructorUsedError;
}
ProductUnitDto _$ProductUnitDtoFromJson(Map<String, dynamic> json) {
return _ProductUnitDto.fromJson(json);
}
/// @nodoc
mixin _$ProductUnitDto {
@JsonKey(name: "id")
String? get id => throw _privateConstructorUsedError;
@JsonKey(name: "name")
String? get name => throw _privateConstructorUsedError;
@JsonKey(name: "abbreviation")
String? get abbreviation => throw _privateConstructorUsedError;
/// Serializes this ProductUnitDto to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of ProductUnitDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ProductUnitDtoCopyWith<ProductUnitDto> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ProductUnitDtoCopyWith<$Res> {
factory $ProductUnitDtoCopyWith(
ProductUnitDto value,
$Res Function(ProductUnitDto) then,
) = _$ProductUnitDtoCopyWithImpl<$Res, ProductUnitDto>;
@useResult
$Res call({
@JsonKey(name: "id") String? id,
@JsonKey(name: "name") String? name,
@JsonKey(name: "abbreviation") String? abbreviation,
});
}
/// @nodoc
class _$ProductUnitDtoCopyWithImpl<$Res, $Val extends ProductUnitDto>
implements $ProductUnitDtoCopyWith<$Res> {
_$ProductUnitDtoCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of ProductUnitDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? name = freezed,
Object? abbreviation = freezed,
}) {
return _then(
_value.copyWith(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String?,
name: freezed == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String?,
abbreviation: freezed == abbreviation
? _value.abbreviation
: abbreviation // ignore: cast_nullable_to_non_nullable
as String?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$ProductUnitDtoImplCopyWith<$Res>
implements $ProductUnitDtoCopyWith<$Res> {
factory _$$ProductUnitDtoImplCopyWith(
_$ProductUnitDtoImpl value,
$Res Function(_$ProductUnitDtoImpl) then,
) = __$$ProductUnitDtoImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
@JsonKey(name: "id") String? id,
@JsonKey(name: "name") String? name,
@JsonKey(name: "abbreviation") String? abbreviation,
});
}
/// @nodoc
class __$$ProductUnitDtoImplCopyWithImpl<$Res>
extends _$ProductUnitDtoCopyWithImpl<$Res, _$ProductUnitDtoImpl>
implements _$$ProductUnitDtoImplCopyWith<$Res> {
__$$ProductUnitDtoImplCopyWithImpl(
_$ProductUnitDtoImpl _value,
$Res Function(_$ProductUnitDtoImpl) _then,
) : super(_value, _then);
/// Create a copy of ProductUnitDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? name = freezed,
Object? abbreviation = freezed,
}) {
return _then(
_$ProductUnitDtoImpl(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String?,
name: freezed == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String?,
abbreviation: freezed == abbreviation
? _value.abbreviation
: abbreviation // ignore: cast_nullable_to_non_nullable
as String?,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$ProductUnitDtoImpl extends _ProductUnitDto {
const _$ProductUnitDtoImpl({
@JsonKey(name: "id") this.id,
@JsonKey(name: "name") this.name,
@JsonKey(name: "abbreviation") this.abbreviation,
}) : super._();
factory _$ProductUnitDtoImpl.fromJson(Map<String, dynamic> json) =>
_$$ProductUnitDtoImplFromJson(json);
@override
@JsonKey(name: "id")
final String? id;
@override
@JsonKey(name: "name")
final String? name;
@override
@JsonKey(name: "abbreviation")
final String? abbreviation;
@override
String toString() {
return 'ProductUnitDto(id: $id, name: $name, abbreviation: $abbreviation)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ProductUnitDtoImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.abbreviation, abbreviation) ||
other.abbreviation == abbreviation));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, name, abbreviation);
/// Create a copy of ProductUnitDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$ProductUnitDtoImplCopyWith<_$ProductUnitDtoImpl> get copyWith =>
__$$ProductUnitDtoImplCopyWithImpl<_$ProductUnitDtoImpl>(
this,
_$identity,
);
@override
Map<String, dynamic> toJson() {
return _$$ProductUnitDtoImplToJson(this);
}
}
abstract class _ProductUnitDto extends ProductUnitDto {
const factory _ProductUnitDto({
@JsonKey(name: "id") final String? id,
@JsonKey(name: "name") final String? name,
@JsonKey(name: "abbreviation") final String? abbreviation,
}) = _$ProductUnitDtoImpl;
const _ProductUnitDto._() : super._();
factory _ProductUnitDto.fromJson(Map<String, dynamic> json) =
_$ProductUnitDtoImpl.fromJson;
@override
@JsonKey(name: "id")
String? get id;
@override
@JsonKey(name: "name")
String? get name;
@override
@JsonKey(name: "abbreviation")
String? get abbreviation;
/// Create a copy of ProductUnitDto
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$ProductUnitDtoImplCopyWith<_$ProductUnitDtoImpl> get copyWith =>
throw _privateConstructorUsedError;
}
ProductDto _$ProductDtoFromJson(Map<String, dynamic> json) {
return _ProductDto.fromJson(json);
}
@@ -321,6 +538,8 @@ mixin _$ProductDto {
String? get imageUrl => throw _privateConstructorUsedError;
@JsonKey(name: "printer_type")
String? get printerType => throw _privateConstructorUsedError;
@JsonKey(name: "print_to_checker")
bool? get printToChecker => throw _privateConstructorUsedError;
@JsonKey(name: "metadata")
Map<String, dynamic>? get metadata => throw _privateConstructorUsedError;
@JsonKey(name: "is_active")
@@ -331,6 +550,16 @@ mixin _$ProductDto {
String? get updatedAt => throw _privateConstructorUsedError;
@JsonKey(name: "variants")
List<ProductVariantDto>? get variants => throw _privateConstructorUsedError;
@JsonKey(name: "sell_by")
String? get sellBy => throw _privateConstructorUsedError;
@JsonKey(name: "unit_id")
String? get unitId => throw _privateConstructorUsedError;
@JsonKey(name: "unit_name")
String? get unitName => throw _privateConstructorUsedError;
@JsonKey(name: "unit_abbreviation")
String? get unitAbbreviation => throw _privateConstructorUsedError;
@JsonKey(name: "unit")
ProductUnitDto? get unit => throw _privateConstructorUsedError;
/// Serializes this ProductDto to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@@ -361,12 +590,20 @@ abstract class $ProductDtoCopyWith<$Res> {
@JsonKey(name: "business_type") String? businessType,
@JsonKey(name: "image_url") String? imageUrl,
@JsonKey(name: "printer_type") String? printerType,
@JsonKey(name: "print_to_checker") bool? printToChecker,
@JsonKey(name: "metadata") Map<String, dynamic>? metadata,
@JsonKey(name: "is_active") bool? isActive,
@JsonKey(name: "created_at") String? createdAt,
@JsonKey(name: "updated_at") String? updatedAt,
@JsonKey(name: "variants") List<ProductVariantDto>? variants,
@JsonKey(name: "sell_by") String? sellBy,
@JsonKey(name: "unit_id") String? unitId,
@JsonKey(name: "unit_name") String? unitName,
@JsonKey(name: "unit_abbreviation") String? unitAbbreviation,
@JsonKey(name: "unit") ProductUnitDto? unit,
});
$ProductUnitDtoCopyWith<$Res>? get unit;
}
/// @nodoc
@@ -395,11 +632,17 @@ class _$ProductDtoCopyWithImpl<$Res, $Val extends ProductDto>
Object? businessType = freezed,
Object? imageUrl = freezed,
Object? printerType = freezed,
Object? printToChecker = freezed,
Object? metadata = freezed,
Object? isActive = freezed,
Object? createdAt = freezed,
Object? updatedAt = freezed,
Object? variants = freezed,
Object? sellBy = freezed,
Object? unitId = freezed,
Object? unitName = freezed,
Object? unitAbbreviation = freezed,
Object? unit = freezed,
}) {
return _then(
_value.copyWith(
@@ -447,6 +690,10 @@ class _$ProductDtoCopyWithImpl<$Res, $Val extends ProductDto>
? _value.printerType
: printerType // ignore: cast_nullable_to_non_nullable
as String?,
printToChecker: freezed == printToChecker
? _value.printToChecker
: printToChecker // ignore: cast_nullable_to_non_nullable
as bool?,
metadata: freezed == metadata
? _value.metadata
: metadata // ignore: cast_nullable_to_non_nullable
@@ -467,10 +714,44 @@ class _$ProductDtoCopyWithImpl<$Res, $Val extends ProductDto>
? _value.variants
: variants // ignore: cast_nullable_to_non_nullable
as List<ProductVariantDto>?,
sellBy: freezed == sellBy
? _value.sellBy
: sellBy // ignore: cast_nullable_to_non_nullable
as String?,
unitId: freezed == unitId
? _value.unitId
: unitId // ignore: cast_nullable_to_non_nullable
as String?,
unitName: freezed == unitName
? _value.unitName
: unitName // ignore: cast_nullable_to_non_nullable
as String?,
unitAbbreviation: freezed == unitAbbreviation
? _value.unitAbbreviation
: unitAbbreviation // ignore: cast_nullable_to_non_nullable
as String?,
unit: freezed == unit
? _value.unit
: unit // ignore: cast_nullable_to_non_nullable
as ProductUnitDto?,
)
as $Val,
);
}
/// Create a copy of ProductDto
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ProductUnitDtoCopyWith<$Res>? get unit {
if (_value.unit == null) {
return null;
}
return $ProductUnitDtoCopyWith<$Res>(_value.unit!, (value) {
return _then(_value.copyWith(unit: value) as $Val);
});
}
}
/// @nodoc
@@ -494,12 +775,21 @@ abstract class _$$ProductDtoImplCopyWith<$Res>
@JsonKey(name: "business_type") String? businessType,
@JsonKey(name: "image_url") String? imageUrl,
@JsonKey(name: "printer_type") String? printerType,
@JsonKey(name: "print_to_checker") bool? printToChecker,
@JsonKey(name: "metadata") Map<String, dynamic>? metadata,
@JsonKey(name: "is_active") bool? isActive,
@JsonKey(name: "created_at") String? createdAt,
@JsonKey(name: "updated_at") String? updatedAt,
@JsonKey(name: "variants") List<ProductVariantDto>? variants,
@JsonKey(name: "sell_by") String? sellBy,
@JsonKey(name: "unit_id") String? unitId,
@JsonKey(name: "unit_name") String? unitName,
@JsonKey(name: "unit_abbreviation") String? unitAbbreviation,
@JsonKey(name: "unit") ProductUnitDto? unit,
});
@override
$ProductUnitDtoCopyWith<$Res>? get unit;
}
/// @nodoc
@@ -527,11 +817,17 @@ class __$$ProductDtoImplCopyWithImpl<$Res>
Object? businessType = freezed,
Object? imageUrl = freezed,
Object? printerType = freezed,
Object? printToChecker = freezed,
Object? metadata = freezed,
Object? isActive = freezed,
Object? createdAt = freezed,
Object? updatedAt = freezed,
Object? variants = freezed,
Object? sellBy = freezed,
Object? unitId = freezed,
Object? unitName = freezed,
Object? unitAbbreviation = freezed,
Object? unit = freezed,
}) {
return _then(
_$ProductDtoImpl(
@@ -579,6 +875,10 @@ class __$$ProductDtoImplCopyWithImpl<$Res>
? _value.printerType
: printerType // ignore: cast_nullable_to_non_nullable
as String?,
printToChecker: freezed == printToChecker
? _value.printToChecker
: printToChecker // ignore: cast_nullable_to_non_nullable
as bool?,
metadata: freezed == metadata
? _value._metadata
: metadata // ignore: cast_nullable_to_non_nullable
@@ -599,6 +899,26 @@ class __$$ProductDtoImplCopyWithImpl<$Res>
? _value._variants
: variants // ignore: cast_nullable_to_non_nullable
as List<ProductVariantDto>?,
sellBy: freezed == sellBy
? _value.sellBy
: sellBy // ignore: cast_nullable_to_non_nullable
as String?,
unitId: freezed == unitId
? _value.unitId
: unitId // ignore: cast_nullable_to_non_nullable
as String?,
unitName: freezed == unitName
? _value.unitName
: unitName // ignore: cast_nullable_to_non_nullable
as String?,
unitAbbreviation: freezed == unitAbbreviation
? _value.unitAbbreviation
: unitAbbreviation // ignore: cast_nullable_to_non_nullable
as String?,
unit: freezed == unit
? _value.unit
: unit // ignore: cast_nullable_to_non_nullable
as ProductUnitDto?,
),
);
}
@@ -619,11 +939,17 @@ class _$ProductDtoImpl extends _ProductDto {
@JsonKey(name: "business_type") this.businessType,
@JsonKey(name: "image_url") this.imageUrl,
@JsonKey(name: "printer_type") this.printerType,
@JsonKey(name: "print_to_checker") this.printToChecker,
@JsonKey(name: "metadata") final Map<String, dynamic>? metadata,
@JsonKey(name: "is_active") this.isActive,
@JsonKey(name: "created_at") this.createdAt,
@JsonKey(name: "updated_at") this.updatedAt,
@JsonKey(name: "variants") final List<ProductVariantDto>? variants,
@JsonKey(name: "sell_by") this.sellBy,
@JsonKey(name: "unit_id") this.unitId,
@JsonKey(name: "unit_name") this.unitName,
@JsonKey(name: "unit_abbreviation") this.unitAbbreviation,
@JsonKey(name: "unit") this.unit,
}) : _metadata = metadata,
_variants = variants,
super._();
@@ -664,6 +990,9 @@ class _$ProductDtoImpl extends _ProductDto {
@override
@JsonKey(name: "printer_type")
final String? printerType;
@override
@JsonKey(name: "print_to_checker")
final bool? printToChecker;
final Map<String, dynamic>? _metadata;
@override
@JsonKey(name: "metadata")
@@ -695,9 +1024,25 @@ class _$ProductDtoImpl extends _ProductDto {
return EqualUnmodifiableListView(value);
}
@override
@JsonKey(name: "sell_by")
final String? sellBy;
@override
@JsonKey(name: "unit_id")
final String? unitId;
@override
@JsonKey(name: "unit_name")
final String? unitName;
@override
@JsonKey(name: "unit_abbreviation")
final String? unitAbbreviation;
@override
@JsonKey(name: "unit")
final ProductUnitDto? unit;
@override
String toString() {
return 'ProductDto(id: $id, organizationId: $organizationId, categoryId: $categoryId, sku: $sku, name: $name, description: $description, price: $price, cost: $cost, businessType: $businessType, imageUrl: $imageUrl, printerType: $printerType, metadata: $metadata, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt, variants: $variants)';
return 'ProductDto(id: $id, organizationId: $organizationId, categoryId: $categoryId, sku: $sku, name: $name, description: $description, price: $price, cost: $cost, businessType: $businessType, imageUrl: $imageUrl, printerType: $printerType, printToChecker: $printToChecker, metadata: $metadata, isActive: $isActive, createdAt: $createdAt, updatedAt: $updatedAt, variants: $variants, sellBy: $sellBy, unitId: $unitId, unitName: $unitName, unitAbbreviation: $unitAbbreviation, unit: $unit)';
}
@override
@@ -722,6 +1067,8 @@ class _$ProductDtoImpl extends _ProductDto {
other.imageUrl == imageUrl) &&
(identical(other.printerType, printerType) ||
other.printerType == printerType) &&
(identical(other.printToChecker, printToChecker) ||
other.printToChecker == printToChecker) &&
const DeepCollectionEquality().equals(other._metadata, _metadata) &&
(identical(other.isActive, isActive) ||
other.isActive == isActive) &&
@@ -729,12 +1076,19 @@ class _$ProductDtoImpl extends _ProductDto {
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
const DeepCollectionEquality().equals(other._variants, _variants));
const DeepCollectionEquality().equals(other._variants, _variants) &&
(identical(other.sellBy, sellBy) || other.sellBy == sellBy) &&
(identical(other.unitId, unitId) || other.unitId == unitId) &&
(identical(other.unitName, unitName) ||
other.unitName == unitName) &&
(identical(other.unitAbbreviation, unitAbbreviation) ||
other.unitAbbreviation == unitAbbreviation) &&
(identical(other.unit, unit) || other.unit == unit));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
int get hashCode => Object.hashAll([
runtimeType,
id,
organizationId,
@@ -747,12 +1101,18 @@ class _$ProductDtoImpl extends _ProductDto {
businessType,
imageUrl,
printerType,
printToChecker,
const DeepCollectionEquality().hash(_metadata),
isActive,
createdAt,
updatedAt,
const DeepCollectionEquality().hash(_variants),
);
sellBy,
unitId,
unitName,
unitAbbreviation,
unit,
]);
/// Create a copy of ProductDto
/// with the given fields replaced by the non-null parameter values.
@@ -781,11 +1141,17 @@ abstract class _ProductDto extends ProductDto {
@JsonKey(name: "business_type") final String? businessType,
@JsonKey(name: "image_url") final String? imageUrl,
@JsonKey(name: "printer_type") final String? printerType,
@JsonKey(name: "print_to_checker") final bool? printToChecker,
@JsonKey(name: "metadata") final Map<String, dynamic>? metadata,
@JsonKey(name: "is_active") final bool? isActive,
@JsonKey(name: "created_at") final String? createdAt,
@JsonKey(name: "updated_at") final String? updatedAt,
@JsonKey(name: "variants") final List<ProductVariantDto>? variants,
@JsonKey(name: "sell_by") final String? sellBy,
@JsonKey(name: "unit_id") final String? unitId,
@JsonKey(name: "unit_name") final String? unitName,
@JsonKey(name: "unit_abbreviation") final String? unitAbbreviation,
@JsonKey(name: "unit") final ProductUnitDto? unit,
}) = _$ProductDtoImpl;
const _ProductDto._() : super._();
@@ -826,6 +1192,9 @@ abstract class _ProductDto extends ProductDto {
@JsonKey(name: "printer_type")
String? get printerType;
@override
@JsonKey(name: "print_to_checker")
bool? get printToChecker;
@override
@JsonKey(name: "metadata")
Map<String, dynamic>? get metadata;
@override
@@ -840,6 +1209,21 @@ abstract class _ProductDto extends ProductDto {
@override
@JsonKey(name: "variants")
List<ProductVariantDto>? get variants;
@override
@JsonKey(name: "sell_by")
String? get sellBy;
@override
@JsonKey(name: "unit_id")
String? get unitId;
@override
@JsonKey(name: "unit_name")
String? get unitName;
@override
@JsonKey(name: "unit_abbreviation")
String? get unitAbbreviation;
@override
@JsonKey(name: "unit")
ProductUnitDto? get unit;
/// Create a copy of ProductDto
/// with the given fields replaced by the non-null parameter values.
@@ -27,6 +27,21 @@ Map<String, dynamic> _$$ListProductDtoImplToJson(
'total_pages': instance.totalPages,
};
_$ProductUnitDtoImpl _$$ProductUnitDtoImplFromJson(Map<String, dynamic> json) =>
_$ProductUnitDtoImpl(
id: json['id'] as String?,
name: json['name'] as String?,
abbreviation: json['abbreviation'] as String?,
);
Map<String, dynamic> _$$ProductUnitDtoImplToJson(
_$ProductUnitDtoImpl instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'abbreviation': instance.abbreviation,
};
_$ProductDtoImpl _$$ProductDtoImplFromJson(Map<String, dynamic> json) =>
_$ProductDtoImpl(
id: json['id'] as String?,
@@ -40,6 +55,7 @@ _$ProductDtoImpl _$$ProductDtoImplFromJson(Map<String, dynamic> json) =>
businessType: json['business_type'] as String?,
imageUrl: json['image_url'] as String?,
printerType: json['printer_type'] as String?,
printToChecker: json['print_to_checker'] as bool?,
metadata: json['metadata'] as Map<String, dynamic>?,
isActive: json['is_active'] as bool?,
createdAt: json['created_at'] as String?,
@@ -47,6 +63,13 @@ _$ProductDtoImpl _$$ProductDtoImplFromJson(Map<String, dynamic> json) =>
variants: (json['variants'] as List<dynamic>?)
?.map((e) => ProductVariantDto.fromJson(e as Map<String, dynamic>))
.toList(),
sellBy: json['sell_by'] as String?,
unitId: json['unit_id'] as String?,
unitName: json['unit_name'] as String?,
unitAbbreviation: json['unit_abbreviation'] as String?,
unit: json['unit'] == null
? null
: ProductUnitDto.fromJson(json['unit'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$ProductDtoImplToJson(_$ProductDtoImpl instance) =>
@@ -62,11 +85,17 @@ Map<String, dynamic> _$$ProductDtoImplToJson(_$ProductDtoImpl instance) =>
'business_type': instance.businessType,
'image_url': instance.imageUrl,
'printer_type': instance.printerType,
'print_to_checker': instance.printToChecker,
'metadata': instance.metadata,
'is_active': instance.isActive,
'created_at': instance.createdAt,
'updated_at': instance.updatedAt,
'variants': instance.variants,
'sell_by': instance.sellBy,
'unit_id': instance.unitId,
'unit_name': instance.unitName,
'unit_abbreviation': instance.unitAbbreviation,
'unit': instance.unit,
};
_$ProductVariantDtoImpl _$$ProductVariantDtoImplFromJson(
@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../common/service/unit_registry.dart';
import '../../../domain/product/product.dart';
import '../datasources/local_data_provider.dart';
import '../datasources/remote_data_provider.dart';
@@ -12,10 +13,38 @@ import '../product_dtos.dart';
class ProductRepository implements IProductRepository {
final ProductRemoteDataProvider _remoteDataProvider;
final ProductLocalDataProvider _localDataProvider;
final UnitRegistry _unitRegistry;
final _logName = 'ProductRepository';
ProductRepository(this._remoteDataProvider, this._localDataProvider);
ProductRepository(
this._remoteDataProvider,
this._localDataProvider,
this._unitRegistry,
);
/// Response produk hanya dijamin membawa `unit_id`. Lengkapi nama dan
/// singkatannya dari master satuan supaya keranjang bisa menulis "4,2 ons".
Product _withUnit(Product product) {
if (!product.isSoldByWeight || product.unitId.isEmpty) return product;
if (product.unitAbbreviation.isNotEmpty && product.unitName.isNotEmpty) {
return product;
}
final unit = _unitRegistry.byId(product.unitId);
if (unit == null) return product;
return product.copyWith(
unitName: product.unitName.isNotEmpty ? product.unitName : unit.name,
unitAbbreviation: product.unitAbbreviation.isNotEmpty
? product.unitAbbreviation
: unit.abbreviation,
);
}
List<Product> _withUnits(List<Product> products) =>
products.map(_withUnit).toList();
@override
Future<Either<ProductFailure, Product>> getProductById(String id) async {
@@ -33,7 +62,7 @@ class ProductRepository implements IProductRepository {
);
}
final productDomain = product.data!.toDomain();
final productDomain = _withUnit(product.data!.toDomain());
log('✅ Product loaded: ${productDomain.name}', name: _logName);
return Right(productDomain);
@@ -85,7 +114,9 @@ class ProductRepository implements IProductRepository {
// 🧱 Bangun entity domain ListProduct
final result = ListProduct(
products: cachedProducts.data!.map((p) => p.toDomain()).toList(),
products: _withUnits(
cachedProducts.data!.map((p) => p.toDomain()).toList(),
),
totalCount: totalCount,
page: page,
limit: limit,
@@ -132,7 +163,9 @@ class ProductRepository implements IProductRepository {
return left(cachedProducts.error!);
}
final products = cachedProducts.data!.map((p) => p.toDomain()).toList();
final products = _withUnits(
cachedProducts.data!.map((p) => p.toDomain()).toList(),
);
final totalCount = await _localDataProvider.getTotalCount(
categoryId: categoryId,
@@ -180,7 +213,9 @@ class ProductRepository implements IProductRepository {
}
// ✅ Mapping ke domain entity (kalau hasilnya masih berupa DTO)
final products = results.data!.map((p) => p.toDomain()).toList();
final products = _withUnits(
results.data!.map((p) => p.toDomain()).toList(),
);
log(
'✅ Local search completed: ${products.length} results',
@@ -369,7 +404,7 @@ class ProductRepository implements IProductRepository {
final products = result.data!.toDomain();
return right(products);
return right(products.copyWith(products: _withUnits(products.products)));
} catch (e, s) {
log('getProducts', name: _logName, error: e, stackTrace: s);
return left(const ProductFailure.unexpectedError());
@@ -0,0 +1,38 @@
import 'dart:convert';
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../common/constant/local_storage_key.dart';
import '../unit_dtos.dart';
/// Satuan berjumlah sedikit dan jarang berubah, jadi cukup disimpan sebagai
/// satu blob JSON di SharedPreferences — tidak perlu tabel sqflite sendiri.
@injectable
class UnitLocalDataProvider {
final SharedPreferences _sharedPreferences;
UnitLocalDataProvider(this._sharedPreferences);
Future<void> saveUnits(List<UnitDto> units) async {
final jsonString = jsonEncode(units.map((e) => e.toJson()).toList());
await _sharedPreferences.setString(LocalStorageKey.units, jsonString);
}
List<UnitDto> getUnits() {
final jsonString = _sharedPreferences.getString(LocalStorageKey.units);
if (jsonString == null || jsonString.isEmpty) return const [];
final decoded = jsonDecode(jsonString);
if (decoded is! List) return const [];
return decoded
.whereType<Map<String, dynamic>>()
.map(UnitDto.fromJson)
.toList();
}
Future<void> clearUnits() async {
await _sharedPreferences.remove(LocalStorageKey.units);
}
}
@@ -0,0 +1,37 @@
import 'dart:developer';
import 'package:data_channel/data_channel.dart';
import 'package:injectable/injectable.dart';
import '../../../common/api/api_client.dart';
import '../../../common/api/api_failure.dart';
import '../../../common/function/app_function.dart';
import '../../../common/url/api_path.dart';
import '../../../domain/unit/unit.dart';
import '../unit_dtos.dart';
@injectable
class UnitRemoteDataProvider {
final ApiClient _apiClient;
final _logName = 'UnitRemoteDataProvider';
UnitRemoteDataProvider(this._apiClient);
Future<DC<UnitFailure, List<UnitDto>>> fetchUnits() async {
try {
final response = await _apiClient.get(
ApiPath.units,
headers: getAuthorizationHeader(),
);
if (response.data['data'] == null) {
return DC.error(const UnitFailure.empty());
}
return DC.data(UnitDto.listFromResponse(response.data['data']));
} on ApiFailure catch (e, s) {
log('fetchUnitsError', name: _logName, error: e, stackTrace: s);
return DC.error(UnitFailure.serverError(e));
}
}
}
@@ -0,0 +1,59 @@
part of '../unit_dtos.dart';
@freezed
class UnitDto with _$UnitDto {
const UnitDto._();
const factory UnitDto({
@JsonKey(name: "id") String? id,
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "name") String? name,
@JsonKey(name: "abbreviation") String? abbreviation,
@JsonKey(name: "is_active") bool? isActive,
}) = _UnitDto;
factory UnitDto.fromJson(Map<String, dynamic> json) =>
_$UnitDtoFromJson(json);
ProductUnit toDomain() => ProductUnit(
id: id ?? '',
organizationId: organizationId ?? '',
name: name ?? '',
abbreviation: abbreviation ?? '',
isActive: isActive ?? true,
);
factory UnitDto.fromDomain(ProductUnit unit) => UnitDto(
id: unit.id,
organizationId: unit.organizationId,
name: unit.name,
abbreviation: unit.abbreviation,
isActive: unit.isActive,
);
/// Response `/units` belum tentu berbentuk sama di setiap versi backend —
/// bisa `data: [...]` atau `data: { units: [...] }`. Terima keduanya supaya
/// POS tidak kehilangan satuan hanya karena pembungkusnya berbeda.
static List<UnitDto> listFromResponse(dynamic data) {
if (data is List) {
return data
.whereType<Map<String, dynamic>>()
.map(UnitDto.fromJson)
.toList();
}
if (data is Map<String, dynamic>) {
for (final key in const ['units', 'items', 'data']) {
final value = data[key];
if (value is List) {
return value
.whereType<Map<String, dynamic>>()
.map(UnitDto.fromJson)
.toList();
}
}
}
return const [];
}
}
@@ -0,0 +1,56 @@
import 'dart:developer';
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/unit/unit.dart';
import '../datasources/local_data_provider.dart';
import '../datasources/remote_data_provider.dart';
@Injectable(as: IUnitRepository)
class UnitRepository implements IUnitRepository {
final UnitRemoteDataProvider _remoteDataProvider;
final UnitLocalDataProvider _localDataProvider;
final _logName = 'UnitRepository';
UnitRepository(this._remoteDataProvider, this._localDataProvider);
@override
Future<Either<UnitFailure, List<ProductUnit>>> getUnits({
bool forceRefresh = false,
}) async {
try {
final cached = _localDataProvider.getUnits();
if (!forceRefresh && cached.isNotEmpty) {
log('📏 ${cached.length} satuan dari cache', name: _logName);
return right(cached.map((e) => e.toDomain()).toList());
}
final result = await _remoteDataProvider.fetchUnits();
if (result.hasError) {
// Offline bukan alasan untuk kehilangan satuan yang sudah pernah ada.
if (cached.isNotEmpty) {
log('⚠️ Gagal ambil satuan, pakai cache', name: _logName);
return right(cached.map((e) => e.toDomain()).toList());
}
return left(result.error!);
}
final units = result.data!;
await _localDataProvider.saveUnits(units);
log('✅ ${units.length} satuan disinkronkan', name: _logName);
return right(units.map((e) => e.toDomain()).toList());
} catch (e, s) {
log('❌ Error getUnits', name: _logName, error: e, stackTrace: s);
return left(UnitFailure.localStorageError(e.toString()));
}
}
@override
Future<List<ProductUnit>> cachedUnits() async =>
_localDataProvider.getUnits().map((e) => e.toDomain()).toList();
}
+8
View File
@@ -0,0 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/unit/unit.dart';
part 'unit_dtos.freezed.dart';
part 'unit_dtos.g.dart';
part 'dtos/unit_dto.dart';
@@ -0,0 +1,280 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'unit_dtos.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
UnitDto _$UnitDtoFromJson(Map<String, dynamic> json) {
return _UnitDto.fromJson(json);
}
/// @nodoc
mixin _$UnitDto {
@JsonKey(name: "id")
String? get id => throw _privateConstructorUsedError;
@JsonKey(name: "organization_id")
String? get organizationId => throw _privateConstructorUsedError;
@JsonKey(name: "name")
String? get name => throw _privateConstructorUsedError;
@JsonKey(name: "abbreviation")
String? get abbreviation => throw _privateConstructorUsedError;
@JsonKey(name: "is_active")
bool? get isActive => throw _privateConstructorUsedError;
/// Serializes this UnitDto to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of UnitDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$UnitDtoCopyWith<UnitDto> get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $UnitDtoCopyWith<$Res> {
factory $UnitDtoCopyWith(UnitDto value, $Res Function(UnitDto) then) =
_$UnitDtoCopyWithImpl<$Res, UnitDto>;
@useResult
$Res call({
@JsonKey(name: "id") String? id,
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "name") String? name,
@JsonKey(name: "abbreviation") String? abbreviation,
@JsonKey(name: "is_active") bool? isActive,
});
}
/// @nodoc
class _$UnitDtoCopyWithImpl<$Res, $Val extends UnitDto>
implements $UnitDtoCopyWith<$Res> {
_$UnitDtoCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of UnitDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? organizationId = freezed,
Object? name = freezed,
Object? abbreviation = freezed,
Object? isActive = freezed,
}) {
return _then(
_value.copyWith(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String?,
organizationId: freezed == organizationId
? _value.organizationId
: organizationId // ignore: cast_nullable_to_non_nullable
as String?,
name: freezed == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String?,
abbreviation: freezed == abbreviation
? _value.abbreviation
: abbreviation // ignore: cast_nullable_to_non_nullable
as String?,
isActive: freezed == isActive
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool?,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$UnitDtoImplCopyWith<$Res> implements $UnitDtoCopyWith<$Res> {
factory _$$UnitDtoImplCopyWith(
_$UnitDtoImpl value,
$Res Function(_$UnitDtoImpl) then,
) = __$$UnitDtoImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
@JsonKey(name: "id") String? id,
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "name") String? name,
@JsonKey(name: "abbreviation") String? abbreviation,
@JsonKey(name: "is_active") bool? isActive,
});
}
/// @nodoc
class __$$UnitDtoImplCopyWithImpl<$Res>
extends _$UnitDtoCopyWithImpl<$Res, _$UnitDtoImpl>
implements _$$UnitDtoImplCopyWith<$Res> {
__$$UnitDtoImplCopyWithImpl(
_$UnitDtoImpl _value,
$Res Function(_$UnitDtoImpl) _then,
) : super(_value, _then);
/// Create a copy of UnitDto
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = freezed,
Object? organizationId = freezed,
Object? name = freezed,
Object? abbreviation = freezed,
Object? isActive = freezed,
}) {
return _then(
_$UnitDtoImpl(
id: freezed == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String?,
organizationId: freezed == organizationId
? _value.organizationId
: organizationId // ignore: cast_nullable_to_non_nullable
as String?,
name: freezed == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String?,
abbreviation: freezed == abbreviation
? _value.abbreviation
: abbreviation // ignore: cast_nullable_to_non_nullable
as String?,
isActive: freezed == isActive
? _value.isActive
: isActive // ignore: cast_nullable_to_non_nullable
as bool?,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$UnitDtoImpl extends _UnitDto {
const _$UnitDtoImpl({
@JsonKey(name: "id") this.id,
@JsonKey(name: "organization_id") this.organizationId,
@JsonKey(name: "name") this.name,
@JsonKey(name: "abbreviation") this.abbreviation,
@JsonKey(name: "is_active") this.isActive,
}) : super._();
factory _$UnitDtoImpl.fromJson(Map<String, dynamic> json) =>
_$$UnitDtoImplFromJson(json);
@override
@JsonKey(name: "id")
final String? id;
@override
@JsonKey(name: "organization_id")
final String? organizationId;
@override
@JsonKey(name: "name")
final String? name;
@override
@JsonKey(name: "abbreviation")
final String? abbreviation;
@override
@JsonKey(name: "is_active")
final bool? isActive;
@override
String toString() {
return 'UnitDto(id: $id, organizationId: $organizationId, name: $name, abbreviation: $abbreviation, isActive: $isActive)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$UnitDtoImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.organizationId, organizationId) ||
other.organizationId == organizationId) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.abbreviation, abbreviation) ||
other.abbreviation == abbreviation) &&
(identical(other.isActive, isActive) ||
other.isActive == isActive));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
organizationId,
name,
abbreviation,
isActive,
);
/// Create a copy of UnitDto
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$UnitDtoImplCopyWith<_$UnitDtoImpl> get copyWith =>
__$$UnitDtoImplCopyWithImpl<_$UnitDtoImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$UnitDtoImplToJson(this);
}
}
abstract class _UnitDto extends UnitDto {
const factory _UnitDto({
@JsonKey(name: "id") final String? id,
@JsonKey(name: "organization_id") final String? organizationId,
@JsonKey(name: "name") final String? name,
@JsonKey(name: "abbreviation") final String? abbreviation,
@JsonKey(name: "is_active") final bool? isActive,
}) = _$UnitDtoImpl;
const _UnitDto._() : super._();
factory _UnitDto.fromJson(Map<String, dynamic> json) = _$UnitDtoImpl.fromJson;
@override
@JsonKey(name: "id")
String? get id;
@override
@JsonKey(name: "organization_id")
String? get organizationId;
@override
@JsonKey(name: "name")
String? get name;
@override
@JsonKey(name: "abbreviation")
String? get abbreviation;
@override
@JsonKey(name: "is_active")
bool? get isActive;
/// Create a copy of UnitDto
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$UnitDtoImplCopyWith<_$UnitDtoImpl> get copyWith =>
throw _privateConstructorUsedError;
}
+25
View File
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'unit_dtos.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$UnitDtoImpl _$$UnitDtoImplFromJson(Map<String, dynamic> json) =>
_$UnitDtoImpl(
id: json['id'] as String?,
organizationId: json['organization_id'] as String?,
name: json['name'] as String?,
abbreviation: json['abbreviation'] as String?,
isActive: json['is_active'] as bool?,
);
Map<String, dynamic> _$$UnitDtoImplToJson(_$UnitDtoImpl instance) =>
<String, dynamic>{
'id': instance.id,
'organization_id': instance.organizationId,
'name': instance.name,
'abbreviation': instance.abbreviation,
'is_active': instance.isActive,
};
+52 -25
View File
@@ -88,6 +88,8 @@ import 'package:apskel_pos_flutter_v2/common/network/network_client.dart'
import 'package:apskel_pos_flutter_v2/common/service/device_info_service.dart'
as _i288;
import 'package:apskel_pos_flutter_v2/common/service/fcm_service.dart' as _i312;
import 'package:apskel_pos_flutter_v2/common/service/unit_registry.dart'
as _i584;
import 'package:apskel_pos_flutter_v2/domain/analytic/analytic.dart' as _i346;
import 'package:apskel_pos_flutter_v2/domain/auth/auth.dart' as _i776;
import 'package:apskel_pos_flutter_v2/domain/category/category.dart' as _i502;
@@ -99,6 +101,7 @@ import 'package:apskel_pos_flutter_v2/domain/payment_method/payment_method.dart'
import 'package:apskel_pos_flutter_v2/domain/printer/printer.dart' as _i104;
import 'package:apskel_pos_flutter_v2/domain/product/product.dart' as _i44;
import 'package:apskel_pos_flutter_v2/domain/table/table.dart' as _i983;
import 'package:apskel_pos_flutter_v2/domain/unit/unit.dart' as _i320;
import 'package:apskel_pos_flutter_v2/env.dart' as _i923;
import 'package:apskel_pos_flutter_v2/infrastructure/analytic/datasources/remote_data_provider.dart'
as _i708;
@@ -148,6 +151,12 @@ import 'package:apskel_pos_flutter_v2/infrastructure/table/datasources/remote_da
as _i95;
import 'package:apskel_pos_flutter_v2/infrastructure/table/repositories/table_repository.dart'
as _i824;
import 'package:apskel_pos_flutter_v2/infrastructure/unit/datasources/local_data_provider.dart'
as _i393;
import 'package:apskel_pos_flutter_v2/infrastructure/unit/datasources/remote_data_provider.dart'
as _i1016;
import 'package:apskel_pos_flutter_v2/infrastructure/unit/repositories/unit_repository.dart'
as _i228;
import 'package:apskel_pos_flutter_v2/presentation/router/app_router.dart'
as _i800;
import 'package:connectivity_plus/connectivity_plus.dart' as _i895;
@@ -229,11 +238,14 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i693.OutletLocalDatasource>(
() => _i693.OutletLocalDatasource(gh<_i460.SharedPreferences>()),
);
gh.factory<_i393.UnitLocalDataProvider>(
() => _i393.UnitLocalDataProvider(gh<_i460.SharedPreferences>()),
);
gh.lazySingleton<_i457.ApiClient>(
() => _i457.ApiClient(gh<_i361.Dio>(), gh<_i923.Env>()),
);
gh.factory<_i923.Env>(() => _i923.ProdEnv(), registerFor: {_prod});
gh.factory<_i104.IPrinterRepository>(
gh.lazySingleton<_i104.IPrinterRepository>(
() => _i881.PrinterRepository(
gh<_i149.PrinterLocalDataProvider>(),
gh<_i693.OutletLocalDatasource>(),
@@ -279,6 +291,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i95.TableRemoteDataProvider>(
() => _i95.TableRemoteDataProvider(gh<_i457.ApiClient>()),
);
gh.factory<_i1016.UnitRemoteDataProvider>(
() => _i1016.UnitRemoteDataProvider(gh<_i457.ApiClient>()),
);
gh.factory<_i776.IAuthRepository>(
() => _i941.AuthRepository(
gh<_i370.AuthRemoteDataProvider>(),
@@ -291,13 +306,6 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i708.CategoryLocalDataProvider>(),
),
);
gh.factory<_i46.LoginFormBloc>(
() => _i46.LoginFormBloc(
gh<_i776.IAuthRepository>(),
gh<_i312.FcmService>(),
gh<_i288.DeviceInfoService>(),
),
);
gh.factory<_i983.ITableRepository>(
() => _i824.TableRepository(
gh<_i95.TableRemoteDataProvider>(),
@@ -313,12 +321,6 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i424.TableLoaderBloc>(
() => _i424.TableLoaderBloc(gh<_i983.ITableRepository>()),
);
gh.factory<_i44.IProductRepository>(
() => _i763.ProductRepository(
gh<_i707.ProductRemoteDataProvider>(),
gh<_i464.ProductLocalDataProvider>(),
),
);
gh.factory<_i552.IOutletRepository>(
() => _i845.OutletRepository(
gh<_i132.OutletRemoteDataProvider>(),
@@ -342,6 +344,12 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i552.IOutletRepository>(),
),
);
gh.factory<_i320.IUnitRepository>(
() => _i228.UnitRepository(
gh<_i1016.UnitRemoteDataProvider>(),
gh<_i393.UnitLocalDataProvider>(),
),
);
gh.factory<_i143.ICustomerRepository>(
() => _i385.CustomerRepository(gh<_i841.CustomerRemoteDataProvider>()),
);
@@ -363,6 +371,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i822.VoidFormBloc>(
() => _i822.VoidFormBloc(gh<_i299.IOrderRepository>()),
);
gh.lazySingleton<_i584.UnitRegistry>(
() => _i584.UnitRegistry(gh<_i320.IUnitRepository>()),
);
gh.factory<_i683.CustomerLoaderBloc>(
() => _i683.CustomerLoaderBloc(gh<_i143.ICustomerRepository>()),
);
@@ -381,19 +392,19 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i76.OutletLoaderBloc>(
() => _i76.OutletLoaderBloc(gh<_i552.IOutletRepository>()),
);
gh.factory<_i13.ProductLoaderBloc>(
() => _i13.ProductLoaderBloc(gh<_i44.IProductRepository>()),
);
gh.factory<_i741.SyncBloc>(
() => _i741.SyncBloc(
gh<_i44.IProductRepository>(),
gh<_i502.ICategoryRepository>(),
gh.factory<_i46.LoginFormBloc>(
() => _i46.LoginFormBloc(
gh<_i776.IAuthRepository>(),
gh<_i312.FcmService>(),
gh<_i288.DeviceInfoService>(),
gh<_i584.UnitRegistry>(),
),
);
gh.factory<_i729.SyncSettingBloc>(
() => _i729.SyncSettingBloc(
gh<_i44.IProductRepository>(),
gh<_i502.ICategoryRepository>(),
gh.factory<_i44.IProductRepository>(
() => _i763.ProductRepository(
gh<_i707.ProductRemoteDataProvider>(),
gh<_i464.ProductLocalDataProvider>(),
gh<_i584.UnitRegistry>(),
),
);
gh.factory<_i911.CategoryAnalyticLoaderBloc>(
@@ -419,6 +430,22 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i413.SalesAnalyticLoaderBloc>(
() => _i413.SalesAnalyticLoaderBloc(gh<_i346.IAnalyticRepository>()),
);
gh.factory<_i729.SyncSettingBloc>(
() => _i729.SyncSettingBloc(
gh<_i44.IProductRepository>(),
gh<_i502.ICategoryRepository>(),
gh<_i584.UnitRegistry>(),
),
);
gh.factory<_i13.ProductLoaderBloc>(
() => _i13.ProductLoaderBloc(gh<_i44.IProductRepository>()),
);
gh.factory<_i741.SyncBloc>(
() => _i741.SyncBloc(
gh<_i44.IProductRepository>(),
gh<_i502.ICategoryRepository>(),
),
);
return this;
}
}
+7 -4
View File
@@ -5,9 +5,9 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:injectable/injectable.dart';
import 'common/service/fcm_service.dart';
import 'common/service/unit_registry.dart';
import 'injection.dart';
import 'presentation/app_widget.dart';
@@ -44,13 +44,16 @@ void main() async {
debugPrint = (message, {wrapWidth}) => '';
}
await configureDependencies(
kReleaseMode ? Environment.prod : Environment.dev,
);
const String env = String.fromEnvironment('ENV', defaultValue: 'dev');
await configureDependencies(env);
// Inisialisasi FCM setelah DI siap
await getIt<FcmService>().initialize();
// Master satuan dimuat dari cache lebih dulu supaya produk timbangan
// langsung punya label satuan, bahkan saat perangkat offline.
unawaited(getIt<UnitRegistry>().load());
runApp(const AppWidget());
},
(error, stack) {
+18 -4
View File
@@ -13,8 +13,6 @@ import '../application/payment_method/payment_method_loader/payment_method_loade
import '../application/printer/bluetooth/bluetooth_connect/bluetooth_connect_bloc.dart';
import '../application/printer/bluetooth/bluetooth_loader/bluetooth_loader_bloc.dart';
import '../application/printer/print_struck/print_struck_bloc.dart';
import '../application/printer/printer_form/printer_form_bloc.dart';
import '../application/printer/printer_loader/printer_loader_bloc.dart';
import '../application/product/product_loader/product_loader_bloc.dart';
import '../application/sync/sync_setting/sync_setting_bloc.dart';
import '../application/table/table_form/table_form_bloc.dart';
@@ -23,6 +21,7 @@ import '../application/void/void_form/void_form_bloc.dart';
import '../common/theme/theme.dart';
import '../common/constant/app_constant.dart';
import '../injection.dart';
import 'components/dialog/dialog.dart';
import 'router/app_router.dart';
import 'router/app_router_observer.dart';
@@ -54,8 +53,9 @@ class _AppWidgetState extends State<AppWidget> {
BlocProvider(create: (context) => getIt<VoidFormBloc>()),
BlocProvider(create: (context) => getIt<BluetoothLoaderBloc>()),
BlocProvider(create: (context) => getIt<BluetoothConnectBloc>()),
BlocProvider(create: (context) => getIt<PrinterFormBloc>()),
BlocProvider(create: (context) => getIt<PrinterLoaderBloc>()),
// PrinterFormBloc dan PrinterLoaderBloc sengaja TIDAK global: tiap tab
// pengaturan printer memakai instance sendiri, lihat
// SettingPrinterSection.
BlocProvider(create: (context) => getIt<PrintStruckBloc>()),
BlocProvider(create: (context) => getIt<SyncSettingBloc>()),
BlocProvider(create: (context) => getIt<LogoutBloc>()),
@@ -67,6 +67,20 @@ class _AppWidgetState extends State<AppWidget> {
routerConfig: _appRouter.config(
navigatorObservers: () => <NavigatorObserver>[AppRouteObserver()],
),
// Satu listener untuk seluruh aplikasi: cetak bisa dipicu dari banyak
// halaman, dan `PrintStruckBloc` hidup di atas router jadi prosesnya
// tetap jalan walau kasir sudah pindah halaman.
builder: (context, child) => BlocListener<PrintStruckBloc, PrintStruckState>(
listenWhen: (previous, current) => previous.jobId != current.jobId,
listener: (_, __) {
// Dialog harus dibuka dari context di bawah Navigator, sementara
// builder ini berada di atasnya.
final navigatorContext = _appRouter.navigatorKey.currentContext;
if (navigatorContext == null) return;
showPrintProgressDialog(navigatorContext);
},
child: child ?? const SizedBox.shrink(),
),
),
);
}
+109 -63
View File
@@ -5,6 +5,7 @@ import '../../../application/checkout/checkout_form/checkout_form_bloc.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/product/product.dart';
import '../dialog/dialog.dart';
import '../image/image.dart';
import '../spaces/space.dart';
@@ -26,8 +27,8 @@ class _OrderMenuState extends State<OrderMenu> {
_controller.addListener(() {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.updateItemNotes(
widget.data.product,
CheckoutFormEvent.updateLineNotes(
widget.data.lineId,
_controller.text,
),
);
@@ -40,6 +41,29 @@ class _OrderMenuState extends State<OrderMenu> {
super.dispose();
}
void _removeLine() {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.removeLine(widget.data.lineId),
);
}
/// Koreksi salah timbang: ubah angka pada baris yang sama, bukan menambah
/// baris baru.
void _editWeight() {
final bloc = context.read<CheckoutFormBloc>();
showDialog(
context: context,
builder: (context) => WeightInputDialog(
product: widget.data.product,
variant: widget.data.variant,
initialWeight: widget.data.weight,
onSubmit: (weight) => bloc.add(
CheckoutFormEvent.updateItemWeight(widget.data.lineId, weight),
),
),
);
}
@override
Widget build(BuildContext context) {
return Column(
@@ -77,9 +101,10 @@ class _OrderMenuState extends State<OrderMenu> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(widget.data.product.price +
(widget.data.variant?.priceModifier ?? 0))
.currencyFormatRp,
widget.data.isWeighted &&
widget.data.product.unitLabel.isNotEmpty
? '${widget.data.unitPrice.currencyFormatRp} / ${widget.data.product.unitLabel}'
: widget.data.unitPrice.currencyFormatRp,
),
if (widget.data.variant != null)
Text(widget.data.variant?.name ?? ""),
@@ -87,60 +112,15 @@ class _OrderMenuState extends State<OrderMenu> {
),
),
),
Row(
children: [
GestureDetector(
onTap: () {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.removeItem(
widget.data.product,
widget.data.variant,
),
);
},
child: Container(
width: 30,
height: 30,
color: AppColor.white,
child: const Icon(
Icons.remove_circle,
color: AppColor.primary,
),
),
),
SizedBox(
width: 30.0,
child: Center(child: Text(widget.data.quantity.toString())),
),
GestureDetector(
onTap: () {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.addItem(
widget.data.product,
widget.data.variant,
),
);
},
child: Container(
width: 30,
height: 30,
color: AppColor.white,
child: const Icon(
Icons.add_circle,
color: AppColor.primary,
),
),
),
],
),
if (widget.data.isWeighted)
_buildWeightControl()
else
_buildQuantityStepper(),
const SpaceWidth(8),
SizedBox(
width: 80.0,
child: Text(
((widget.data.product.price +
(widget.data.variant?.priceModifier ?? 0)) *
widget.data.quantity)
.currencyFormatRp,
widget.data.lineTotal.currencyFormatRp,
textAlign: TextAlign.right,
style: const TextStyle(
color: AppColor.primary,
@@ -167,14 +147,7 @@ class _OrderMenuState extends State<OrderMenu> {
),
const SpaceWidth(16.0),
GestureDetector(
onTap: () {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.removeItem(
widget.data.product,
widget.data.variant,
),
);
},
onTap: _removeLine,
child: Container(
height: 40,
width: 40,
@@ -194,4 +167,77 @@ class _OrderMenuState extends State<OrderMenu> {
],
);
}
/// Baris berbobot tidak punya stepper — nilainya bukan cacah, dan angka 1
/// yang tersimpan di `quantity` akan terbaca seperti "satu ons".
Widget _buildWeightControl() {
return GestureDetector(
onTap: _editWeight,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.08),
borderRadius: BorderRadius.circular(8.0),
border: Border.all(color: AppColor.primary.withOpacity(0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.data.quantityLabel,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppColor.primary,
),
),
const SpaceWidth(4),
const Icon(Icons.edit_outlined, size: 14, color: AppColor.primary),
],
),
),
);
}
Widget _buildQuantityStepper() {
return Row(
children: [
GestureDetector(
onTap: () {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.removeItem(
widget.data.product,
widget.data.variant,
),
);
},
child: Container(
width: 30,
height: 30,
color: AppColor.white,
child: const Icon(Icons.remove_circle, color: AppColor.primary),
),
),
SizedBox(
width: 30.0,
child: Center(child: Text(widget.data.quantity.toString())),
),
GestureDetector(
onTap: () {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.addItem(
widget.data.product,
widget.data.variant,
),
);
},
child: Container(
width: 30,
height: 30,
color: AppColor.white,
child: const Icon(Icons.add_circle, color: AppColor.primary),
),
),
],
);
}
}
@@ -17,18 +17,35 @@ class ProductCard extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
if (product.isActive == true) {
if (product.variants.isEmpty) {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.addItem(product, null),
);
} else {
showDialog(
context: context,
builder: (context) => VariantDialog(product: product),
);
}
if (product.isActive != true) return;
if (product.variants.isNotEmpty) {
showDialog(
context: context,
builder: (context) => VariantDialog(product: product),
);
return;
}
// Produk timbangan tidak bisa langsung masuk keranjang — kasir harus
// menimbang dulu, dan tiap penimbangan jadi barisnya sendiri.
if (product.isSoldByWeight) {
final bloc = context.read<CheckoutFormBloc>();
showDialog(
context: context,
builder: (context) => WeightInputDialog(
product: product,
onSubmit: (weight) => bloc.add(
CheckoutFormEvent.addWeightedItem(product, null, weight),
),
),
);
return;
}
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.addItem(product, null),
);
},
child: Container(
decoration: BoxDecoration(
@@ -75,7 +92,11 @@ class ProductCard extends StatelessWidget {
Align(
alignment: Alignment.center,
child: Text(
product.price.currencyFormatRp,
product.isSoldByWeight
? '${product.price.currencyFormatRp} ${product.priceUnitLabel}'
.trim()
: product.price.currencyFormatRp,
textAlign: TextAlign.center,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
@@ -89,31 +110,39 @@ class ProductCard extends StatelessWidget {
BlocBuilder<CheckoutFormBloc, CheckoutFormState>(
builder: (context, state) {
final totalQuantity = state.items
final lines = state.items
.where((item) => item.product.id == product.id)
.map((item) => item.quantity)
.fold(0, (sum, qty) => sum + qty);
.toList();
if (lines.isEmpty) return const SizedBox.shrink();
// Untuk produk timbangan, jumlah baris tidak berarti apa-apa
// (selalu 1 per penimbangan) — yang informatif adalah total
// berat yang sudah masuk keranjang.
final label = product.isSoldByWeight
? '${lines.fold<double>(0, (sum, item) => sum + (item.weight ?? 0)).weightFormat} ${product.unitLabel}'
.trim()
: lines
.fold<int>(0, (sum, item) => sum + item.quantity)
.toString();
if (totalQuantity == 0) {
return const SizedBox.shrink();
}
return Positioned(
top: 4,
right: 4,
child: Container(
width: 40,
constraints: const BoxConstraints(minWidth: 40),
height: 40,
padding: const EdgeInsets.all(6),
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(9.0)),
color: AppColor.primary,
),
child: Center(
child: Text(
totalQuantity.toString(),
style: const TextStyle(
label,
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontSize: product.isSoldByWeight ? 13 : 20,
fontWeight: FontWeight.bold,
),
),
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -6,11 +8,13 @@ import '../../../application/checkout/checkout_form/checkout_form_bloc.dart';
import '../../../application/outlet/outlet_loader/outlet_loader_bloc.dart';
import '../../../application/printer/bluetooth/bluetooth_connect/bluetooth_connect_bloc.dart';
import '../../../application/printer/bluetooth/bluetooth_loader/bluetooth_loader_bloc.dart';
import '../../../application/printer/print_struck/print_struck_bloc.dart';
import '../../../common/extension/extension.dart';
import '../../../common/function/app_function.dart';
import '../../../common/theme/theme.dart';
import '../../../common/types/order_type.dart';
import '../../../domain/delivery/delivery.dart';
import '../../../domain/printer/printer.dart';
import '../../../domain/product/product.dart';
import '../button/button.dart';
import '../card/outlet_card.dart';
@@ -20,6 +24,8 @@ import '../spaces/space.dart';
part 'custom_modal_dialog.dart';
part 'outlet_dialog.dart';
part 'variant_dialog.dart';
part 'weight_input_dialog.dart';
part 'delivery_dialog.dart';
part 'order_type_dialog.dart';
part 'printer_bluetooth_dialog.dart';
part 'print_progress_dialog.dart';
@@ -163,7 +163,9 @@ class OrderSaveConfirmDialog extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
Text(
'${item.quantity} qty',
item.isWeighted
? item.quantityLabel
: '${item.quantity} qty',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
@@ -185,8 +187,7 @@ class OrderSaveConfirmDialog extends StatelessWidget {
),
),
child: Text(
((item.product.price) * item.quantity)
.currencyFormatRpV2,
item.lineTotal.currencyFormatRpV2,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.primary,
@@ -28,18 +28,20 @@ class OrderVoidConfirmDialog extends StatelessWidget {
children: [
Expanded(
child: AppElevatedButton.outlined(
onPressed: () => context.maybePop(),
onPressed: state.isSubmitting ? null : () => context.maybePop(),
label: 'Batal',
),
),
const SpaceWidth(16),
Expanded(
child: AppElevatedButton.filled(
onPressed: () {
context.read<VoidFormBloc>().add(
const VoidFormEvent.submitted(),
);
},
onPressed: state.isSubmitting
? null
: () {
context.read<VoidFormBloc>().add(
const VoidFormEvent.submitted(),
);
},
label: state.voidType.isAll ? 'Void Pesanan' : 'Void Item',
isLoading: state.isSubmitting,
),
@@ -0,0 +1,273 @@
part of 'dialog.dart';
/// Dijaga supaya satu job cetak hanya membuka satu dialog, walau listener
/// terpicu lebih dari sekali.
bool _isPrintProgressDialogOpen = false;
Future<void> showPrintProgressDialog(BuildContext context) async {
if (_isPrintProgressDialogOpen) return;
_isPrintProgressDialogOpen = true;
try {
await showDialog<void>(
context: context,
// Cetak jalan di latar belakang, kasir tidak perlu menunggui dialognya.
barrierDismissible: true,
builder: (_) => const PrintProgressDialog(),
);
} finally {
_isPrintProgressDialogOpen = false;
}
}
class PrintProgressDialog extends StatefulWidget {
const PrintProgressDialog({super.key});
@override
State<PrintProgressDialog> createState() => _PrintProgressDialogState();
}
class _PrintProgressDialogState extends State<PrintProgressDialog> {
Timer? _autoClose;
@override
void dispose() {
_autoClose?.cancel();
super.dispose();
}
/// Semua station keluar — tutup sendiri supaya antrean kasir tidak tertahan,
/// tapi beri jeda sebentar agar centang hijaunya sempat terbaca.
void _scheduleAutoClose() {
_autoClose?.cancel();
_autoClose = Timer(const Duration(milliseconds: 1500), () {
if (mounted) Navigator.of(context).pop();
});
}
@override
Widget build(BuildContext context) {
return BlocConsumer<PrintStruckBloc, PrintStruckState>(
listenWhen: (previous, current) =>
previous.isPrinting && !current.isPrinting,
listener: (context, state) {
if (state.hasFailure) return;
_scheduleAutoClose();
},
builder: (context, state) {
final isDone = !state.isPrinting;
return CustomModalDialog(
title: isDone ? 'Hasil Cetak' : 'Mencetak Struk',
maxWidth: 480,
minHeight: 0,
contentPadding: const EdgeInsets.all(20),
bottom: _buildFooter(context, state),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSummary(state),
const SpaceHeight(16),
for (final result in state.stations) ...[
_StationRow(result: result),
const SpaceHeight(8),
],
],
),
);
},
);
}
Widget _buildSummary(PrintStruckState state) {
final isDone = !state.isPrinting;
final total = state.plannedCount;
final success = state.successCount;
final Color color;
final String message;
if (!isDone) {
color = AppColor.primary;
message = 'Mengirim ke printer…';
} else if (state.hasFailure) {
color = AppColor.error;
message = '${state.failedStations.length} station gagal dicetak';
} else {
color = AppColor.success;
message = 'Semua struk berhasil dicetak';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.25)),
),
child: Row(
children: [
Expanded(
child: Text(
message,
style: AppStyle.md.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
),
Text(
'$success dari $total',
style: AppStyle.md.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
Widget _buildFooter(BuildContext context, PrintStruckState state) {
final isDone = !state.isPrinting;
return Container(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isDone) ...[
Text(
'Cetak berjalan di latar belakang. Dialog ini boleh ditutup.',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
textAlign: TextAlign.center,
),
const SpaceHeight(12),
],
Row(
children: [
Expanded(
child: AppElevatedButton.outlined(
onPressed: () => Navigator.of(context).pop(),
label: 'Tutup',
),
),
if (isDone && state.hasFailure) ...[
const SpaceWidth(12),
Expanded(
flex: 2,
child: AppElevatedButton.filled(
onPressed: () => context.read<PrintStruckBloc>().add(
const PrintStruckEvent.retryFailed(),
),
label: 'Cetak Ulang yang Gagal',
icon: const Icon(
Icons.refresh_rounded,
color: AppColor.white,
size: 18,
),
),
),
],
],
),
],
),
);
}
}
class _StationRow extends StatelessWidget {
final PrintStationResult result;
const _StationRow({required this.result});
@override
Widget build(BuildContext context) {
final color = _color;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: result.isFailed ? color.withOpacity(0.4) : AppColor.border,
),
color: result.isFailed ? color.withOpacity(0.05) : Colors.transparent,
),
child: Row(
children: [
SizedBox(width: 20, height: 20, child: Center(child: _icon)),
const SpaceWidth(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
result.station.label,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
),
const SpaceHeight(2),
Text(
_statusText,
style: AppStyle.sm.copyWith(color: color),
),
],
),
),
if (result.elapsed > Duration.zero) ...[
const SpaceWidth(8),
Text(
'${(result.elapsed.inMilliseconds / 1000).toStringAsFixed(1)}s',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
],
],
),
);
}
Widget get _icon => switch (result.status) {
PrintStationStatus.printing => const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
PrintStationStatus.pending => const Icon(
Icons.schedule_rounded,
size: 18,
color: AppColor.textSecondary,
),
PrintStationStatus.success => const Icon(
Icons.check_circle_rounded,
size: 20,
color: AppColor.success,
),
PrintStationStatus.failed => const Icon(
Icons.error_rounded,
size: 20,
color: AppColor.error,
),
PrintStationStatus.skipped => const Icon(
Icons.remove_circle_outline_rounded,
size: 18,
color: AppColor.textSecondary,
),
};
Color get _color => switch (result.status) {
PrintStationStatus.printing => AppColor.primary,
PrintStationStatus.success => AppColor.success,
PrintStationStatus.failed => AppColor.error,
PrintStationStatus.pending ||
PrintStationStatus.skipped => AppColor.textSecondary,
};
String get _statusText => switch (result.status) {
PrintStationStatus.pending => 'Menunggu giliran',
PrintStationStatus.printing => 'Mengirim ke printer…',
PrintStationStatus.success => 'Berhasil dicetak',
PrintStationStatus.failed => result.message ?? 'Gagal dicetak',
PrintStationStatus.skipped => result.message ?? 'Dilewati',
};
}
@@ -21,10 +21,33 @@ class VariantDialog extends StatelessWidget {
return GestureDetector(
onTap: () {
// Aksi saat varian dipilih
context.maybePop();
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.addItem(product, variant),
);
final bloc = context.read<CheckoutFormBloc>();
// Context dialog ini mati begitu di-pop, jadi dialog lanjutan
// dibuka lewat context milik Navigator.
final navigator = Navigator.of(context, rootNavigator: true);
navigator.pop();
// Produk timbangan lanjut ke papan angka; varian hanya mengubah
// harga per satuannya.
if (product.isSoldByWeight) {
showDialog(
context: navigator.context,
builder: (context) => WeightInputDialog(
product: product,
variant: variant,
onSubmit: (weight) => bloc.add(
CheckoutFormEvent.addWeightedItem(
product,
variant,
weight,
),
),
),
);
return;
}
bloc.add(CheckoutFormEvent.addItem(product, variant));
},
child: Container(
width: (context.deviceWidth * 0.4 - 12 - 32) / 2 - 6, // 2 per row
@@ -0,0 +1,267 @@
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),
),
),
);
}
}
+109 -26
View File
@@ -6,6 +6,27 @@ import '../../../domain/outlet/outlet.dart';
import 'receipt_component_builder.dart';
class PrintUi {
/// `CapabilityProfile.load()` membaca dan mem-parse aset tiap kali dipanggil,
/// dan sekali cetak semua station memanggilnya sampai lima kali. Hasilnya
/// sama untuk seluruh aplikasi, jadi cukup dimuat satu kali.
static Future<CapabilityProfile>? _cachedProfile;
static Future<CapabilityProfile> _capabilityProfile() async {
final cached = _cachedProfile;
if (cached != null) return cached;
final future = CapabilityProfile.load();
_cachedProfile = future;
try {
return await future;
} catch (_) {
// Jangan kunci kegagalan sesaat — biarkan cetakan berikutnya mencoba lagi.
_cachedProfile = null;
rethrow;
}
}
Future<List<int>> printOrder({
required Order order,
required Outlet outlet,
@@ -14,7 +35,7 @@ class PrintUi {
}) async {
List<int> bytes = [];
final profile = await CapabilityProfile.load();
final profile = await _capabilityProfile();
final generator = Generator(
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
profile,
@@ -30,6 +51,9 @@ class PrintUi {
outletName: outlet.name,
address: outlet.address,
phoneNumber: outlet.phoneNumber,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.orderInfo(
@@ -38,9 +62,14 @@ class PrintUi {
cashierName: cashierName,
paymentMethod: order.payments.last.paymentMethodName,
tableNumber: order.tableNumber,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.orderType(order.orderType);
bytes += builder.orderType(order.orderType, fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,);
bytes += builder.emptyLines(1);
@@ -48,10 +77,14 @@ class PrintUi {
bytes += builder.orderItem(
productName: item.productName,
quantity: item.quantity,
quantityLabel: item.isWeighted ? item.quantityLabel : null,
unitPrice: item.unitPrice.currencyFormatRpV2,
totalPrice: item.totalPrice.currencyFormatRpV2,
variantName: item.productVariantName,
notes: item.notes,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
}
@@ -61,6 +94,9 @@ class PrintUi {
discount: order.discountAmount.currencyFormatRpV2,
total: order.totalAmount.currencyFormatRpV2,
paid: order.totalPaid.currencyFormatRpV2,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.footer();
@@ -76,7 +112,7 @@ class PrintUi {
}) async {
List<int> bytes = [];
final profile = await CapabilityProfile.load();
final profile = await _capabilityProfile();
final generator = Generator(
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
profile,
@@ -90,26 +126,22 @@ class PrintUi {
// Header
bytes += builder.textCenter('Table Checker', bold: true);
bytes += builder.separator();
bytes += builder.textCenter(
'Table : ${order.tableNumber.isNotEmpty ? order.tableNumber : '-'}',
bold: true,
);
bytes += builder.separator();
bytes += builder.tableName(order.tableNumber.isNotEmpty ? order.tableNumber : '-', height: PosTextSize.size3, width: PosTextSize.size3);
// Order info — label : value, left aligned
bytes += builder.orderInfoSimple(
orderNumber: order.orderNumber,
orderType: order.orderType,
cashierName: cashierName,
customerName: order.metadata['customer_name'] ?? '-',
);
bytes += builder.separator();
// Items — qty NAMA (uppercase), variant indent
for (final item in order.orderItems) {
for (final item in order.orderItems.where((e) => e.printToChecker)) {
final name = item.productName.toUpperCase();
bytes += builder.itemText('${item.quantity} $name');
bytes += builder.itemText('${item.quantityLabel} $name');
if (item.productVariantName.isNotEmpty) {
bytes += builder.itemText(' ${item.productVariantName.toUpperCase()}', bold: false);
}
@@ -132,7 +164,7 @@ class PrintUi {
}) async {
List<int> bytes = [];
final profile = await CapabilityProfile.load();
final profile = await _capabilityProfile();
final generator = Generator(
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
profile,
@@ -146,7 +178,7 @@ class PrintUi {
bytes += generator.reset();
// Header
bytes += builder.row2Columns('Kitchen', order.orderType, bold: true);
bytes += builder.row2Columns('Kitchen', order.orderType.toUpperCase(), bold: true);
bytes += builder.separator();
bytes += builder.textCenter(
'Table : ${order.tableNumber.isNotEmpty ? order.tableNumber : '-'}',
@@ -158,13 +190,14 @@ class PrintUi {
bytes += builder.orderInfoSimple(
orderNumber: order.orderNumber,
cashierName: cashierName,
customerName: order.metadata['customer_name'] ?? '-',
);
bytes += builder.separator();
// Single item
final name = item.productName.toUpperCase();
bytes += builder.itemText('${item.quantity} $name');
bytes += builder.itemText('${item.quantityLabel} $name');
if (item.productVariantName.isNotEmpty) {
bytes += builder.itemText(' ${item.productVariantName.toUpperCase()}', bold: false);
}
@@ -187,7 +220,7 @@ class PrintUi {
}) async {
List<int> bytes = [];
final profile = await CapabilityProfile.load();
final profile = await _capabilityProfile();
final generator = Generator(
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
profile,
@@ -201,7 +234,7 @@ class PrintUi {
bytes += generator.reset();
// Header
bytes += builder.textCenter('Bar', bold: true);
bytes += builder.row2Columns('Bar', order.orderType.toUpperCase(), bold: true);
bytes += builder.separator();
bytes += builder.textCenter(
'Table : ${order.tableNumber.isNotEmpty ? order.tableNumber : '-'}',
@@ -212,7 +245,7 @@ class PrintUi {
// Order info
bytes += builder.orderInfoSimple(
orderNumber: order.orderNumber,
orderType: order.orderType,
customerName: order.metadata['customer_name'] ?? '-',
cashierName: cashierName,
);
@@ -220,7 +253,7 @@ class PrintUi {
// Single item
final name = item.productName.toUpperCase();
bytes += builder.itemText('${item.quantity} $name');
bytes += builder.itemText('${item.quantityLabel} $name');
if (item.productVariantName.isNotEmpty) {
bytes += builder.itemText(' ${item.productVariantName.toUpperCase()}', bold: false);
}
@@ -242,7 +275,7 @@ class PrintUi {
}) async {
List<int> bytes = [];
final profile = await CapabilityProfile.load();
final profile = await _capabilityProfile();
final generator = Generator(
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
profile,
@@ -283,7 +316,7 @@ class PrintUi {
}) async {
List<int> bytes = [];
final profile = await CapabilityProfile.load();
final profile = await _capabilityProfile();
final generator = Generator(
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
profile,
@@ -299,6 +332,9 @@ class PrintUi {
outletName: outlet.name,
address: outlet.address,
phoneNumber: outlet.phoneNumber,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.orderInfo(
@@ -309,9 +345,14 @@ class PrintUi {
? null
: order.payments.last.paymentMethodName,
tableNumber: order.tableNumber,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.orderType(order.orderType);
bytes += builder.orderType(order.orderType, fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,);
bytes += builder.emptyLines(1);
@@ -319,10 +360,14 @@ class PrintUi {
bytes += builder.orderItem(
productName: item.productName,
quantity: item.quantity,
quantityLabel: item.isWeighted ? item.quantityLabel : null,
unitPrice: item.unitPrice.currencyFormatRpV2,
totalPrice: item.totalPrice.currencyFormatRpV2,
variantName: item.productVariantName,
notes: item.notes,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
}
@@ -332,6 +377,9 @@ class PrintUi {
discount: order.discountAmount.currencyFormatRpV2,
total: order.totalAmount.currencyFormatRpV2,
paid: order.totalPaid.currencyFormatRpV2,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.footer(message: 'Kasir');
@@ -347,7 +395,7 @@ class PrintUi {
}) async {
List<int> bytes = [];
final profile = await CapabilityProfile.load();
final profile = await _capabilityProfile();
final generator = Generator(
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
profile,
@@ -363,6 +411,9 @@ class PrintUi {
outletName: outlet.name,
address: outlet.address,
phoneNumber: outlet.phoneNumber,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.dateTime(DateTime.now());
@@ -375,9 +426,14 @@ class PrintUi {
? null
: order.payments.last.paymentMethodName,
tableNumber: order.tableNumber,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.orderType('Void');
bytes += builder.orderType('Void', fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,);
bytes += builder.emptyLines(1);
@@ -385,10 +441,14 @@ class PrintUi {
bytes += builder.orderItem(
productName: item.productName,
quantity: item.quantity,
quantityLabel: item.isWeighted ? item.quantityLabel : null,
unitPrice: item.unitPrice.currencyFormatRpV2,
totalPrice: item.totalPrice.currencyFormatRpV2,
variantName: item.productVariantName,
notes: item.notes,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
}
bytes += builder.summary(
@@ -397,6 +457,9 @@ class PrintUi {
discount: order.discountAmount.currencyFormatRpV2,
total: order.totalAmount.currencyFormatRpV2,
paid: order.totalPaid.currencyFormatRpV2,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.footer(message: 'Kasir');
@@ -412,7 +475,7 @@ class PrintUi {
}) async {
List<int> bytes = [];
final profile = await CapabilityProfile.load();
final profile = await _capabilityProfile();
final generator = Generator(
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
profile,
@@ -428,9 +491,14 @@ class PrintUi {
outletName: outlet.name,
address: outlet.address,
phoneNumber: outlet.phoneNumber,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.dateTime(DateTime.now());
bytes += builder.dateTime(DateTime.now(), fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,);
bytes += builder.orderInfo(
orderNumber: order.orderNumber,
@@ -440,13 +508,21 @@ class PrintUi {
? null
: order.payments.last.paymentMethodName,
tableNumber: order.tableNumber,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.orderType('Split');
bytes += builder.orderType('Split', fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,);
bytes += builder.row2Columns(
'Split',
'${order.payments.last.splitNumber} / ${order.payments.last.splitTotal}',
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.separator();
@@ -457,10 +533,14 @@ class PrintUi {
bytes += builder.orderItem(
productName: item.productName,
quantity: item.quantity,
quantityLabel: item.isWeighted ? item.quantityLabel : null,
unitPrice: item.unitPrice.currencyFormatRpV2,
totalPrice: item.totalPrice.currencyFormatRpV2,
variantName: item.productVariantName,
notes: item.notes,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
}
@@ -472,6 +552,9 @@ class PrintUi {
discount: 0.currencyFormatRpV2,
total: order.payments.last.amount.currencyFormatRpV2,
paid: order.payments.last.amount.currencyFormatRpV2,
fontType: PosFontType.fontA,
width: PosTextSize.size1,
height: PosTextSize.size1,
);
bytes += builder.footer(message: 'Terima Kasih');
@@ -9,26 +9,26 @@ class ReceiptComponentBuilder {
ReceiptComponentBuilder({required this.generator, this.paperSize = 58});
/// Helper: returns size2 for 80mm paper, size1 for 58mm
PosTextSize get _titleSize =>
paperSize == 80 ? PosTextSize.size3 : PosTextSize.size1;
/// Font type per paper size — easy to change here
/// 58mm → fontA, 80mm → fontA
PosFontType get _font =>
paperSize == 80 ? PosFontType.fontA : PosFontType.fontA;
paperSize == 80 ? PosFontType.fontB : PosFontType.fontA;
/// Text size for body — height size2 for 80mm to appear taller, size1 for 58mm
PosTextSize get _bodySize =>
PosTextSize get _bodyHeight =>
paperSize == 80 ? PosTextSize.size2 : PosTextSize.size1;
/// Body width stays size1 always to prevent overflow
PosTextSize get _bodyWidth => PosTextSize.size1;
PosTextSize get _bodyWidth => paperSize == 80 ? PosTextSize.size2 : PosTextSize.size1;
/// Characters per line based on paper size (always size1 font)
String get _separatorLine => paperSize == 80
? '------------------------------------------------'
: '--------------------------------';
/// Characters per line based on paper size and font
String get _separatorLine {
if (paperSize == 80) {
return _font == PosFontType.fontB
? '----------------------------------------------------------------'
: '------------------------------------------------';
} else {
return _font == PosFontType.fontB
? '------------------------------------------'
: '--------------------------------';
}
}
// ---------------------------------------------------------------------------
// Basic text
@@ -37,43 +37,48 @@ class ReceiptComponentBuilder {
List<int> textCenter(
String text, {
bool bold = false,
PosTextSize height = PosTextSize.size1,
PosTextSize width = PosTextSize.size1,
PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,
}) {
return generator.text(
text,
styles: PosStyles(
bold: bold,
align: PosAlign.center,
height: height,
width: width,
fontType: _font,
height: height ?? _bodyHeight,
width: width ?? _bodyWidth,
fontType: fontType ?? _font,
),
);
}
List<int> textLeft(String text, {bool bold = false}) {
List<int> textLeft(String text, {bool bold = false, PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,}) {
return generator.text(
text,
styles: PosStyles(
bold: bold,
align: PosAlign.left,
fontType: _font,
height: _bodySize,
width: _bodyWidth,
height: height ?? _bodyHeight,
width: width ?? _bodyWidth,
fontType: fontType ?? _font,
),
);
}
List<int> textRight(String text, {bool bold = false}) {
List<int> textRight(String text, {bool bold = false, PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,}) {
return generator.text(
text,
styles: PosStyles(
bold: bold,
align: PosAlign.right,
fontType: _font,
height: _bodySize,
width: _bodyWidth,
height: height ?? _bodyHeight,
width: width ?? _bodyWidth,
fontType: fontType ?? _font,
),
);
}
@@ -101,6 +106,9 @@ class ReceiptComponentBuilder {
bool bold = false,
int leftWidth = 6,
int rightWidth = 6,
PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,
}) {
return generator.row([
PosColumn(
@@ -109,9 +117,9 @@ class ReceiptComponentBuilder {
styles: PosStyles(
align: PosAlign.left,
bold: bold,
fontType: _font,
height: _bodySize,
width: _bodyWidth,
height: height ?? _bodyHeight,
width: width ?? _bodyWidth,
fontType: fontType ?? _font,
),
),
PosColumn(
@@ -120,9 +128,9 @@ class ReceiptComponentBuilder {
styles: PosStyles(
align: PosAlign.right,
bold: bold,
fontType: _font,
height: _bodySize,
width: _bodyWidth,
height: height ?? _bodyHeight,
width: width ?? _bodyWidth,
fontType: fontType ?? _font,
),
),
]);
@@ -163,8 +171,8 @@ class ReceiptComponentBuilder {
bold: bold,
align: PosAlign.left,
fontType: _font,
height: PosTextSize.size2,
width: PosTextSize.size2,
height: _bodyHeight,
width: _bodyWidth,
),
);
}
@@ -182,25 +190,34 @@ class ReceiptComponentBuilder {
required String outletName,
required String address,
required String phoneNumber,
PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,
}) {
List<int> bytes = [];
bytes += textCenter(outletName, bold: true, height: _titleSize, width: _titleSize);
bytes += textCenter(address);
bytes += textCenter(phoneNumber);
bytes += textCenter(outletName, height: PosTextSize.size2, width: PosTextSize.size2, fontType: fontType, bold: true);
bytes += textCenter(address, fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth);
bytes += textCenter(phoneNumber, fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth);
bytes += separator();
return bytes;
}
}
/// Centered printer type label (e.g. KITCHEN, BAR)
List<int> printerType({required String printerType}) {
return textCenter(printerType, bold: true, height: _titleSize, width: _titleSize);
return textCenter(printerType, bold: true);
}
/// Date + time row (receipt style)
List<int> dateTime(DateTime dateTime) {
List<int> dateTime(DateTime dateTime, { PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,
}) {
return row2Columns(
DateFormat('dd MMM yyyy').format(dateTime),
DateFormat('HH:mm').format(dateTime),
height: height ?? _bodyHeight,
width: width ?? _bodyWidth,
fontType: fontType ?? _font,
);
}
@@ -209,15 +226,21 @@ class ReceiptComponentBuilder {
required String orderNumber,
String? orderType,
required String cashierName,
String? customerName,
}) {
List<int> bytes = [];
final dateStr = DateFormat('dd-MM-yyyy HH:mm').format(DateTime.now());
bytes += textLeft('Order : $orderNumber');
bytes += textLeft('Date : $dateStr');
if(orderType != null) {
bytes += textLeft('Purpose : $orderType');
bytes += textLeft('Order : $orderNumber');
bytes += textLeft('Date : $dateStr');
if(customerName != null) {
bytes += textLeft('Customer : $customerName');
}
bytes += textLeft('Waiter : $cashierName');
if(orderType != null) {
bytes += textLeft('Purpose : ${orderType.toUpperCase()}');
}
bytes += textLeft('Waiter : $cashierName');
return bytes;
}
@@ -228,29 +251,48 @@ class ReceiptComponentBuilder {
required String cashierName,
String? paymentMethod,
String? tableNumber,
PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,
}) {
List<int> bytes = [];
final dateStr = DateFormat('dd-MM-yyyy HH:mm').format(DateTime.now());
bytes += textLeft('Order : $orderNumber');
bytes += textLeft('Date : $dateStr');
bytes += textLeft('Order : $orderNumber', fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth);
bytes += textLeft('Date : $dateStr', fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth);
if (tableNumber != null && tableNumber.isNotEmpty) {
bytes += textLeft('Table : $tableNumber');
bytes += textLeft('Table : $tableNumber', fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth);
}
bytes += textLeft('Waiter : $cashierName');
bytes += textLeft('Customer : $customerName');
bytes += textLeft('Waiter : $cashierName', fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth);
bytes += textLeft('Customer : $customerName', fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth);
if (paymentMethod != null) {
bytes += textLeft('Payment : $paymentMethod');
bytes += textLeft('Payment : $paymentMethod', fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth);
}
return bytes;
}
/// Order type banner (separator + type + separator)
List<int> orderType(String type) {
List<int> orderType(String type, {
PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,
}) {
List<int> bytes = [];
bytes += separator();
bytes += textCenter(type, bold: true, height: _titleSize, width: _titleSize);
bytes += textCenter(type.toUpperCase(), fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth, bold: true);
bytes += separator();
return bytes;
}
List<int> tableName(String table, {
PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,
}) {
List<int> bytes = [];
bytes += separator();
bytes += textCenter( 'Table : ${table.isNotEmpty ? table : '-'}', fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth, bold: true);
bytes += separator();
return bytes;
}
@@ -259,19 +301,29 @@ class ReceiptComponentBuilder {
List<int> orderItem({
required String productName,
required int quantity,
/// Untuk baris berbobot, isi dengan "4,2 ons" — angka quantity-nya selalu
/// 1 dan akan salah dibaca di struk.
String? quantityLabel,
required String unitPrice,
required String totalPrice,
String? variantName,
String? notes,
PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,
}) {
List<int> bytes = [];
final displayName = (variantName != null && variantName.isNotEmpty)
? '$productName ($variantName)'
: productName;
bytes += textLeft(displayName, bold: paperSize == 80);
bytes += row2Columns('$quantity x $unitPrice', totalPrice, leftWidth: 8, rightWidth: 4);
bytes += textLeft(displayName, bold: true, fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth);
final quantityText = quantityLabel != null && quantityLabel.isNotEmpty
? '$quantityLabel x $unitPrice'
: '${quantity}x $unitPrice';
bytes += row2Columns(quantityText, totalPrice, fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth,);
if (notes != null && notes.isNotEmpty) {
bytes += row2Columns('Note', notes, leftWidth: 4, rightWidth: 8);
bytes += row2Columns('Note', notes, fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth, leftWidth: 4, rightWidth: 8);
}
bytes += emptyLines(1);
return bytes;
@@ -284,17 +336,20 @@ class ReceiptComponentBuilder {
required String discount,
required String total,
required String paid,
PosTextSize? height,
PosTextSize? width,
PosFontType? fontType,
}) {
List<int> bytes = [];
bytes += separator();
if (totalItems > 0) {
bytes += row2Columns('Total Item', totalItems.toString());
bytes += row2Columns('Total Item', totalItems.toString(), fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth,);
}
bytes += row2Columns('Subtotal', subtotal);
bytes += row2Columns('Diskon', discount);
bytes += row2Columns('Subtotal', subtotal, fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth,);
bytes += row2Columns('Diskon', discount, fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth,);
bytes += separator();
bytes += row2Columns('Total', total, bold: true);
bytes += row2Columns('Bayar', paid);
bytes += row2Columns('Total', total, bold: true, fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth,);
bytes += row2Columns('Bayar', paid, fontType: fontType, height: height ?? _bodyHeight, width: width ?? _bodyWidth,);
bytes += separator();
return bytes;
}
@@ -303,7 +358,7 @@ class ReceiptComponentBuilder {
List<int> footer({String message = 'Terima kasih'}) {
List<int> bytes = [];
bytes += emptyLines(2);
bytes += textCenter(message, bold: true, height: _titleSize, width: _titleSize);
bytes += textCenter(message, bold: true);
if (kDebugMode) {
bytes += textCenter('$paperSize MM');
}
@@ -7,6 +7,7 @@ import '../../../application/order/order_form/order_form_bloc.dart';
import '../../../application/payment_method/payment_method_loader/payment_method_loader_bloc.dart';
import '../../../application/table/table_loader/table_loader_bloc.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/product/product.dart';
import '../../../injection.dart';
import '../../components/spaces/space.dart';
import '../../components/toast/flushbar.dart';
@@ -86,14 +87,7 @@ class CheckoutPage extends StatelessWidget implements AutoRouteWrapper {
tag: 'checkout_screen',
child: BlocBuilder<CheckoutFormBloc, CheckoutFormState>(
builder: (context, state) {
final price = state.items.fold(
0,
(previousValue, element) =>
previousValue +
(element.product.price.toInt() +
(element.variant?.priceModifier.toInt() ?? 0)) *
element.quantity,
);
final price = state.items.cartTotal.toInt();
return Scaffold(
backgroundColor: AppColor.white,
@@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../../application/checkout/checkout_form/checkout_form_bloc.dart';
import '../../../../../../common/extension/extension.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../../domain/product/product.dart';
import '../../../../../components/button/button.dart';
import '../../../../../components/card/order_menu.dart';
import '../../../../../components/spaces/space.dart';
@@ -21,15 +22,7 @@ class HomeRightPanel extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<CheckoutFormBloc, CheckoutFormState>(
builder: (context, state) {
final price = state.items.isEmpty
? 0.0
: state.items
.map(
(e) =>
(e.product.price + (e.variant?.priceModifier ?? 0)) *
e.quantity,
)
.reduce((value, element) => value + element);
final price = state.items.cartTotal;
log('🛒 Total Price: $price');
return Align(
alignment: Alignment.topCenter,
@@ -87,8 +80,12 @@ class HomeRightPanel extends StatelessWidget {
: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemBuilder: (context, index) =>
OrderMenu(data: state.items[index]),
itemBuilder: (context, index) => OrderMenu(
// Baris berbobot bisa dihapus satu per satu, jadi
// state tiap baris harus terikat ke identitasnya.
key: ValueKey(state.items[index].lineId),
data: state.items[index],
),
separatorBuilder: (context, index) =>
const SpaceHeight(1.0),
itemCount: state.items.length,
@@ -32,6 +32,8 @@ class SettingPrinterForm extends StatefulWidget {
class _SettingPrinterFormState extends State<SettingPrinterForm> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _networkController = TextEditingController();
void setup() {
if (widget.printer != null) {
context.read<PrinterFormBloc>().add(
@@ -50,6 +52,7 @@ class _SettingPrinterFormState extends State<SettingPrinterForm> {
PrinterFormEvent.paperChanged(widget.printer!.paper),
);
_nameController.text = widget.printer!.name;
_networkController.text = widget.printer!.address;
}
}
@@ -163,6 +166,7 @@ class _SettingPrinterFormState extends State<SettingPrinterForm> {
)
: AppTextFormField(
label: 'Network',
controller: _networkController,
onChanged: (value) {
context.read<PrinterFormBloc>().add(
PrinterFormEvent.addressChanged(value),
@@ -1,5 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../../application/printer/printer_form/printer_form_bloc.dart';
import '../../../../../../application/printer/printer_loader/printer_loader_bloc.dart';
import '../../../../../../injection.dart';
import '../../../../../components/page/page_title.dart';
import '../../../../../components/tab/custom_tabbar.dart';
import 'printer/setting_printer_bar.dart';
@@ -11,18 +15,36 @@ import 'printer/setting_printer_ticket.dart';
class SettingPrinterSection extends StatelessWidget {
const SettingPrinterSection({super.key});
/// Tiap tab wajib punya `PrinterFormBloc` dan `PrinterLoaderBloc` sendiri.
///
/// Kelima tab hidup bersamaan di dalam `TabBarView`, dan `PrinterFormBloc`
/// membangun entitas `Printer` sepenuhnya dari state-nya (`type`, `address`,
/// `code`, ...). Saat blocnya dibagi, tab yang terakhir menulis state akan
/// menang, lalu tombol Simpan di tab mana pun menyimpan state milik tab lain
/// — itu sebabnya printer kitchen bisa tersimpan dengan alamat IP yang benar
/// tapi `type` "Bluetooth" warisan tab Receipt.
Widget _scoped(Widget tab) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => getIt<PrinterFormBloc>()),
BlocProvider(create: (_) => getIt<PrinterLoaderBloc>()),
],
child: tab,
);
}
@override
Widget build(BuildContext context) {
return const Column(
return Column(
children: [
PageTitle(
const PageTitle(
isBack: false,
title: 'Printer',
subtitle: 'Kelola printer untuk cetak struk',
),
Expanded(
child: CustomTabBar(
tabTitles: [
tabTitles: const [
'Receipt Printer',
'Checker Printer',
'Kitchen Printer',
@@ -30,11 +52,11 @@ class SettingPrinterSection extends StatelessWidget {
'Tiket Printer',
],
tabViews: [
SettingPrinterReceipt(),
SettingPrinterChecker(),
SettingPrinterKitchen(),
SettingPrinterBar(),
SettingPrinterTicket(),
_scoped(const SettingPrinterReceipt()),
_scoped(const SettingPrinterChecker()),
_scoped(const SettingPrinterKitchen()),
_scoped(const SettingPrinterBar()),
_scoped(const SettingPrinterTicket()),
],
),
),
+1 -16
View File
@@ -3,10 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../application/order/order_loader/order_loader_bloc.dart';
import '../../../application/printer/print_struck/print_struck_bloc.dart';
import '../../../common/theme/theme.dart';
import '../../../injection.dart';
import '../../components/toast/flushbar.dart';
import 'widgets/order_left_panel.dart';
import 'widgets/order_right_panel.dart';
@@ -37,20 +35,7 @@ class OrderPage extends StatelessWidget implements AutoRouteWrapper {
);
},
),
BlocListener<PrintStruckBloc, PrintStruckState>(
listenWhen: (previous, current) =>
previous.failureOrPrintStruck != current.failureOrPrintStruck,
listener: (context, state) {
state.failureOrPrintStruck.fold(() {}, (either) {
either.fold(
(f) => AppFlushbar.showPrinterFailureToast(context, f),
(success) {
AppFlushbar.showSuccess(context, "Struck berhasil dicetak");
},
);
});
},
),
// Hasil cetak dilaporkan lewat dialog progres global di `AppWidget`.
],
child: SafeArea(
child: Scaffold(

Some files were not shown because too many files have changed in this diff Show More