chore: initial commit Dutra Store POS app

This commit is contained in:
Onie
2026-07-23 22:12:32 +07:00
commit 22356f630e
175 changed files with 13934 additions and 0 deletions

49
.gitignore vendored Normal file
View File

@@ -0,0 +1,49 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Secrets / config (Supabase keys)
lib/config.dart
supabse.txt
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View File

@@ -0,0 +1,195 @@
# UI/UX Design Skill — Dutra Store (POS Grosir Flutter)
Gunakan skill ini sebagai acuan setiap kali mengubah, menambah, atau meninjau tampilan aplikasi POS grosir berbasis Flutter ini. Jika ada pertentangan, prioritaskan yang tercantum di sini.
## 1. Tujuan Utama
- Transaksi cepat tanpa hambatan visual.
- Tidak ada overflow/keluar layar di ukuran HP kecil.
- Status stok, harga, dan aksi utama terlihat jelas dalam 1 scan.
- Maintain konsistensi visual antar screen.
## 2. Design System
### 2.1 Tema
- Theme gelap (dark) sebagai basis utama.
- Warna aksen: amber (`Colors.amber`).
- Radius default: 18 untuk kartu, 12 untuk input/dialog, 8 untuk chip/tag.
- Font: gunakan `Theme.of(context).textTheme` agar sesuai pengaturan sistem.
### 2.2 Warna Semantik
- `kAccent` — amber untuk aksen dan highlight.
- `kSuccess` — hijau untuk status lunas/berhasil.
- `kDanger` — merah untuk pembatalan/error/stok habis.
- `kMuted` — abu-abu untuk teks sekunder/timestamp.
- `kSurface2` — background chip yang tidak aktif.
- `kBorder` — border untuk chip dan outline.
### 2.3 Spacing & Layout
- Gunakan `EdgeInsets` tetap untuk konsistensi:
- `EdgeInsets.all(12)` padding screen/list.
- `EdgeInsets.all(16)` padding form/dialog.
- `EdgeInsets.symmetric(horizontal: 14, vertical: 10)` untuk baris list.
- Gap antar field/form: `SizedBox(height: 10)`.
- Gap antar section: `SizedBox(height: 14)`.
### 2.4 Kartu (Card)
- Semua item list/kartu harus punya padding minimal `EdgeInsets.all(12)`.
- Jangan hardcode tinggi kartu; gunakan `Column` + `MainAxisSize.min` atau `IntrinsicHeight` bila perlu.
- Jika ada 3+ field di dalam kartu, prioritaskan wrap/scroll.
- Status transaksi harus jelas:
- Lunas: badge/icon hijau.
- Belum lunas: badge/icon amber/oranye.
- Dibatalkan: badge/icon merah + `TextDecoration.lineThrough`.
### 2.5 Input & Form
- Label jelas, helper text opsional untuk field opsional.
- Jangan taruh form di dalam `Column` tanpa `SingleChildScrollView` jika total tinggi form bisa melebihi layar.
- Saat keyboard muncul, pastikan field terlihat:
- Bungkus form dengan `SingleChildScrollView` + `padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom)`.
### 2.6 Tombol & Aksi
- Primary action (Bayar, Tambah, Simpan): `FilledButton` berwarna aksen.
- Destructive action (Hapus, Batalkan): `FilledButton` / `OutlinedButton` berwarna merah (`kDanger`).
- Secondary action (Batal, Tutup): `TextButton` atau `OutlinedButton`.
- Setiap dialog harus punya urutan aksi: **Konfirmasi** di kanan/bawah, **Batal** di kiri/atas.
### 2.6a Haptic & Feedback
- Aksi signifikan yang cepat: tambah item ke keranjang, print struk, scan barcode → `HapticFeedback.lightImpact()`.
- Aksi destruktif atau butuh konfirmasi: hapus item/batal transaksi → `HapticFeedback.mediumImpact()`.
- Sukses transaksi/tampilkan feedback →lanjut ke `SnackBar` hijau (`kSuccess`), jangan pakai dialog sukses untuk hal yang cepat.
- Jangan paksa haptic di setiap tap; hanya untuk aksi yang jelas berdampak.
### 2.7 Auto-Scroll & Keyboard Behavior
- Setiap form di dalam bottom sheet atau dialog harus meng-handle keyboard muncul/tutup.
- Gunakan `MediaQuery.of(context).viewInsets.bottom` untuk padding bawah dinamis pada `SingleChildScrollView`.
- Untuk bottom sheet form yang tinggi dan perlu auto-expand:
1. Pakai `DraggableScrollableController`.
2. Buat `FocusNode` untuk setiap `TextField`.
3. Tambah listener ke setiap focus node: jika `hasFocus`, animasikan sheet ke `maxChildSize` (misal `0.92`), lalu panggil `Scrollable.ensureVisible()` agar field aktif terlihat.
4. Durasi animasi sheet: `300ms`, scroll: `250ms`, alignment: `0.0`.
- Jangan andal `Scaffold.resizeToAvoidBottomInset` saja untuk bottom sheet form kompleks; manual padding lebih stabil untuk kasus form panjang.
### 2.8 Empty State
- Jangan tampilkan teks kosong tanpa visual indicator.
- Minimal: icon + teks. Contoh: `Icon(Icons.shopping_cart_outlined, size: 48, color: kMuted)` + teks "Keranjang kosong".
- Center alignment, `Column` dengan `MainAxisSize.min`.
- Konsisten pakai ikon `outlined` variant untuk empty state.
### 2.9 Dialog Button Layout
- Mobile: tombol dialog harus **full-width stacked vertikal** dengan `SizedBox(height: 8)` antar tombol.
- Urutan: **Batal** di atas, **Konfirmasi** di bawah.
- Jangan pakai `Row` untuk 2 tombol di dialog mobile, kecuali screen width > 600dp.
- Tombol konfirmasi destruktif: `FilledButton` dengan `backgroundColor: kDanger`.
- Tombol konfirmasi positif: `FilledButton` dengan `backgroundColor: kAccent` atau `kSuccess`.
### 2.10 Loading State
- Untuk operasi < 500ms: tidak perlu loading indicator.
- Untuk operasi > 500ms: tampilkan loading di button yang dipencet.
- Spinner: `CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F))` untuk light button text.
- Spinner: `CircularProgressIndicator(strokeWidth: 2, color: kAccent)` untuk dark button text.
- Untuk full-screen loading: gunakan `CircularProgressIndicator(color: kAccent)` di `Center`.
- Jangan gunakan `LinearProgressIndicator` kecuali untuk determinate progress yang jelas.
- Jika list kosong dan pengguna bisa melakukan aksi (misal tambah produk), tampilkan `FloatingActionButton` atau `FilledButton` sebagai helper agar tidak dead-end.
### 2.11 Error State
- Error dari form validation: tampilkan inline di bawah field, warna `kDanger`, font 13.
- Error dari operasi database/API: tampilkan `SnackBar` merah (`kDanger`).
- Jangan hanya pakai warna tanpa label. Tambahkan ikon/prefix jika perlu.
- Jika field error, pastikan `TextField` tetap bisa di-focus dan di-scroll.
## 3. Responsif & Overflow
### 3.1 Grid Produk (Kasir)
- Default: 2 kolom di layar HP (`crossAxisCount: 2`).
- Tablet (lebar > 600dp): naik jadi 3 kolom.
- Jangan pakai `childAspectRatio` tetap yang bikin kartu terlalu tinggi/pendek. Gunakan `AspectRatio(1.1)` atau biarkan `GridView` yang menentukan tinggi.
### 3.2 Payment Method Chips
- Urutan chip di UI: **Tunai**, **Bank**, **QRIS**, **Bayar Nanti**, **Split**.
- Untuk mobile, kelompokkan menjadi:
- Baris atas: 3 chip (`Tunai`, `Bank`, `QRIS`) dalam satu `Row` dengan `Expanded`.
- Divider tipis antar kelompok.
- Baris bawah: 2 chip (`Bayar Nanti`, `Split`) dalam satu `Row` dengan `Expanded`.
- Jangan pakai `Wrap` untuk payment chips. `Wrap` boleh dipakai kembali untuk kasus lain seperti `Filter chips` atau `Tag`.
- Chip harus compact, aman di layar kecil, dan tidak memaksa scroll horizontal.
### 3.3 Bottom Sheet
- Keranjang harus berupa bottom sheet, bukan panel tetap.
- Sheet harus bisa di-drag (`DraggableScrollableSheet` atau `showModalBottomSheet` dengan `isScrollControlled: true`).
- Konten sheet harus **selalu scrollable** (`SingleChildScrollView`), tanpa `Expanded` di dalamnya, agar footer tidak pernah terhalangi keyboard.
- Untuk menutup sheet dari aksi di dalamnya, gunakan `Navigator.pop(sheetContext)` bukan `parentContext`.
### 3.4 Dialog
- Semua dialog input yang panjang harus dibungkus `SingleChildScrollView` atau `SingleChildScrollView` di dalam `AlertDialog` agar bisa di-scroll saat keyboard muncul.
- Jangan pakai `Expanded` di dalam dialog yang berpotensi overflow.
### 3.5 Produk & Form Panjang
- Tab Produk adalah gabungan form + list produk. Gunakan `SingleChildScrollView` + `ListView.builder` dengan `shrinkWrap: true` agar form bisa di-scroll tanpa overflow.
### 3.6 Teks
- Jangan hardcode font size. Gunakan skema `TextStyle` dari `ThemeData`.
- Jika label teks bisa panjang (mis. nama produk), batasi maksimal 2 baris dengan `maxLines: 2` + `overflow: TextOverflow.ellipsis`.
- ID transaksi/nama panjang: `Expanded` + `TextOverflow.ellipsis` agar tidak overflow di `Row`.
## 4. UX untuk Kasir (Critical Path)
- Target: transaksi selesai dalam 3 tap (pilih produk → bayar → selesai).
- Jangan sembunyikan aksi utama (Bayar) di bawah banyak opsi.
- Tampilkan total, diskon, dan kembalian secara **real-time**.
- Gunakan chip/segmented button untuk tipe pembeli (Umum/Reseller) — jangan dropdown.
- Setiap item di keranjang harus bisa diubah qty atau tipe harga dalam 1 tap.
- Jika stok habis, matikan visual (opacity turun, disable tap) dan tampilkan badge "Habis".
- Jika stok menipis, tampilkan badge peringatan oranye sebelum habis.
## 5. Payment Flow: Bayar Nanti
- Saat checkout pilih **Bayar Nanti**, tombol harus jelas: **"Pesanan Sekarang"**.
- Field "Uang Dibayar" menjadi disabled dengan label **"Belum Bayar"**.
- Tampilkan status **"Belum Bayar"** di receipt/summary dengan warna `kMuted`.
- Di Riwayat, transaksi unpaid harus punya badge/status yang menonjol.
- Saat customer datang bayar: tombol **"Terima Pembayaran"** atau **"Bayar Sisa"** di detail transaksi.
## 6. Aksesibilitas & Feedback
- Minimal tap target: 44x44dp untuk semua tombol/icon.
- Warna teks harus kontras dengan background (minimal 4.5:1 untuk teks normal).
- Jangan hanya andal warna untuk status (tambah ikon/label). Contoh: stok habis = badge merah + teks "Habis".
- Setiap aksi berhasil/gagal harus ada `SnackBar` feedback:
- Sukses: hijau (`kSuccess`).
- Error: merah (`kDanger`).
- Info: biru/abu.
- Loading state harus muncul saat operasi > 500ms. Gunakan `CircularProgressIndicator(color: kAccent)`.
## 7. Role-Based Visibility
- Fitur yang tidak diizinkan untuk role tertentu harus **disembunyikan sepenuhnya**, bukan cuma di-disable.
- Admin: lihat semua transaksi + filter tanggal + aksi batalkan transaksi.
- Kasir: lihat transaksi hari ini, tidak ada aksi batalkan, tidak ada filter tanggal.
## 8. List & Pagination
- Gunakan `ListView.separated` dengan `itemCount` yang sesuai untuk performa.
- Tombol "Muat lebih banyak" harus ada di bawah list jika data bisa banyak.
- Filter chips: gunakan `Wrap` agar responsif di layar kecil.
## 8a. Printer & Thermal Receipt
- Semua teks struk harus Bahasa Indonesia dan ringkas.
- Struk wajib menampilkan: nama toko, tanggal, jam, nomor transaksi, daftar item, diskon (jika ada), total, metode pembayaran, detail pembayaran tunai/split (jika ada), dan **nama kasir**.
- Jangan tampilkan label per-item **Umum/Reseller** di struk thermal, agar lebih rapi.
- Jika `splitPayment` ada, tampilkan per bagian: `Split Tunai`, `Split Bank`, atau `Split QRIS` beserta nominalnya.
- Batasi lebar konten sesuai PaperSize.mm58 agar tidak terpotong.
- Setelah cetak, arahkan feedback ke user melalui `SnackBar`, bukan hanya log.
- Flow pairing printer harus bisa diakses oleh role kasir, bukan hanya admin.
## 9. Reusability
- Buat widget kecil reuseable untuk komponen yang berulang: `_Tag`, `_ReportCard`, `_Avatar`, `_Stepper`, `_PaymentMethodChip`.
- Jangan duplikasi warna/spacing. Taruh konstanta di `lib/utils/colors.dart`.
## 10. Checklist Sebelum Submit Perubahan UI/UX
- [ ] `flutter analyze` → No issues found (tanpa error).
- [ ] Coba di layar kecil (HP 5") dan besar (tablet).
- [ ] Coba dengan keyboard muncul (input uang/diskon).
- [ ] Coba mode gelap dan terang (jika supported).
- [ ] Pastikan tidak ada hardcoded height yang bikin overflow.
- [ ] Semua teks dalam Bahasa Indonesia.
- [ ] Konsisten pakai `formatRupiah()` untuk angka mata uang.
## 11. Referensi Project
- `lib/utils/colors.dart` — design tokens.
- `lib/screens/kasir_screen.dart` — contoh bottom sheet cart, grid produk, dialog pembayaran.
- `lib/screens/riwayat_screen.dart` — contoh kartu laporan, filter role-based.

45
.metadata Normal file
View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "9f455d2486bcb28cad87b062475f42edc959f636"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: android
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: ios
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: linux
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: macos
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: web
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: windows
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

476
PRD.md Normal file
View File

@@ -0,0 +1,476 @@
# PRD - Dutra Store
## Changelog
### v1.0.1 — 2026-07-22
- Perbaikan UI/UX form produk: auto-scroll keyboard pada Tambah Produk dan Edit Produk.
- reorganisasi metode pembayaran: kelompok atas 3 (Tunai, Bank, QRIS), kelompok bawah 2 (Bayar Nanti, Split).
- Validasi harga reseller: harga reseller tidak boleh lebih tinggi dari harga umum.
- Fix sinttas `produk_screen.dart` dan `kasir_screen.dart` (`flutter analyze` clean dari error).
- Deploy release APK ke device: **TECNO KM4** (Android 15).
### v1.0.0 — 2026-07-20 (Resmikan)
- Fitur lengkap: Auth, Kasir, Produk, Riwayat, Pembayaran, Struk, Printer Bluetooth, Export PDF.
- Release APK pertama untuk distribusi.
## 1. Ringkasan Produk
Dutra Store adalah aplikasi kasir digital berbasis Flutter untuk toko grosir. Aplikasi ini menghubungkan multi-device melalui sinkronisasi realtime Supabase, sehingga data produk dan transaksi dapat diakses dari berbagai perangkat secara bersamaan.
## 2. Tujuan Bisnis
- Mempercepat transaksi penjualan di toko grosir.
- Menjamin harga konsisten untuk pelanggan umum dan reseller.
- Menyediakan laporan penjualan yang realtime.
- Mengurangi risiko inkonsistensi data antar perangkat kasir.
- Memisahkan wewenang antara admin (pemilik) dan kasir (karyawan).
## 3. Target Pengguna
- **Admin** (pemilik toko): mengelola produk, melihat laporan, mengubah setting toko.
- **Kasir** (karyawan): melakukan transaksi penjualan, tidak bisa mengubah data master.
- Pelanggan reseller yang mendapatkan harga khusus.
## 4. Lingkung Aplikasi
- Platform: Android (emulator & perangkat fisik).
- State management: Provider.
- Backend: Supabase (PostgreSQL).
- Autentikasi: Supabase Auth (email + password) + tabel `profiles` untuk role.
- Sinkronisasi: Realtime stream untuk `products` dan `sales` pada paid tier; pada free tier menggunakan **periodic polling 1 menit** saat app aktif sebagai fallback, ditambah pull-to-refresh manual.
- Penyimpanan lokal: `shared_preferences` untuk cache JSON + session auth dikelola oleh Supabase.
- Startup flow: `AuthGate` mengecek session/login state → `LoginScreen` atau `HomePage`.
## 5. Role & Permission
### 5.1 Admin
- Login ke aplikasi.
- CRUD produk (tambah, edit, hapus, atur stok & harga).
- Melihat riwayat transaksi & laporan.
- Mengelola akun kasir (tambah / nonaktifkan).
- Mengubah pengaturan toko (nama toko, mata uang, printer default).
### 5.2 Kasir
- Login ke aplikasi.
- Melakukan transaksi penjualan (tab Kasir).
- Tidak dapat mengedit atau menghapus produk.
- Tidak dapat melihat laporan lengkap / riwayat lama.
- Tidak dapat mengubah pengaturan toko.
### 5.3 Guest (tanpa login)
- Tidak diizinkan. Semua perangkat harus login terlebih dahulu.
## 6. Fitur Utama
### 6.1 Autentikasi
- Screen login (email + password) menggunakan Supabase Auth.
- Logout.
- Role ditentukan dari kolom `role` di tabel `profiles` Supabase.
- Session disimpan lokal; startup otomatis login jika session masih valid.
### 6.2 Manajemen Produk (Admin only)
- CRUD produk: nama, barcode/no. produk, harga umum, harga reseller, stok.
- Pencarian produk berdasarkan nama atau barcode.
- Indikator stok habis (label "Habis" + opacity reduced).
- Input harga menggunakan format Rupiah otomatis (`RupiahInputFormatter`): pengguna mengetik angka mentah, field otomatis menampilkan `Rp XX.XXX`.
- **Scan barcode produk**: tombol scan di form "Tambah Produk" menggunakan kamera HP. Setelah barcode discan, hasil scan otomatis diisi ke field **"Barcode / No. Produk"** (bukan nama). Admin tetap mengisi nama produk, harga, dan stok secara manual.
- Di list produk, barcode ditampilkan di bawah nama produk untuk memudahkan identifikasi.
- Seed data otomatis jika tabel produk kosong.
### 6.3 Transaksi Kasir (Admin & Kasir)
- Toggle tipe pembeli: Umum / Reseller (global per transaksi).
- Fitur harga reseller dapat dinonaktifkan dari **Pengaturan Toko → Mode Reseller Aktif**. Jika dimatikan: toggle Pembeli (Umum/Reseller) dan badge per-item (Reseller/Umum) disembunyikan, semua harga otomatis Umum.
- Tambah produk ke keranjang dengan 1 tap.
- Override harga per line: setiap item di keranjang dapat di-toggle antara Umum / Reseller.
- Qty per item, dengan batas stok.
- Diskon transaksi (Rp).
- Perhitungan otomatis: subtotal, diskon, total, bayar, kembalian.
### 6.4 Keranjang (Cart)
- Bottom bar ringkas menampilkan jumlah item & total.
- Draggable bottom sheet untuk detail keranjang.
- Tombol "Kosongkan" keranjang.
- Input diskon dengan format Rupiah otomatis.
- Tombol "Bayar" mengarah ke dialog pembayaran.
### 6.5 Pembayaran
- Dialog pembayaran scrollable (mengantisipasi keyboard).
- Input "Uang Dibayar" dengan format Rupiah otomatis dan kalkulasi kembalian realtime.
- **Pemilihan metode pembayaran**: Tunai, Bank, QRIS, Split.
- Chip cepat untuk Tunai: Uang Pas, +5rb, +10rb, +20rb, +50rb, +100rb.
- **Bank/QRIS**: tampilkan informasi petunjuk + input jumlah + status Lunas/Kurang.
- **Split Payment**: input pembayaran gabungan (Tunai + Bank + QRIS) dengan total split real-time.
- Validasi minimal pembayaran >= total untuk Tunai.
- Tombol "Selesai & Cetak" menyelesaikan transaksi. Saat diproses, tombol menampilkan indikator loading (spinner + teks "Memproses...") dan terkunci agar tidak bisa diklik dua kali.
- Jika offline, transaksi disimpan ke queue lokal dan disinkronkan otomatis saat koneksi kembali.
### 6.6 Struk / Nota
- Dialog struk setelah pembayaran berhasil.
- Menampilkan: daftar item, diskon, total, bayar, kembali, tanggal, ID transaksi.
- Tombol "Tutup".
### 6.7 Riwayat Transaksi
- **Urutan**: transaksi terbaru tampil di paling atas (baik untuk kasir maupun admin).
- **Kasir**: menampilkan transaksi **hanya hari ini** (tanggal berjalan).
- **Admin**: menampilkan **transaksi bulan berjalan** secara default, dengan opsi **filter rentang tanggal** menggunakan date range picker untuk melihat data periode lain.
- **Export**: Admin dapat export laporan transaksi dalam format **PDF**. Export **CSV/Excel** ditunda untuk pengembangan berikutnya.
- Summary cards dalam bentuk **horizontal slider/carousel** dengan dot indicator:
- **Pendapatan Hari Ini** vs Kemarin (persentase perubahan).
- **Pendapatan Bulan Ini** vs Bulan Lalu.
- **Laba Kotor Bulan Ini** vs Bulan Lalu.
- **Transaksi Bulan Ini** vs Bulan Lalu.
- Swipe kiri/kanan untuk berpindah antar card.
- Dot indicator di bawah slider menandakan card aktif.
- Card aktif menampilkan detail lengkap, card non-aktif menampilkan ringkas.
- List transaksi menampilkan: total, tanggal & waktu, jumlah item, ID transaksi.
- Tap pada item transaksi membuka dialog detail struk (daftar item, diskon, total, bayar, kembali, tanggal, ID transaksi).
- Setiap item di dialog detail menampilkan badge **"Reseller"** atau **"Umum"** sesuai tipe harganya.
- Dialog detail riwayat menampilkan **Metode Pembayaran** (Tunai/Bank/QRIS/Split Payment).
- Jika Split Payment, menampilkan breakdown per metode beserta jumlahnya.
- Dialog detail riwayat memiliki tombol **"Cetak Struk"** yang mengirim struk ke thermal printer Bluetooth (ESC/POS) — mirip dengan cetak dari dialog pembayaran kasir.
### 6.9 Retur / Pembatalan Transaksi (Admin only)
- Admin dapat membatalkan transaksi dari dialog detail riwayat.
- Saat dibatalkan, stok produk secara otomatis dikembalikan ke jumlah semula.
- Transaksi yang dibatalkan ditandai dengan label **"DIBATALKAN"** dan tidak masuk hitungan pendapatan.
- Tombol **"Batalkan Transaksi"** hanya muncul untuk admin dan hanya pada transaksi yang belum dibatalkan.
- Konfirmasi dialog sebelum pembatalan: "Stok akan dikembalikan secara otomatis. Tindakan ini tidak dapat dibatalkan."
### 6.10 Stok Minimum & Notifikasi
- Setiap produk memiliki field **Stok Minimum** (default: 5) yang dapat diatur saat tambah/edit produk.
- Di list produk, stok yang di bawah minimum akan menampilkan badge peringatan berwarna oranye: **"⚠ Stok rendah (X/Y)"**.
- Stok habis tetap menampilkan badge merah **"Stok habis"**.
- Field stok minimum ditambahkan di form tambah produk dan form edit produk.
### 6.11 Laba / Rugi
- Setiap produk memiliki field **Harga Pokok (modal)** yang dapat diatur saat tambah/edit produk.
- Di **Riwayat**, menampilkan **Laba Kotor** per transaksi: `Pendapatan - Harga Pokok`.
- **Kasir**: melihat **Laba Hari Ini**.
- **Admin**: melihat **Laba Kotor Total** (semua transaksi) dan **Laba Kotor** per periode filter.
- Laba kotor dihitung otomatis saat checkout berdasarkan harga pokok yang tersimpan di produk.
- Transaksi yang dibatalkan tidak masuk hitungan laba/rugi.
### 6.12 Pengaturan Toko (Admin only)
- Nama toko.
- Mata uang default (default: IDR).
- Toggle mode reseller (aktif/nonaktif).
## 7. Screen / Menu Structure
### 7.1 Login Screen
- Email + password.
- Tombol "Masuk".
- Loading indicator selama inisialisasi AppState.
- Error handling (invalid credentials).
- Layout: `SafeArea` + `SingleChildScrollView` dengan padding dinamis untuk keyboard.
### 7.2 AuthGate (main.dart)
- `StreamBuilder` + `Selector<AppState>` untuk mengecek:
1. `state.loading` → tampilkan `CircularProgressIndicator`.
2. `state.isLoggedIn` → tampilkan `HomePage`.
3. Selain itu → tampilkan `LoginScreen`.
- Listener `onAuthStateChange` di `AppState` untuk update `userId` dan `userRole` otomatis.
### 7.3 HomePage (bottom nav)
- **AppBar**: judul "Dutra Store" + icon logout.
- **Body**: `IndexedStack` dengan screens sesuai role.
- **BottomNavigationBar**:
- **Kasir**: 2 tab (`Kasir`, `Riwayat`).
- **Admin**: 5 tab (`Kasir`, `Produk`, `Riwayat`, `Akun`, `Pengaturan`).
- Navigasi menggunakan `_index` yang direset ke 0 jika role berubah.
### 7.4 Kasir Screen
- Search bar + segmented button tipe pembeli (Umum/Reseller).
- Grid produk 2 kolom.
- Bottom bar: jumlah item + total + icon expand.
- Draggable bottom sheet keranjang.
- Dialog pembayaran + struk.
### 7.5 Produk Screen
- Search bar + form tambah/edit produk.
- Grid daftar produk.
- Hanya admin yang melihat tab ini.
### 7.6 Riwayat Screen
- **Admin**: date range picker untuk filter transaksi berdasarkan tanggal.
- Summary cards:
- **Kasir**: Transaksi Hari Ini, Pendapatan Hari Ini.
- **Admin**: Semua Transaksi, Total Pendapatan, Jumlah Semua Transaksi.
- List transaksi:
- **Kasir**: hanya transaksi hari ini.
- **Admin**: semua transaksi atau sesuai filter tanggal.
- Setiap item: total, tanggal & waktu, jumlah item, ID transaksi.
### 7.7 Kelola Akun Kasir (Admin only)
- Screen untuk mengelola akun kasir.
- Form tambah akun kasir: email, password, nama lengkap.
- List akun kasir yang ada dengan opsi hapus.
- Setiap akun memiliki role `kasir` di tabel `profiles`.
### 7.8 Pengaturan Toko (Admin only)
- Screen untuk mengubah pengaturan toko.
- Field: nama toko, mata uang (default: IDR), alamat printer Bluetooth.
- Toggle mode reseller (aktif/nonaktif).
- Pengaturan disimpan di `shared_preferences`.
### 7.9 Login/Logout Flow
- Login: `LoginScreen` → `AppState.login()` → update `userId` + `userRole` → `AuthGate` otomatis navigate ke `HomePage`.
- Logout: icon logout di AppBar → `AppState.logout()` → clear cart + discount → `AuthGate` otomatis navigate ke `LoginScreen`.
## 8. Model Data
### Product
| Field | Tipe | Keterangan |
|-------|------|-----------|
| id | text (PK) | UUID unik |
| name | text | Nama produk |
| barcode | text | Barcode / No. Produk (opsional) |
| price | number | Harga umum |
| price_reseller | number | Harga reseller |
| stock | integer | Stok tersedia |
| min_stock | integer | Batas stok minimum untuk peringatan (default: 5) |
| harga_pokok | number | Harga modal per unit (untuk hitung laba kotor) |
### Sale
| Field | Tipe | Keterangan |
|-------|------|-----------|
| id | text (PK) | UUID unik |
| created_at | timestamp | Waktu transaksi |
| items | jsonb | Array of {name, price, qty, isReseller} |
| subtotal | number | Total sebelum diskon |
| discount | number | Diskon transaksi |
| total | number | subtotal - discount |
| paid | number | Jumlah dibayar |
| change | number | paid - total |
| user_id | text (FK) | ID user yang melakukan transaksi (opsional) |
| is_cancelled | boolean | true jika transaksi dibatalkan (default: false) |
| total_cost | number | Total harga pokok dari item yang terjual (untuk laba kotor) |
| payment_method | text | Metode pembayaran: 'tunai' (default), 'bank', 'qris', 'split' |
| split_payment | jsonb | Object pembayaran gabungan: {"tunai": 50000, "bank": 100000, "qris": 50000} (opsional) |
### CartLine (lokal)
| Field | Tipe | Keterangan |
|-------|------|-----------|
| product | Product | Referensi produk |
| qty | int | Jumlah |
| isReseller | bool | true = harga reseller dipakai |
### Profile (Supabase)
| Field | Tipe | Keterangan |
|-------|------|-----------|
| id | uuid (FK auth.users) | Referensi user |
| role | text | 'admin' atau 'kasir' |
| full_name | text | Nama lengkap (opsional) |
## 8.1 Utilitas Format Rupiah
- `RupiahInputFormatter`: `TextInputFormatter` untuk field harga/uang.
- Saat pengguna mengetik angka, field otomatis menampilkan format `Rp XX.XXX` (contoh: `50000` → `Rp 50.000`).
- `RupiahInputFormatter.getRawValue(String)`: mengembalikan nilai `int?` dari string yang diformat, untuk kebutuhan perhitungan.
- Digunakan pada:
- Form tambah/edit produk: `Harga Umum`, `Harga Reseller`.
- Dialog pembayaran: `Uang Dibayar`.
- Keranjang: `Diskon`.
## 9. Alur Transaksi
1. User login → role ditentukan → navigasi sesuai role.
2. **Kasir**:
- Pilih tipe pembeli (Umum / Reseller).
- Tambah produk ke keranjang.
- Sesuaikan qty & tipe harga per item.
- Isi diskon (opsional).
- Tekan "Bayar" → dialog pembayaran muncul.
- Input uang dibayar / pilih chip cepat.
- Validasi, lalu checkout → simpan ke Supabase, kurangi stok.
- Tampilkan dialog struk.
- Tutup struk → kembali ke tab Kasir dengan keranjang kosong.
3. **Admin**:
- Dapat melakukan transaksi seperti kasir.
- Dapat menambah/edit/hapus produk.
- Dapat melihat riwayat & laporan.
- Dapat mengelola akun kasir & pengaturan toko.
## 10. UI / UX Requirements
- Tema gelap dengan aksen amber.
- Kartu produk dengan sudut rounded, shadow, avatar inisial nama.
- Bottom sheet keranjang dengan handle drag.
- Semua dialog yang berpotensi overflow harus menggunakan scroll view.
- Label dan mata uang dalam Bahasa Indonesia (Rupiah).
- UI menyesuaikan role: tombol/fitur yang tidak diizinkan disembunyikan (tidak hanya di-disable).
### 10.1 Design System
- **Tema utama:** Dark theme. Warna aksen: amber.
- **Radius:** 18 (kartu), 12 (input/dialog), 8 (chip/tag).
- **Spacing:** padding screen/list `EdgeInsets.all(12)`, padding form/dialog `EdgeInsets.all(16)`, gap field `SizedBox(height: 10)`, gap section `SizedBox(height: 14)`.
- **Kartu:** padding minimal 12, jangan hardcode tinggi. Jika 3+ field, prioritaskan wrap/scroll.
- **Input:** label jelas, helper text untuk opsional. Jika form panjang, bungkus `SingleChildScrollView` + padding bottom `MediaQuery.of(context).viewInsets.bottom` agar tidak tertutup keyboard.
- **Tombol:** primary = `FilledButton` (aksi utama: Bayar, Tambah, Simpan), destructive = merah (`kDanger`), secondary = `TextButton`/`OutlinedButton`.
### 10.2 Responsif & Overflow
- **Grid Kasir:** 2 kolom di HP, 3–4 kolom di tablet (lebar > 600dp). Hindari `childAspectRatio` tetap; pakai `AspectRatio(1.1)` atau biarkan grid menentukan tinggi.
- **Bottom Sheet:** keranjang harus berupa bottom sheet (bukan panel tetap), bisa di-drag, konten selalu scrollable (`SingleChildScrollView` tanpa `Expanded`).
- **Dialog:** input panjang harus scrollable. Jangan pakai `Expanded` di dalam dialog yang bisa overflow.
- **Produk:** form + list dalam satu halaman scroll (`SingleChildScrollView` + `ListView.shrinkWrap`).
- **Teks:** jangan hardcode font size. Batasi nama produk max 2 baris (`maxLines: 2`, `overflow: TextOverflow.ellipsis`).
### 10.3 UX Kasir (Critical Path)
- Target: transaksi selesai dalam 3 tap (pilih → bayar → selesai).
- Tampilkan total, diskon, kembalian real-time.
- Tipe pembeli pakai segmented button (Umum/Reseller), bukan dropdown.
- Setiap item keranjang bisa ubah qty atau tipe harga dalam 1 tap.
- Stok habis: tampil badge merah "Habis" + matikan visual/tap.
- Stok menipis: badge peringatan oranye sebelum habis.
### 10.4 Aksesibilitas & Feedback
- Minimal tap target: 44x44dp.
- Kontras teks minimal 4.5:1.
- Jangan andal warna saja (tambah ikon/label). Contoh: stok habis = badge merah + teks "Habis".
- Setiap aksi ada `SnackBar` feedback: sukses hijau, error merah, info biru/abu.
- Loading state untuk operasi > 500ms (`CircularProgressIndicator(color: kAccent)`).
### 10.5 Role-Based Visibility
- Fitur tidak diizinkan untuk role tertentu harus **disembunyikan sepenuhnya**, bukan cuma di-disable.
- Admin: lihat semua transaksi + filter tanggal + aksi batalkan.
- Kasir: lihat transaksi hari ini, tidak ada aksi batalkan/filter.
### 10.6 Reusability & Konsistensi
- Buat widget reuseable untuk komponen berulang: `_Tag`, `_ReportCard`, `_Avatar`, `_Stepper`.
- Taruh konstanta warna/spacing di `lib/utils/colors.dart`.
- Konsisten pakai `formatRupiah()` untuk semua angka mata uang.
### 10.7 Checklist Sebelum Submit Perubahan UI/UX
- [ ] `flutter analyze` → No issues found (tanpa error).
- [ ] Coba di layar kecil (HP 5") dan besar (tablet).
- [ ] Coba dengan keyboard muncul (input uang/diskon).
- [ ] Pastikan tidak ada hardcoded height yang bikin overflow.
- [ ] Semua teks dalam Bahasa Indonesia.
- [ ] Konsisten pakai `formatRupiah()` untuk angka mata uang.
## 11. Non-Fitur (Out of Scope untuk versi ini)
- ~~Printer thermal Bluetooth (direncanakan Sprint 4).~~ ✅ Sudah diimplementasikan (Sprint 4).
- ~~Laporan bulanan / export PDF/Excel.~~ ✅ Sudah diimplementasikan (export PDF & CSV di Sprint 3/4).
- ~~Barcode scanner.~~ ✅ Sudah diimplementasikan (Sprint 2, `mobile_scanner`).
- Multi-cabang / multi-toko.
## 12. Kebutuhan Non-Fungsional
- Sinkronisasi realtime < 2 detik antar perangkat.
- Offline mode: data terbaru tetap tampil meskipun koneksi terputus (cache lokal).
- Build APK debug untuk emulator Android 13+.
- Session auth harus aman (token disimpan aman, auto-logout setelah inactivity opsional).
## 13. Risiko & Mitigasi
| Risiko | Mitigasi |
|--------|----------|
| Stok menjadi negatif | Validasi di `addToCart` & `changeQty` dibatasi oleh stok produk. |
| Data inkonsistenan saat并发 | Supabase realtime + operasi atomic di sisi backend. |
| Keyboard menutupi input di dialog | Semua dialog input menggunakan `SingleChildScrollView`. |
| Akses tidak sah | Role-based UI + validasi di Supabase (RLS policies). |
| Koneksi putus saat checkout | Simpan transaksi ke queue lokal, kirim saat koneksi kembali (opsional). |
## 14. Sprint Breakdown
### Sprint 0 — Foundation (Selesai)
- [x] Setup Flutter + Provider + Supabase.
- [x] Struktur dasar: screens, models, state, utils.
- [x] CRUD Produk + Realtime stream.
- [x] Dual pricing (Umum / Reseller).
- [x] UI redesign (dark theme, amber accent).
### Sprint 1 — Transaksi Kasir (Selesai)
- [x] Tab Kasir: grid produk + search + tipe pembeli.
- [x] Cart: bottom bar + bottom sheet + qty control.
- [x] Diskon transaksi.
- [x] Dialog pembayaran + kalkulasi kembalian.
- [x] Dialog struk.
- [x] Fix overflow (Produk tab, Kasir dialog, Cart sheet).
### Sprint 2 — Auth & Role-Based Access (Selesai)
- [x] Setup Supabase Auth (email + password).
- [x] Tabel `profiles` dengan kolom `role`.
- [x] Screen login + loading state + error handling.
- [x] Logout icon di AppBar.
- [x] Integrasi role ke AppState (`userId`, `userRole`, `isAdmin`, `isKasir`, `isLoggedIn`).
- [x] Auth state listener (`onAuthStateChange`) untuk update otomatis saat login/logout.
- [x] UI adaptif sesuai role:
- Bottom nav: Kasir (2 tab), Admin (3 tab).
- Riwayat: Kasir lihat hari ini, Admin lihat semua.
- Produk: hanya admin.
- [x] Seed admin default via `_seedAdminIfEmpty()`.
- [x] Audit trail: tambah `user_id` pada model `Sale` dan simpan saat checkout.
- [x] Fix bottom nav assertion error untuk role kasir (nav disembunyikan jika < 2 item).
- [x] Fix login screen stuck/overflow dengan `SafeArea` + `SingleChildScrollView` + padding dinamis.
- [x] Optimasi `AuthGate` dengan `Selector` agar tidak rebuild berlebihan.
- [x] Rupiah input formatter untuk field harga/uang di Produk, Pembayaran, dan Diskon.
- [x] Scan barcode produk di form Produk menggunakan `mobile_scanner` + permission kamera.
### Sprint 3 — Admin Features
- [x] Riwayat transaksi dengan filter role-based (kasir: hari ini, admin: semua).
- [x] Summary cards adaptif sesuai role.
- [x] Filter date range untuk admin di riwayat menggunakan date range picker.
- [x] Manajemen akun kasir (tambah/hapus akun kasir dari dalam app).
- [x] Edit akun kasir (ubah nama/email/password via dialog edit, password pakai `auth.admin.updateUserById`).
- [x] Pengaturan toko (nama toko, mata uang, toggle reseller).
- [x] Export laporan ke PDF dan CSV (Excel) menggunakan `pdf`, `csv`, `path_provider`, dan `share_plus`.
### Sprint 4 — Polish & Integration
- [x] Export laporan ke PDF dan CSV (Excel) menggunakan `pdf`, `csv`, `path_provider`, dan `share_plus`.
- [x] Offline mode improvements (queue transaksi menggunakan `OfflineQueueService` + `connectivity_plus`).
- [x] Thermal printer Bluetooth (package `print_bluetooth_thermal` + `esc_pos_utils_plus`, ganti dari `blue_thermal_printer` yang gagal build).
- [x] Cetak struk langsung dari dialog pembayaran kasir.
- [x] Retur / Pembatalan transaksi (admin dapat batalkan transaksi dari riwayat, stok otomatis dipulihkan).
- [x] Stok minimum & notifikasi (field min_stock di produk, badge warning di list produk).
- [x] Laba / Rugi (harga pokok per produk, laporan laba kotor di riwayat).
- [x] Build release APK untuk distribusi.
- [ ] Testing & bug fixing menyeluruh.
## 15. Status Implementasi Saat Ini
### Fitur yang Sudah Berjalan
- ✅ Login/logout dengan Supabase Auth
- ✅ Role-based access: Admin (3 tab: Kasir, Produk, Riwayat, Pengaturan) dan Kasir (2 tab) — Akun Kasir & Printer digabung ke dalam tab Pengaturan
- ✅ CRUD Produk dengan barcode scanner
- ✅ Transaksi kasir dengan dual pricing (Umum/Reseller)
- ✅ Mode Reseller dapat dinyalakan/matikan dari Pengaturan Toko (saat mati, toggle harga reseller di kasir disembunyikan)
- ✅ Cart bottom sheet + diskon + pembayaran + struk
- ✅ Pembayaran: tombol "Selesai & Cetak" pakai loading guard (spinner + "Memproses...") agar tidak dobel klik
- ✅ Riwayat transaksi: terurut terbaru di atas, filter role-based, date range
- ✅ Detail riwayat: badge per-item "Reseller"/"Umum" + tombol "Cetak Struk"
- ✅ Manajemen akun kasir (tambah/hapus/edit)
- ✅ Pengaturan toko (nama, mata uang, toggle reseller) — nama app "Dutra Store"
- ✅ Export laporan PDF
- ⏸️ Export laporan CSV/Excel (ditunda untuk pengembangan berikutnya)
- ✅ Offline mode queue untuk transaksi
- ✅ Thermal printer Bluetooth (scan, connect, auto-reconnect, cetak struk ESC/POS)
- ✅ Cetak struk dari riwayat (tap transaksi → dialog detail → tombol "Cetak Struk")
- ✅ Rupiah input formatter
- ✅ Overflow fixes pada semua screen
- ✅ Retur/Pembatalan transaksi (admin dapat batalkan dari dialog detail riwayat, stok otomatis dipulihkan)
- ✅ Stok minimum & notifikasi (badge warning stok rendah di list produk)
- ✅ Laba/Rugi (harga pokok per produk, laporan laba kotor di riwayat: Laba Hari Ini untuk kasir, LABA KOTOR untuk admin)
- ✅ Tab Riwayat: summary cards compact (tap untuk expand), list transaksi ditampilkan di bawah summary, detail transaksi menggunakan bottom sheet
- ✅ Tab Riwayat: ringkasan bulanan dengan perbandingan MoM (Pendapatan, Laba Kotor, Transaksi) untuk admin
- ✅ Tab Riwayat: admin melihat transaksi bulan berjalan secara default (bukan semua transaksi)
- ✅ Tab Riwayat: summary cards dalam bentuk horizontal slider/carousel dengan dot indicator, swipe kiri/kanan untuk pindah card, card aktif menampilkan detail lengkap
- ✅ Tab Produk: form tambah produk dipindah ke bottom sheet (FAB + Tambah Produk), list produk tetap terlihat
- ✅ Tab Kasir: animasi pulse di cart bar saat produk ditambahkan
- ✅ Dialog pembayaran: loading overlay + disable input saat proses checkout + minimum 300ms visibility
- ✅ Dialog pembayaran: pilihan metode pembayaran (Tunai, Bank, QRIS, Split) dengan chip selector
- ✅ Dialog pembayaran: chip cepat untuk Tunai (Uang Pas, +5rb, +10rb, +20rb, +50rb, +100rb)
- ✅ Dialog pembayaran: split payment support dengan total split real-time
- ✅ Receipt & detail riwayat: menampilkan metode pembayaran dan breakdown split payment
- ✅ Sale model: field payment_method + split_payment untuk dokumentasi transaksi
- ✅ Release APK (app-release.apk 66.9MB)
### Fitur yang Didefer / Belum
- ✅ Ganti password akun kasir via Supabase admin API (`auth.admin.updateUserById` tersedia di gotrue 2.26.0).
- ✅ Testing & bug fixing menyeluruh (analyzer `No issues found!`, 0 error/warning).
- ⏳ Testing & bug fixing menyeluruh (belum dilakukan comprehensive testing).
- ✅ Build release APK untuk distribusi (app-release.apk 66.9MB).
### Catatan Teknis
- Nama aplikasi: **Dutra Store** (launcher label, AppBar, judul login, export).
- Admin tab dikurangi: Akun Kasir & Printer digabung ke dalam tab **Pengaturan** (admin sekarang 3 tab: Produk, Riwayat, Pengaturan).
- Printer auto-connect saat app start (MAC tersimpan di shared_preferences).
- `flutter analyze` sekarang **No issues found!** (semua `withOpacity` diganti `withValues(alpha:)`, `use_build_context_synchronously` di-handle, dead code dihapus).
- App sudah di-deploy ke device: **TECNO KM4** (Android 15) dan **RMX2061** (Android 11 API 30), keduanya berjalan normal.
- **Database migration**:
1. Jalankan SQL di `supabase/migrations/20240101_add_cancel_and_min_stock.sql` untuk kolom `is_cancelled` (sales) dan `min_stock` (products).
2. Jalankan SQL di `supabase/migrations/20240102_add_harga_pokok_and_profit.sql` untuk kolom `harga_pokok` (products) dan `total_cost` (sales).
3. Jalankan SQL di `supabase/migrations/20240103_add_payment_method.sql` untuk kolom `payment_method` dan `split_payment` (sales) — **sudah dijalankan**.
- UI/UX skill tersimpan di `.kilo/agent/uiux-pos-grosir.md` dan bagian PRD 10.1-10.7.
- Color scheme: brand amber dominant di Kasir & Produk, hijau untuk harga umum, merah untuk error/habis, abu untuk muted.

16
README.md Normal file
View File

@@ -0,0 +1,16 @@
# app_grosir
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

28
analysis_options.yaml Normal file
View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.grosir.app_grosir"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.grosir.app_grosir"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,64 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Camera permission for barcode scanning -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Bluetooth permissions for thermal printer -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:label="Dutra Store"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.flutter.share"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.grosir.app_grosir
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="external_files" path="." />
<cache-path name="cache" path="." />
<files-path name="files" path="." />
<external-cache-path name="external_cache" path="." />
</paths>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true

View File

@@ -0,0 +1,5 @@
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

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}
include(":app")

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

38
check_addform.py Normal file
View File

@@ -0,0 +1,38 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
stack = []
for i in range(179, 448):
line = lines[i]
in_string = False
escape = False
for j, c in enumerate(line):
if escape:
escape = False
continue
if c == '\\':
escape = True
continue
if c in "'\"":
in_string = not in_string
continue
if in_string:
continue
if c == '(':
stack.append((i+1, j+1))
elif c == ')':
if stack:
stack.pop()
else:
print(f'Unexpected ) at line {i+1}:{j+1}')
break
else:
continue
break
else:
if stack:
print(f'Unclosed ( at:')
for item in stack:
print(f' Line {item[0]}:{item[1]}')
else:
print('All balanced!')

39
check_addform_balance.py Normal file
View File

@@ -0,0 +1,39 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
# Check bracket balance for _showAddForm (lines 180-431)
stack = []
for i in range(179, 431):
line = lines[i]
in_string = False
escape = False
for j, c in enumerate(line):
if escape:
escape = False
continue
if c == '\\':
escape = True
continue
if c in "'\"":
in_string = not in_string
continue
if in_string:
continue
if c == '(':
stack.append((i+1, j+1, line.rstrip()))
elif c == ')':
if stack:
stack.pop()
else:
print(f'Unexpected ) at line {i+1}:{j+1}')
break
else:
continue
break
else:
if stack:
print(f'Unclosed ( at:')
for item in stack:
print(f' Line {item[0]}:{item[1]} | {item[2]}')
else:
print('All balanced!')

15
check_addform_parens.py Normal file
View File

@@ -0,0 +1,15 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
opens = 0
closes = 0
for i in range(179, 431):
line = lines[i]
o = line.count('(')
c = line.count(')')
opens += o
closes += c
if o != 0 or c != 0:
print(f'{i+1}: +{o}-{c} = {opens-closes} | {line.rstrip()}')
print(f'\nTotal (: {opens}, ): {closes}, diff: {opens-closes}')

27
check_addform_parens2.py Normal file
View File

@@ -0,0 +1,27 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
opens = 0
closes = 0
for i in range(179, 436):
line = lines[i]
in_string = False
escape = False
for c in line:
if escape:
escape = False
continue
if c == '\\':
escape = True
continue
if c in "'\"":
in_string = not in_string
continue
if in_string:
continue
if c == '(':
opens += 1
elif c == ')':
closes += 1
print(f'Total (: {opens}, ): {closes}, diff: {opens-closes}')

16
check_depth.py Normal file
View File

@@ -0,0 +1,16 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
depth = 0
for i in range(493, 644):
line = lines[i]
o = line.count('(')
c = line.count(')')
if depth == 0 and (o != 0 or c != 0):
print(f'Depth became 0 at line {i+1}: {line.rstrip()}')
depth += o - c
if depth < 0:
print(f'Negative depth at line {i+1}: {line.rstrip()}')
break
print(f'Final depth: {depth}')

10
check_depth2.py Normal file
View File

@@ -0,0 +1,10 @@
with open('D:\\app\\ai\\app-grosir\\test_syntax2.dart', 'r') as f:
lines = f.readlines()
depth = 0
for i, line in enumerate(lines):
opens = line.count('(')
closes = line.count(')')
if opens != 0 or closes != 0 or depth != 0:
depth += opens - closes
print(f'{i+1}: +{opens}-{closes} = {depth} | {line.rstrip()}')

15
check_parens.py Normal file
View File

@@ -0,0 +1,15 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
opens = 0
closes = 0
for i in range(493, 644):
line = lines[i]
o = line.count('(')
c = line.count(')')
opens += o
closes += c
if o != 0 or c != 0:
print(f'{i+1}: +{o}-{c} = {opens-closes} | {line.rstrip()}')
print(f'Total (: {opens}, ): {closes}')

23
check_parens_detail.py Normal file
View File

@@ -0,0 +1,23 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
for i in range(493, 644):
line = lines[i]
in_string = False
escape = False
for j, c in enumerate(line):
if escape:
escape = False
continue
if c == '\\':
escape = True
continue
if c in "'\"":
in_string = not in_string
continue
if in_string:
continue
if c == '(':
print(f'{i+1}:{j+1} OPEN | {line.rstrip()}')
elif c == ')':
print(f'{i+1}:{j+1} CLOSE | {line.rstrip()}')

View File

@@ -0,0 +1,29 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
depth = 0
for i in range(493, 644):
line = lines[i]
in_string = False
escape = False
for c in line:
if escape:
escape = False
continue
if c == '\\':
escape = True
continue
if c in "'\"":
in_string = not in_string
continue
if in_string:
continue
if c == '(':
depth += 1
elif c == ')':
depth -= 1
if depth < 0:
print(f'Negative depth at line {i+1}: {line.rstrip()}')
break
print(f'Final depth: {depth}')

23
check_strings.py Normal file
View File

@@ -0,0 +1,23 @@
with open('D:\\app\\ai\\app-grosir\\test_syntax2.dart', 'r') as f:
lines = f.readlines()
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith('//') or stripped.startswith('/*') or stripped.startswith('*'):
continue
in_string = False
escape = False
for j, c in enumerate(line):
if escape:
escape = False
continue
if c == '\\':
escape = True
continue
if c in "'\"":
if not in_string:
in_string = True
else:
in_string = False
if in_string:
print(f'Unclosed string at line {i+1}: {line.rstrip()}')

38
check_unclosed.py Normal file
View File

@@ -0,0 +1,38 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
stack = []
for i in range(493, 644):
line = lines[i]
in_string = False
escape = False
for j, c in enumerate(line):
if escape:
escape = False
continue
if c == '\\':
escape = True
continue
if c in "'\"":
in_string = not in_string
continue
if in_string:
continue
if c == '(':
stack.append((i+1, j+1, line.rstrip()))
elif c == ')':
if stack:
stack.pop()
else:
print(f'Unexpected ) at line {i+1}:{j+1}')
break
else:
continue
break
else:
if stack:
print(f'Unclosed ( at:')
for item in stack:
print(f' Line {item[0]}:{item[1]} | {item[2]}')
else:
print('All balanced!')

38
check_unclosed2.py Normal file
View File

@@ -0,0 +1,38 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
stack = []
for i in range(493, 649):
line = lines[i]
in_string = False
escape = False
for j, c in enumerate(line):
if escape:
escape = False
continue
if c == '\\':
escape = True
continue
if c in "'\"":
in_string = not in_string
continue
if in_string:
continue
if c == '(':
stack.append((i+1, j+1, line.rstrip()))
elif c == ')':
if stack:
stack.pop()
else:
print(f'Unexpected ) at line {i+1}:{j+1}')
break
else:
continue
break
else:
if stack:
print(f'Unclosed ( at:')
for item in stack:
print(f' Line {item[0]}:{item[1]} | {item[2]}')
else:
print('All balanced!')

35
fix_indent.py Normal file
View File

@@ -0,0 +1,35 @@
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
lines = f.readlines()
# Find the start and end of _showAddForm
start = None
end = None
for i, line in enumerate(lines):
if line.strip().startswith('void _showAddForm'):
start = i
if start is not None and line.strip() == '}' and i > start + 5:
# Check if this is the end of _showAddForm
# It should be followed by empty line or another top-level declaration
if i + 1 < len(lines):
next_line = lines[i + 1].strip()
if next_line == '' or next_line.startswith('void ') or next_line.startswith('class '):
end = i
break
print(f'_showAddForm: lines {start+1} to {end+1}')
# Remove 2-space indent from _showAddForm function
for i in range(start, end + 1):
if lines[i].startswith(' '):
lines[i] = lines[i][2:]
# After _showAddForm, there might be an extra class closing }
# The class _ProdukScreenState should close right after build method
# Let's find the class closing after _showAddForm and remove it if it's extra
# After _showAddForm ends, the next line should be empty or a function/class
# If there's a } followed by empty line and then another }, remove the first }
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'w') as f:
f.writelines(lines)
print('Done')

1
git.txt Normal file
View File

@@ -0,0 +1 @@
https://github.com/HiiTayo/app-grosir.git

34
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.grosir.appGrosir;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.grosir.appGrosir.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.grosir.appGrosir.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.grosir.appGrosir.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.grosir.appGrosir;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.grosir.appGrosir;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

49
ios/Runner/Info.plist Normal file
View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>App Grosir</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>app_grosir</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

176
lib/main.dart Normal file
View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'config.dart';
import 'state/app_state.dart';
import 'services/printer_service.dart';
import 'screens/login_screen.dart';
import 'screens/home_page.dart';
import 'utils/colors.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(url: supabaseUrl, publishableKey: supabaseAnonKey);
await printerService.init();
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
late final AppState _appState;
@override
void initState() {
super.initState();
_appState = AppState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_appState.stopPolling();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_appState.startPolling();
} else {
_appState.stopPolling();
}
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: _appState,
child: MaterialApp(
title: 'Dutra Store',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: kBackground,
colorScheme: ColorScheme.dark(
primary: kAccent,
secondary: kSuccess,
surface: kSurface,
onSurface: kText,
error: kDanger,
),
appBarTheme: const AppBarTheme(
backgroundColor: kBackground,
foregroundColor: kText,
elevation: 0,
centerTitle: true,
titleTextStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: kText,
),
),
cardTheme: CardThemeData(
color: kSurface2,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadius),
side: const BorderSide(color: kBorder),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: kSurface2,
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: kBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: kAccent, width: 1.5),
),
labelStyle: const TextStyle(color: kMuted),
hintStyle: const TextStyle(color: kMuted),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: kAccent,
foregroundColor: const Color(0xFF1A1206),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
dialogTheme: DialogThemeData(
backgroundColor: kSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
titleTextStyle: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: kText,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: kSurface,
selectedItemColor: kAccent,
unselectedItemColor: kMuted,
selectedLabelStyle: TextStyle(fontWeight: FontWeight.w600),
type: BottomNavigationBarType.fixed,
elevation: 0,
),
),
home: const AuthGate(),
),
);
}
}
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context) {
return Selector<AppState, bool>(
selector: (context, state) => state.loading,
builder: (context, loading, child) {
if (loading) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(color: kAccent),
),
);
}
return Selector<AppState, bool>(
selector: (context, state) => state.isLoggedIn,
builder: (context, loggedIn, child) {
if (loggedIn) {
return const HomePage();
}
return const LoginScreen();
},
);
},
);
}
}

51
lib/models/product.dart Normal file
View File

@@ -0,0 +1,51 @@
class Product {
final String id;
final String name;
final String barcode;
final double price;
final double priceReseller;
final int stock;
final int minStock;
final double hargaPokok;
Product({
required this.id,
required this.name,
this.barcode = '',
required this.price,
this.priceReseller = 0,
required this.stock,
this.minStock = 5,
this.hargaPokok = 0,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as String,
name: json['name'] as String,
barcode: json['barcode'] != null ? json['barcode'] as String : '',
price: (json['price'] as num).toDouble(),
priceReseller: json['price_reseller'] != null
? (json['price_reseller'] as num).toDouble()
: 0,
stock: json['stock'] as int,
minStock: json['min_stock'] != null
? (json['min_stock'] as int)
: 5,
hargaPokok: json['harga_pokok'] != null
? (json['harga_pokok'] as num).toDouble()
: 0,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'barcode': barcode,
'price': price,
'price_reseller': priceReseller,
'stock': stock,
'min_stock': minStock,
'harga_pokok': hargaPokok,
};
}

118
lib/models/sale.dart Normal file
View File

@@ -0,0 +1,118 @@
class SaleItem {
final String productId;
final String name;
final double price;
final int qty;
final bool isReseller;
SaleItem({
required this.productId,
required this.name,
required this.price,
required this.qty,
this.isReseller = false,
});
double get subtotal => price * qty;
factory SaleItem.fromJson(Map<String, dynamic> json) {
return SaleItem(
productId: json['product_id'] as String? ?? '',
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
qty: json['qty'] as int,
isReseller: json['isReseller'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() => {
'product_id': productId,
'name': name,
'price': price,
'qty': qty,
'isReseller': isReseller,
};
}
class Sale {
final String id;
final DateTime date;
final String? userId;
final List<SaleItem> items;
final double subtotal;
final double discount;
final double total;
final double paid;
final double change;
final double totalCost;
final bool isCancelled;
final String paymentMethod;
final String paymentStatus;
final double dueAmount;
final String? customerPhone;
final Map<String, double>? splitPayment;
Sale({
required this.id,
required this.date,
this.userId,
required this.items,
required this.subtotal,
required this.discount,
required this.total,
required this.paid,
required this.change,
required this.totalCost,
this.isCancelled = false,
this.paymentMethod = 'tunai',
this.paymentStatus = 'paid',
this.dueAmount = 0.0,
this.customerPhone,
this.splitPayment,
});
factory Sale.fromJson(Map<String, dynamic> json) {
return Sale(
id: json['id'] as String,
date: DateTime.parse(json['created_at'] as String),
userId: json['user_id'] as String?,
items: (json['items'] as List)
.map((e) => SaleItem.fromJson(e as Map<String, dynamic>))
.toList(),
subtotal: (json['subtotal'] as num).toDouble(),
discount: (json['discount'] as num).toDouble(),
total: (json['total'] as num).toDouble(),
paid: (json['paid'] as num).toDouble(),
change: (json['change'] as num).toDouble(),
totalCost: (json['total_cost'] as num?)?.toDouble() ?? 0,
isCancelled: json['is_cancelled'] as bool? ?? false,
paymentMethod: json['payment_method'] as String? ?? 'tunai',
paymentStatus: json['payment_status'] as String? ?? 'paid',
dueAmount: (json['due_amount'] as num?)?.toDouble() ?? 0.0,
customerPhone: json['customer_phone'] as String?,
splitPayment: json['split_payment'] != null
? Map<String, double>.from(json['split_payment'] as Map)
: null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'created_at': date.toIso8601String(),
'user_id': userId,
'items': items.map((e) => e.toJson()).toList(),
'subtotal': subtotal,
'discount': discount,
'total': total,
'paid': paid,
'change': change,
'total_cost': totalCost,
'is_cancelled': isCancelled,
'payment_method': paymentMethod,
'payment_status': paymentStatus,
'due_amount': dueAmount,
'customer_phone': customerPhone,
if (splitPayment != null && splitPayment!.isNotEmpty)
'split_payment': splitPayment,
};
}

View File

@@ -0,0 +1,25 @@
class UserProfile {
final String id;
final String role;
final String? fullName;
UserProfile({
required this.id,
required this.role,
this.fullName,
});
factory UserProfile.fromJson(Map<String, dynamic> json) {
return UserProfile(
id: json['id'] as String,
role: json['role'] as String,
fullName: json['full_name'] as String?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'role': role,
'full_name': fullName,
};
}

120
lib/screens/home_page.dart Normal file
View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'kasir_screen.dart';
import 'produk_screen.dart';
import 'riwayat_screen.dart';
import 'pengaturan_screen.dart';
import 'printer_screen.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _index = 0;
List<Widget> _screensForRole(String? role) {
final screens = <Widget>[const KasirScreen()];
if (role == 'admin') {
screens.addAll([
const ProdukScreen(),
const RiwayatScreen(),
const PengaturanScreen(),
]);
} else {
screens.addAll([
const RiwayatScreen(),
const PrinterScreen(),
]);
}
return screens;
}
List<BottomNavigationBarItem> _navItemsForRole(String? role) {
final items = <BottomNavigationBarItem>[
const BottomNavigationBarItem(
icon: Icon(Icons.point_of_sale),
label: 'Kasir',
),
];
if (role == 'admin') {
items.addAll([
const BottomNavigationBarItem(
icon: Icon(Icons.inventory_2),
label: 'Produk',
),
const BottomNavigationBarItem(
icon: Icon(Icons.receipt_long),
label: 'Riwayat',
),
const BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
label: 'Pengaturan',
),
]);
} else {
items.addAll([
const BottomNavigationBarItem(
icon: Icon(Icons.receipt_long),
label: 'Riwayat',
),
const BottomNavigationBarItem(
icon: Icon(Icons.print_outlined),
label: 'Printer',
),
]);
}
return items;
}
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
final screens = _screensForRole(state.userRole);
final navItems = _navItemsForRole(state.userRole);
final safeIndex = _index >= screens.length ? 0 : _index;
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.storefront_rounded, color: kAccent),
const SizedBox(width: 8),
const Text('Dutra Store'),
],
),
centerTitle: true,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.logout, color: kMuted),
onPressed: () async {
await context.read<AppState>().logout();
},
tooltip: 'Logout',
),
],
),
body: IndexedStack(
index: safeIndex,
children: screens,
),
bottomNavigationBar: navItems.length >= 2
? BottomNavigationBar(
currentIndex: safeIndex,
onTap: (i) => setState(() => _index = i),
items: navItems,
)
: null,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,372 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
class KelolaKasirScreen extends StatefulWidget {
final bool embedded;
const KelolaKasirScreen({super.key, this.embedded = false});
@override
State<KelolaKasirScreen> createState() => _KelolaKasirScreenState();
}
class _KelolaKasirScreenState extends State<KelolaKasirScreen> {
final TextEditingController _emailCtrl = TextEditingController();
final TextEditingController _passwordCtrl = TextEditingController();
final TextEditingController _nameCtrl = TextEditingController();
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
context.read<AppState>().loadKasirAccounts();
}
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
_nameCtrl.dispose();
super.dispose();
}
Future<void> _addKasir() async {
final email = _emailCtrl.text.trim();
final password = _passwordCtrl.text;
final name = _nameCtrl.text.trim();
if (email.isEmpty || password.isEmpty) {
setState(() => _error = 'Email dan password harus diisi');
return;
}
setState(() {
_loading = true;
_error = null;
});
final state = context.read<AppState>();
final err = await state.createKasir(email, password, name);
if (mounted) {
setState(() => _loading = false);
if (err != null) {
setState(() => _error = err);
} else {
_emailCtrl.clear();
_passwordCtrl.clear();
_nameCtrl.clear();
}
}
}
@override
Widget build(BuildContext context) {
final body = _buildBody(context);
if (widget.embedded) return body;
return Scaffold(
appBar: AppBar(
title: const Text('Akun Kasir'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: body,
),
);
}
Widget _buildBody(BuildContext context) {
final state = context.watch<AppState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.person_add_outlined, color: kAccent),
const SizedBox(width: 8),
Text(
'Tambah Akun Kasir',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: kText),
),
],
),
const SizedBox(height: 14),
TextField(
controller: _nameCtrl,
decoration: const InputDecoration(
labelText: 'Nama Lengkap',
prefixIcon: Icon(Icons.person_outline),
),
),
const SizedBox(height: 10),
TextField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
),
const SizedBox(height: 10),
TextField(
controller: _passwordCtrl,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock_outlined),
),
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(_error!,
style: const TextStyle(color: kDanger, fontSize: 13)),
],
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _loading ? null : _addKasir,
icon: _loading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Color(0xFF1A1206)),
)
: const Icon(Icons.add),
label: Text(_loading ? 'Menyimpan...' : 'Tambah Kasir'),
),
),
],
),
),
),
const SizedBox(height: 12),
Text(
'Daftar Akun Kasir',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: kText),
),
const SizedBox(height: 10),
if (state.kasirAccounts.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: const [
Icon(Icons.people_outline, size: 48, color: kMuted),
SizedBox(height: 8),
Text('Belum ada akun kasir', style: TextStyle(color: kMuted)),
],
),
)
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: state.kasirAccounts.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final kasir = state.kasirAccounts[i];
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: kAccent.withValues(alpha: 0.15),
child: Text(
(kasir['full_name']?.toString().isNotEmpty == true
? kasir['full_name'].toString()
: kasir['email']?.toString())
?.isNotEmpty ==
true
? (kasir['full_name']?.toString().isNotEmpty == true
? kasir['full_name'].toString()
: kasir['email'].toString())[0].toUpperCase()
: '?',
style: TextStyle(color: kAccent, fontWeight: FontWeight.w700),
),
),
title: Text(
(kasir['full_name']?.toString().isNotEmpty == true
? kasir['full_name'].toString()
: kasir['email']?.toString().isNotEmpty == true
? kasir['email'].toString()
: 'Tanpa Nama'),
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${kasir['role'] == 'kasir' ? 'Kasir' : 'Admin'}${kasir['email']?.toString().isNotEmpty == true ? ' • ${kasir['email']}' : ''}',
style: const TextStyle(fontSize: 12, color: kMuted),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
color: kAccent,
onPressed: () {
_showEditDialog(context, state, kasir);
},
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
color: kDanger,
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
icon: Icon(Icons.person_remove_outlined, color: kDanger, size: 28),
title: const Text('Hapus Akun'),
content: Text(
'Yakin ingin menghapus akun ${kasir['full_name']?.toString().isNotEmpty == true ? kasir['full_name'] : (kasir['email']?.toString().isNotEmpty == true ? kasir['email'] : '')}?'),
actions: [
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pop(context, false),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
),
child: const Text('Batal'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
backgroundColor: kDanger,
),
child: const Text('Hapus'),
),
),
],
),
);
if (confirm == true && mounted) {
await state.deleteKasir(kasir['id'].toString());
}
},
),
],
),
),
);
},
),
],
);
}
void _showEditDialog(BuildContext context, AppState state, Map<String, dynamic> kasir) {
final emailCtrl = TextEditingController(text: kasir['email']?.toString() ?? '');
final nameCtrl = TextEditingController(text: kasir['full_name']?.toString() ?? '');
final passwordCtrl = TextEditingController();
bool isLoading = false;
String? error;
showDialog(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (ctx, setDialog) {
Future<void> submit() async {
final email = emailCtrl.text.trim();
final name = nameCtrl.text.trim();
final password = passwordCtrl.text;
if (email.isEmpty) {
setDialog(() => error = 'Email harus diisi');
return;
}
setDialog(() {
isLoading = true;
error = null;
});
final err = await state.updateKasir(
kasir['id'].toString(),
email,
name,
password.isEmpty ? null : password,
);
if (!dialogContext.mounted) return;
setDialog(() => isLoading = false);
if (err != null) {
setDialog(() => error = err);
} else {
Navigator.pop(dialogContext);
}
}
return AlertDialog(
title: const Text('Edit Akun Kasir'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
),
const SizedBox(height: 10),
TextField(
controller: nameCtrl,
decoration: const InputDecoration(
labelText: 'Nama Lengkap',
prefixIcon: Icon(Icons.person_outline),
),
),
const SizedBox(height: 10),
TextField(
controller: passwordCtrl,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password (kosongkan jika tidak diubah)',
prefixIcon: Icon(Icons.lock_outlined),
),
),
if (error != null) ...[
const SizedBox(height: 10),
Text(error!, style: const TextStyle(color: kDanger, fontSize: 13)),
],
],
),
actions: [
TextButton(
onPressed: isLoading ? null : () => Navigator.pop(dialogContext),
child: const Text('Batal'),
),
FilledButton(
onPressed: isLoading ? null : submit,
style: FilledButton.styleFrom(backgroundColor: kAccent),
child: isLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Color(0xFF1A1206)),
)
: const Text('Simpan'),
),
],
);
},
);
},
);
}
}

View File

@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
String? _error;
bool _loading = false;
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
final email = _emailCtrl.text.trim();
final password = _passwordCtrl.text;
if (email.isEmpty || password.isEmpty) {
setState(() => _error = 'Email dan password harus diisi');
return;
}
setState(() {
_loading = true;
_error = null;
});
final state = context.read<AppState>();
final err = await state.login(email, password);
if (mounted) {
setState(() => _loading = false);
if (err != null) {
setState(() => _error = err);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 24,
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Column(
children: [
const SizedBox(height: 80),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: const Icon(Icons.storefront_rounded,
size: 48, color: kAccent),
),
const SizedBox(height: 24),
Text(
'Dutra Store',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: kText,
),
),
const SizedBox(height: 8),
Text(
'Masuk untuk melanjutkan',
style: TextStyle(color: kMuted, fontSize: 14),
),
const SizedBox(height: 32),
TextField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(color: kText),
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined, color: kMuted),
),
),
const SizedBox(height: 16),
TextField(
controller: _passwordCtrl,
obscureText: true,
style: const TextStyle(color: kText),
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock_outlined, color: kMuted),
),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!,
style: const TextStyle(color: kDanger, fontSize: 13)),
],
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Color(0xFF1A1206)),
)
: const Text('Masuk'),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
import 'kelola_kasir_screen.dart';
import 'printer_screen.dart';
class PengaturanScreen extends StatefulWidget {
const PengaturanScreen({super.key});
@override
State<PengaturanScreen> createState() => _PengaturanScreenState();
}
class _PengaturanScreenState extends State<PengaturanScreen> {
final TextEditingController _storeNameCtrl = TextEditingController();
final TextEditingController _currencyCtrl = TextEditingController(text: 'IDR');
final TextEditingController _whatsappCtrl = TextEditingController();
final TextEditingController _notesCtrl = TextEditingController();
bool _resellerEnabled = true;
bool _loading = true;
@override
void initState() {
super.initState();
_loadSettings();
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_storeNameCtrl.text = prefs.getString('store_name') ?? 'Toko Grosir';
_currencyCtrl.text = prefs.getString('currency') ?? 'IDR';
_whatsappCtrl.text = prefs.getString('store_whatsapp') ?? '';
_notesCtrl.text = prefs.getString('store_notes') ?? '';
_resellerEnabled = prefs.getBool('reseller_enabled') ?? true;
_loading = false;
});
}
Future<void> _saveSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('store_name', _storeNameCtrl.text.trim());
await prefs.setString('currency', _currencyCtrl.text.trim());
await prefs.setString('store_whatsapp', _whatsappCtrl.text.trim());
await prefs.setString('store_notes', _notesCtrl.text.trim());
await prefs.setBool('reseller_enabled', _resellerEnabled);
if (!mounted) return;
final state = context.read<AppState>();
state.setResellerEnabled(_resellerEnabled);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Center(child: CircularProgressIndicator(color: kAccent));
}
return RefreshIndicator(
onRefresh: () async {
context.read<AppState>().recordManualRefresh();
await context.read<AppState>().refresh();
},
color: kAccent,
child: ListView(
padding: const EdgeInsets.all(12),
children: [
_SettingsListTile(
icon: Icons.store_outlined,
title: 'Pengaturan Toko',
subtitle: _whatsappCtrl.text.isEmpty
? '${_storeNameCtrl.text} • ${_currencyCtrl.text}'
: '${_storeNameCtrl.text} • ${_currencyCtrl.text} • ${_whatsappCtrl.text}',
onTap: () => _showStoreSettingsDialog(context),
),
const SizedBox(height: 8),
_SettingsListTile(
icon: Icons.people_outline,
title: 'Akun Kasir',
subtitle: 'Tambah, edit, atau hapus akun kasir',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const KelolaKasirScreen(),
),
);
},
),
const SizedBox(height: 8),
_SettingsListTile(
icon: Icons.print_outlined,
title: 'Printer Bluetooth',
subtitle: 'Scan, connect, dan cetak struk',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PrinterScreen(),
),
);
},
),
],
),
);
}
Future<void> _showStoreSettingsDialog(BuildContext context) async {
final storeNameCtrl = TextEditingController(text: _storeNameCtrl.text);
final currencyCtrl = TextEditingController(text: _currencyCtrl.text);
final whatsappCtrl = TextEditingController(text: _whatsappCtrl.text);
final notesCtrl = TextEditingController(text: _notesCtrl.text);
bool resellerEnabled = _resellerEnabled;
final messenger = ScaffoldMessenger.of(context);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setModalState) => AlertDialog(
title: const Text('Pengaturan Toko'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: storeNameCtrl,
decoration: const InputDecoration(
labelText: 'Nama Toko',
prefixIcon: Icon(Icons.badge_outlined),
),
),
const SizedBox(height: 10),
TextField(
controller: currencyCtrl,
decoration: const InputDecoration(
labelText: 'Mata Uang',
prefixIcon: Icon(Icons.money_outlined),
),
),
const SizedBox(height: 10),
TextField(
controller: whatsappCtrl,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'No. WhatsApp',
prefixIcon: Icon(Icons.phone_outlined),
helperText: 'Contoh: 081234567890',
),
),
const SizedBox(height: 10),
TextField(
controller: notesCtrl,
maxLines: 3,
decoration: const InputDecoration(
labelText: 'Catatan Tambahan',
prefixIcon: Icon(Icons.notes_outlined),
helperText: 'Opsional, akan dicetak di struk',
),
),
const SizedBox(height: 10),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Mode Reseller Aktif',
style: TextStyle(fontSize: 14)),
subtitle: const Text(
'Jika aktif, kasir bisa toggle harga reseller per transaksi',
style: TextStyle(fontSize: 12, color: kMuted)),
value: resellerEnabled,
onChanged: (v) => setModalState(() => resellerEnabled = v),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Batal'),
),
FilledButton.icon(
onPressed: () async {
_storeNameCtrl.text = storeNameCtrl.text.trim();
_currencyCtrl.text = currencyCtrl.text.trim();
_whatsappCtrl.text = whatsappCtrl.text.trim();
_notesCtrl.text = notesCtrl.text.trim();
_resellerEnabled = resellerEnabled;
await _saveSettings();
if (ctx.mounted) {
Navigator.pop(ctx, true);
}
},
icon: const Icon(Icons.save_outlined, size: 18),
label: const Text('Simpan'),
),
],
),
),
);
if (result == true && mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Pengaturan tersimpan'),
backgroundColor: kSuccess,
),
);
}
}
@override
void dispose() {
_storeNameCtrl.dispose();
_currencyCtrl.dispose();
_whatsappCtrl.dispose();
_notesCtrl.dispose();
super.dispose();
}
}
class _SettingsListTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _SettingsListTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: kAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: kAccent, size: 20),
),
title: Text(
title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
subtitle: Text(
subtitle,
style: const TextStyle(fontSize: 12, color: kMuted),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Icon(Icons.chevron_right_rounded, color: kMuted, size: 20),
onTap: onTap,
),
);
}
}

View File

@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
import '../services/printer_service.dart';
import '../utils/colors.dart';
class PrinterScreen extends StatefulWidget {
final bool embedded;
const PrinterScreen({super.key, this.embedded = false});
@override
State<PrinterScreen> createState() => _PrinterScreenState();
}
class _PrinterScreenState extends State<PrinterScreen> {
List<BluetoothInfo> _devices = [];
bool _loading = false;
bool _scanning = false;
bool _connecting = false;
@override
void initState() {
super.initState();
_loadDevices();
}
Future<void> _loadDevices() async {
setState(() => _loading = true);
try {
final enabled = await printerService.isBluetoothEnabled();
if (!enabled) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Bluetooth belum aktif. Nyalakan Bluetooth di HP.'),
backgroundColor: kDanger,
),
);
}
}
_devices = await printerService.getPairedDevices();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal memuat printer: $e')),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _scan() async {
setState(() => _scanning = true);
try {
_devices = await printerService.getPairedDevices();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal scan: $e')),
);
}
} finally {
if (mounted) setState(() => _scanning = false);
}
}
Future<void> _connect(BluetoothInfo device) async {
setState(() => _connecting = true);
final ok = await printerService.connect(device.macAdress);
if (mounted) {
setState(() => _connecting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ok
? 'Terhubung ke ${device.name}'
: 'Gagal hubungkan ${device.name}'),
backgroundColor: ok ? kSuccess : kDanger,
),
);
if (ok) setState(() {});
}
}
Future<void> _disconnect() async {
await printerService.disconnect();
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final body = _buildBody(context);
if (widget.embedded) return body;
return Scaffold(
appBar: AppBar(
title: const Text('Printer Bluetooth'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: body,
),
);
}
Widget _buildBody(BuildContext context) {
final connected = printerService.isConnected;
final connectedMac = printerService.connectedMac;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.print_outlined, color: kAccent),
const SizedBox(width: 8),
Text(
'Printer Bluetooth',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: kText),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: connected
? kSuccess.withValues(alpha: 0.12)
: kSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: connected ? kSuccess : kBorder,
),
),
child: Row(
children: [
Icon(
connected ? Icons.bluetooth_connected : Icons.bluetooth,
color: connected ? kSuccess : kMuted,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
connected ? 'Terhubung' : 'Tidak terhubung',
style: TextStyle(
fontWeight: FontWeight.w600,
color: connected ? kSuccess : kMuted,
),
),
if (connected && connectedMac != null)
Text(
connectedMac,
style: const TextStyle(
fontSize: 12, color: kMuted),
),
],
),
),
if (connected)
TextButton(
onPressed: _connecting ? null : _disconnect,
child: const Text('Putus'),
),
],
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _scanning ? null : _scan,
icon: _scanning
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: kAccent),
)
: const Icon(Icons.refresh),
label: Text(_scanning ? 'Scanning...' : 'Scan Ulang'),
),
),
],
),
],
),
),
),
const SizedBox(height: 12),
Text(
'Perangkat Tersedia',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: kText),
),
const SizedBox(height: 10),
if (_loading)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: CircularProgressIndicator(color: kAccent),
))
else if (_devices.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
Icon(Icons.print_disabled_outlined, size: 48, color: kMuted),
SizedBox(height: 8),
Text('Tidak ada printer terpasang',
style: TextStyle(color: kMuted)),
SizedBox(height: 4),
Text('Pasangkan printer di pengaturan Bluetooth HP',
style: TextStyle(color: kMuted, fontSize: 12)),
],
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _devices.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, i) {
final d = _devices[i];
final isThis = connected && connectedMac == d.macAdress;
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: isThis
? kSuccess.withValues(alpha: 0.15)
: kAccent.withValues(alpha: 0.15),
child: Icon(
isThis ? Icons.bluetooth_connected : Icons.print_outlined,
color: isThis ? kSuccess : kAccent,
),
),
title: Text(d.name.isNotEmpty ? d.name : 'Printer',
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(d.macAdress,
style: const TextStyle(fontSize: 12, color: kMuted)),
trailing: _connecting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: kAccent),
)
: isThis
? const Chip(
label: Text('Aktif',
style: TextStyle(fontSize: 11)),
backgroundColor: kSuccess,
labelPadding: EdgeInsets.zero,
)
: TextButton(
onPressed: () => _connect(d),
child: const Text('Hubungkan'),
),
),
);
},
),
],
);
}
}

View File

@@ -0,0 +1,802 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../models/product.dart';
import '../state/app_state.dart';
import '../utils/colors.dart';
import '../utils/format.dart';
import '../utils/rupiah_input.dart';
import 'scan_screen.dart';
Future<bool> confirmResellerWarning(BuildContext context, double general, double reseller) async {
if (reseller <= 0 || reseller <= general) return true;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(Icons.warning_amber_rounded, color: kAccent, size: 28),
title: const Text('Peringatan'),
content: Text('Harga reseller (${formatRupiah(reseller)}) lebih tinggi dari harga umum (${formatRupiah(general)}).'),
actions: [
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx, false),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
),
child: const Text('Batal'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: kAccent),
child: const Text('Tetap Simpan'),
),
),
],
),
);
return confirmed ?? false;
}
class ProdukScreen extends StatefulWidget {
const ProdukScreen({super.key});
@override
State<ProdukScreen> createState() => _ProdukScreenState();
}
class _ProdukScreenState extends State<ProdukScreen> {
final TextEditingController _search = TextEditingController();
@override
void initState() {
super.initState();
_search.addListener(() {
if (mounted) context.read<AppState>().resetPagination();
});
}
@override
void dispose() {
_search.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = context.watch<AppState>();
if (state.loading) {
return const Center(child: CircularProgressIndicator(color: kAccent));
}
final query = _search.text.toLowerCase();
final filtered = state.products
.where((p) =>
p.name.toLowerCase().contains(query) ||
p.barcode.toLowerCase().contains(query))
.toList();
final products = filtered.take(state.visibleProductCount).toList();
return RefreshIndicator(
onRefresh: () async {
context.read<AppState>().recordManualRefresh();
await context.read<AppState>().refresh();
},
color: kAccent,
child: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
children: [
TextField(
controller: _search,
onChanged: (_) => setState(() {}),
style: const TextStyle(color: kText),
decoration: InputDecoration(
hintText: 'Cari produk...',
prefixIcon: const Icon(Icons.search, color: kMuted),
suffixIcon: _search.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.close, color: kMuted),
onPressed: () {
_search.clear();
setState(() {});
},
)
: null,
),
),
const SizedBox(height: 12),
Row(
children: [
Text(
'Daftar Produk',
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w700),
),
const Spacer(),
FilledButton.icon(
onPressed: () => _showAddForm(context, state),
icon: const Icon(Icons.add, size: 18),
label: const Text('Tambah Produk'),
style: FilledButton.styleFrom(
backgroundColor: kAccent,
foregroundColor: const Color(0xFF06210F),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
visualDensity: VisualDensity.compact,
),
),
],
),
const SizedBox(height: 12),
if (products.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inventory_2_outlined, size: 48, color: kMuted),
SizedBox(height: 8),
Text('Belum ada produk', style: TextStyle(color: kMuted)),
],
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: products.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) =>
_ProductRow(p: products[i], state: state),
),
if (state.hasMoreProducts)
Padding(
padding: const EdgeInsets.only(top: 12),
child: FilledButton.icon(
onPressed: () => state.loadMoreProducts(),
icon: const Icon(Icons.expand_more, size: 18),
label: Text('Muat lebih banyak (${state.products.length - state.visibleProductCount} tersisa)'),
),
),
],
),
),
],
),
);
}
}
void _showAddForm(BuildContext context, AppState state) {
final name = TextEditingController();
final barcode = TextEditingController();
final price = TextEditingController();
final priceReseller = TextEditingController();
final stock = TextEditingController();
final minStockCtrl = TextEditingController(text: '5');
final hargaPokokCtrl = TextEditingController();
final focusNodes = <FocusNode>[
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
];
bool _listenersAdded = false;
final _sheetController = DraggableScrollableController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => DraggableScrollableSheet(
controller: _sheetController,
initialChildSize: 0.65,
minChildSize: 0.5,
maxChildSize: 0.92,
builder: (_, scrollController) {
if (!_listenersAdded) {
_listenersAdded = true;
for (final node in focusNodes) {
node.addListener(() {
if (node.hasFocus && context.mounted) {
_sheetController.animateTo(
0.92,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
Future.delayed(const Duration(milliseconds: 350), () {
if (node.context != null && node.context!.mounted) {
Scrollable.ensureVisible(
node.context!,
duration: const Duration(milliseconds: 250),
alignment: 0.0,
);
}
});
}
});
}
}
return Container(
decoration: const BoxDecoration(
color: kSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
border: Border(top: BorderSide(color: kBorder)),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: kBorder,
borderRadius: BorderRadius.circular(4),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Icon(Icons.add_box_outlined, color: kAccent),
const SizedBox(width: 8),
const Text('Tambah Produk',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: kMuted),
onPressed: () => Navigator.pop(sheetContext),
tooltip: 'Tutup',
),
],
),
),
const Divider(height: 12),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: EdgeInsets.fromLTRB(16, 8, 16, MediaQuery.of(context).viewInsets.bottom + 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: barcode,
focusNode: focusNodes[0],
decoration: InputDecoration(
labelText: 'Barcode / No. Produk',
prefixIcon: const Icon(Icons.qr_code_2_outlined),
suffixIcon: IconButton(
icon: const Icon(Icons.qr_code_scanner_outlined, color: kAccent),
onPressed: () async {
final code = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (_) => const ScanScreen(
title: 'Scan Barcode Produk',
),
),
);
if (code != null && context.mounted) {
barcode.text = code;
}
},
tooltip: 'Scan barcode',
)),
),
const SizedBox(height: 10),
TextField(
controller: name,
focusNode: focusNodes[1],
decoration: const InputDecoration(
labelText: 'Nama produk',
prefixIcon: Icon(Icons.label_outline)),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: TextField(
controller: price,
focusNode: focusNodes[2],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Harga Umum',
prefixText: 'Rp ',
),
),
),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: priceReseller,
focusNode: focusNodes[3],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Harga Reseller',
prefixText: 'Rp ',
),
),
),
],
),
const SizedBox(height: 10),
TextField(
controller: stock,
focusNode: focusNodes[4],
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Stok awal',
prefixIcon: Icon(Icons.numbers),
),
),
const SizedBox(height: 10),
TextField(
controller: hargaPokokCtrl,
focusNode: focusNodes[5],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Harga Pokok (modal)',
prefixText: 'Rp ',
helperText: 'Kosongkan jika tidak diketahui',
),
),
const SizedBox(height: 10),
TextField(
controller: minStockCtrl,
focusNode: focusNodes[6],
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Stok minimum (warning)',
prefixIcon: Icon(Icons.warning_outlined),
helperText: 'Default: 5',
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
final n = name.text.trim();
final bc = barcode.text.trim();
final pr = (RupiahInputFormatter.getRawValue(price.text) ?? 0)
.toDouble();
final prR =
(RupiahInputFormatter.getRawValue(priceReseller.text) ?? pr)
.toDouble();
final st = int.tryParse(stock.text) ?? 0;
final ms = int.tryParse(minStockCtrl.text) ?? 5;
final hp = (RupiahInputFormatter.getRawValue(hargaPokokCtrl.text) ?? 0)
.toDouble();
if (n.isEmpty || pr <= 0) return;
if (!await confirmResellerWarning(context, pr, prR)) return;
final err = await state.addProduct(n, bc, pr, prR, st, ms, hp);
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
if (err == null) {
name.clear();
barcode.clear();
price.clear();
priceReseller.clear();
stock.clear();
minStockCtrl.clear();
hargaPokokCtrl.clear();
messenger.showSnackBar(
const SnackBar(
content: Text('Produk berhasil ditambahkan'),
backgroundColor: kSuccess,
),
);
Navigator.pop(sheetContext);
} else {
messenger.showSnackBar(
SnackBar(
content: Text('Gagal menambah produk: '),
backgroundColor: kDanger,
),
);
}
},
icon: const Icon(Icons.add),
label: const Text('Tambah Produk'),
),
),
],
),
),
),
],
),
);
},
),
);
}
void _showEditDialog(BuildContext context, AppState state, Product product) {
final name = TextEditingController(text: product.name);
final barcode = TextEditingController(text: product.barcode);
final price = TextEditingController(
text: RupiahInputFormatter.formatNumber(product.price.toInt()));
final priceReseller = TextEditingController(
text: RupiahInputFormatter.formatNumber(product.priceReseller.toInt()));
final stock = TextEditingController(text: product.stock.toString());
final minStockCtrl = TextEditingController(text: product.minStock.toString());
final hargaPokokCtrl = TextEditingController(
text: product.hargaPokok > 0
? RupiahInputFormatter.formatNumber(product.hargaPokok.toInt())
: '');
final focusNodes = <FocusNode>[
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
FocusNode(),
];
bool _listenersAdded = false;
final _sheetController = DraggableScrollableController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
return DraggableScrollableSheet(
controller: _sheetController,
initialChildSize: 0.65,
minChildSize: 0.5,
maxChildSize: 0.92,
builder: (_, scrollController) {
if (!_listenersAdded) {
_listenersAdded = true;
for (final node in focusNodes) {
node.addListener(() {
if (node.hasFocus && context.mounted) {
_sheetController.animateTo(
0.92,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
Future.delayed(const Duration(milliseconds: 350), () {
if (node.context != null && node.context!.mounted) {
Scrollable.ensureVisible(
node.context!,
duration: const Duration(milliseconds: 250),
alignment: 0.0,
);
}
});
}
});
}
}
return Container(
decoration: const BoxDecoration(
color: kSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
border: Border(top: BorderSide(color: kBorder)),
),
child: Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12),
width: 40,
height: 4,
decoration: BoxDecoration(
color: kBorder,
borderRadius: BorderRadius.circular(4),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
const Icon(Icons.edit_outlined, color: kAccent),
const SizedBox(width: 8),
const Text('Edit Produk',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: kMuted),
onPressed: () => Navigator.pop(sheetContext),
tooltip: 'Tutup',
),
],
),
),
const Divider(height: 12),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: EdgeInsets.fromLTRB(16, 8, 16, 16 + bottomInset),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: barcode,
focusNode: focusNodes[0],
decoration: const InputDecoration(
labelText: 'Barcode / No. Produk',
prefixIcon: Icon(Icons.qr_code_2_outlined)),
),
const SizedBox(height: 10),
TextField(
controller: name,
focusNode: focusNodes[1],
decoration: const InputDecoration(labelText: 'Nama'),
),
const SizedBox(height: 10),
TextField(
controller: price,
focusNode: focusNodes[2],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(labelText: 'Harga Umum', prefixText: 'Rp '),
),
const SizedBox(height: 10),
TextField(
controller: priceReseller,
focusNode: focusNodes[3],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(labelText: 'Harga Reseller', prefixText: 'Rp '),
),
const SizedBox(height: 10),
TextField(
controller: hargaPokokCtrl,
focusNode: focusNodes[4],
keyboardType: TextInputType.number,
inputFormatters: [RupiahInputFormatter()],
decoration: const InputDecoration(
labelText: 'Harga Pokok (modal)',
prefixText: 'Rp ',
helperText: 'Kosongkan jika tidak diketahui',
),
),
const SizedBox(height: 10),
TextField(
controller: stock,
focusNode: focusNodes[5],
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Stok'),
),
const SizedBox(height: 10),
TextField(
controller: minStockCtrl,
focusNode: focusNodes[6],
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Stok minimum (warning)',
helperText: 'Default: 5',
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
final n = name.text.trim();
final bc = barcode.text.trim();
final pr = (RupiahInputFormatter.getRawValue(price.text) ?? 0)
.toDouble();
final prR =
(RupiahInputFormatter.getRawValue(priceReseller.text) ?? pr)
.toDouble();
final st = int.tryParse(stock.text) ?? 0;
final ms = int.tryParse(minStockCtrl.text) ?? 5;
final hp = (RupiahInputFormatter.getRawValue(hargaPokokCtrl.text) ?? 0)
.toDouble();
if (n.isEmpty || pr <= 0) return;
if (!await confirmResellerWarning(context, pr, prR)) return;
final err = await state.updateProduct(product.id, n, bc, pr, prR, st, ms, hp);
if (!context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
if (err == null) {
messenger.showSnackBar(
const SnackBar(
content: Text('Produk berhasil diperbarui'),
backgroundColor: kSuccess,
),
);
Navigator.pop(sheetContext);
} else {
messenger.showSnackBar(
SnackBar(
content: Text('Gagal memperbarui produk: '),
backgroundColor: kDanger,
),
);
}
},
icon: const Icon(Icons.save),
label: const Text('Simpan'),
),
),
],
),
),
),
],
),
);
},
);
},
);
}
class _ProductRow extends StatelessWidget {
final Product p;
final AppState state;
const _ProductRow({required this.p, required this.state});
@override
Widget build(BuildContext context) {
final out = p.stock <= 0;
final lowStock = p.stock > 0 && p.stock <= p.minStock;
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: kAccent.withValues(alpha: 0.15),
child: Text(
p.name.isNotEmpty ? p.name[0].toUpperCase() : '?',
style:
TextStyle(color: kAccent, fontWeight: FontWeight.w700),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(p.name,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600)),
if (p.barcode.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
p.barcode,
style: const TextStyle(
fontSize: 11, color: kMuted, fontFamily: 'monospace'),
),
],
const SizedBox(height: 4),
Wrap(
spacing: 10,
runSpacing: 6,
children: [
_Tag(formatRupiah(p.price), kSuccess),
if (p.priceReseller > 0)
_Tag('Rsl ${formatRupiah(p.priceReseller)}', kAccent),
if (p.hargaPokok > 0)
_Tag('Modal ${formatRupiah(p.hargaPokok)}', kAccent),
if (out)
_Tag('Stok habis', kDanger)
else if (lowStock)
_Tag('Stok rendah (${p.stock}/${p.minStock})', kAccent)
else
_Tag('Stok ${p.stock}', kMuted),
],
),
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_outlined, size: 20),
color: kAccent,
onPressed: () => _showEditDialog(context, state, p),
),
IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
color: kDanger,
onPressed: () async {
HapticFeedback.mediumImpact();
final messenger = ScaffoldMessenger.of(context);
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
icon: Icon(Icons.delete_outline, color: kDanger, size: 28),
title: const Text('Hapus Produk?'),
content: Text('Yakin ingin menghapus "${p.name}"?'),
actions: [
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx, false),
style: OutlinedButton.styleFrom(
foregroundColor: kMuted,
side: const BorderSide(color: kBorder),
),
child: const Text('Batal'),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: kDanger,
),
child: const Text('Hapus'),
),
),
],
),
);
if (confirm == true && context.mounted) {
final err = await state.deleteProduct(p.id);
if (err == null) {
messenger.showSnackBar(
const SnackBar(
content: Text('Produk berhasil dihapus'),
backgroundColor: kSuccess,
),
);
} else {
messenger.showSnackBar(
SnackBar(
content: Text('Gagal menghapus produk: $err'),
backgroundColor: kDanger,
),
);
}
}
},
),
],
),
],
),
),
);
}
}
class _Tag extends StatelessWidget {
final String label;
final Color color;
const _Tag(this.label, this.color);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(label,
style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: color)),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class ScanScreen extends StatefulWidget {
final String title;
const ScanScreen({
super.key,
required this.title,
});
@override
State<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
final MobileScannerController _controller = MobileScannerController();
bool _isScanned = false;
void _handleBarcode(BarcodeCapture capture) {
if (_isScanned) return;
final barcode = capture.barcodes.firstOrNull;
if (barcode != null && barcode.rawValue != null) {
final code = barcode.rawValue!.trim();
if (code.isNotEmpty) {
_isScanned = true;
_controller.stop();
Navigator.pop(context, code);
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
centerTitle: true,
),
body: MobileScanner(
controller: _controller,
onDetect: _handleBarcode,
),
);
}
}

View File

@@ -0,0 +1,167 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
class ExportService {
static final ExportService _instance = ExportService._internal();
factory ExportService() => _instance;
ExportService._internal();
Future<String?> exportToPdf(List<Map<String, dynamic>> transactions,
String storeName, DateTimeRange? dateRange) async {
try {
debugPrint('exportToPdf called with ${transactions.length} transactions');
final pdf = pw.Document();
pdf.addPage(
pw.Page(
build: (context) {
return pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(storeName, style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(height: 8),
if (dateRange != null)
pw.Text('${_formatDate(dateRange.start)} - ${_formatDate(dateRange.end)}',
style: pw.TextStyle(fontSize: 12)),
pw.SizedBox(height: 16),
pw.Table(
border: pw.TableBorder.all(),
children: [
pw.TableRow(
children: [
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Tanggal', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('ID', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Total', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Bayar', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
pw.Padding(padding: pw.EdgeInsets.all(8), child: pw.Text('Kembali', style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
],
),
for (final tx in transactions)
pw.TableRow(
children: [
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(_formatDate(tx['date']))),
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['id'])),
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['total'])),
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['paid'])),
pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(tx['change'])),
],
),
],
),
],
);
},
),
);
Directory? directory;
try {
directory = await getTemporaryDirectory();
debugPrint('PDF using temporary directory: ${directory.path}');
} catch (e) {
debugPrint('getTemporaryDirectory failed for PDF: $e');
return null;
}
final file = File('${directory.path}/laporan_transaksi_${DateTime.now().millisecondsSinceEpoch}.pdf');
try {
await file.writeAsBytes(await pdf.save());
debugPrint('PDF file written: ${file.path}, exists: ${await file.exists()}');
} catch (e) {
debugPrint('Failed to write PDF file: $e');
return null;
}
return file.path;
} catch (e) {
debugPrint('exportToPdf error: $e');
return null;
}
}
Future<String?> exportToCsv(List<Map<String, dynamic>> transactions) async {
try {
debugPrint('=== CSV Export Started ===');
debugPrint('Transactions count: ${transactions.length}');
if (transactions.isEmpty) {
debugPrint('No transactions to export');
return null;
}
final buffer = StringBuffer();
buffer.writeln('Tanggal,ID Transaksi,Total,Bayar,Kembali,Item,Diskon');
for (final tx in transactions) {
final date = _formatDate(tx['date']);
final id = tx['id'];
final total = tx['total'];
final paid = tx['paid'];
final change = tx['change'];
final items = tx['items'] is List ? (tx['items'] as List).length : (tx['items'] as int? ?? 0);
final discount = tx['discount'] ?? 0;
buffer.writeln('$date,$id,$total,$paid,$change,$items,$discount');
}
debugPrint('CSV buffer created, length: ${buffer.toString().length}');
String? filePath;
try {
final tempDir = await getTemporaryDirectory();
debugPrint('Temp dir: ${tempDir.path}');
final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = 'laporan_transaksi_$timestamp.csv';
filePath = '${tempDir.path}/$fileName';
final file = File(filePath);
await file.writeAsString(buffer.toString());
debugPrint('CSV file written: $filePath');
debugPrint('File exists: ${await file.exists()}');
debugPrint('File size: ${await file.length()}');
} catch (e) {
debugPrint('Error creating CSV file: $e');
return null;
}
debugPrint('=== CSV Export Completed Successfully ===');
return filePath;
} catch (e) {
debugPrint('=== CSV Export Failed ===');
debugPrint('Error: $e');
debugPrint('Stack trace: ${StackTrace.current}');
return null;
}
}
Future<void> shareFile(String filePath) async {
try {
debugPrint('Sharing file: $filePath');
final file = File(filePath);
final exists = await file.exists();
debugPrint('File exists: $exists, size: ${exists ? await file.length() : 0}');
if (!exists) {
debugPrint('File does not exist, cannot share');
return;
}
await Share.shareXFiles([XFile(filePath)], text: 'Laporan Transaksi');
debugPrint('Share completed');
} catch (e) {
debugPrint('shareFile error: $e');
rethrow;
}
}
String _formatDate(dynamic date) {
if (date is DateTime) {
return '${date.day}/${date.month}/${date.year}';
}
return date.toString();
}
}

View File

@@ -0,0 +1,156 @@
import 'dart:async';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class OfflineQueueService {
static final OfflineQueueService _instance = OfflineQueueService._internal();
factory OfflineQueueService() => _instance;
OfflineQueueService._internal();
final SupabaseClient _supabase = Supabase.instance.client;
final Connectivity _connectivity = Connectivity();
List<Map<String, dynamic>> _queue = [];
bool _isOnline = true;
bool _syncing = false;
static const Duration _timeout = Duration(seconds: 30);
Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async {
try {
return await future.timeout(
_timeout,
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
);
} on TimeoutException catch (e) {
print('OfflineQueue timeout: $e');
rethrow;
}
}
List<Map<String, dynamic>> get queue => List.unmodifiable(_queue);
bool get isOnline => _isOnline;
bool get hasPendingItems => _queue.isNotEmpty;
Future<void> init() async {
await _loadQueue();
_connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
await _checkConnection();
if (_isOnline) await _processQueue();
}
Future<void> _loadQueue() async {
final prefs = await SharedPreferences.getInstance();
final queueJson = prefs.getString('offline_queue');
if (queueJson != null) {
final List<dynamic> decoded = json.decode(queueJson);
_queue = decoded.cast<Map<String, dynamic>>();
}
}
Future<void> _saveQueue() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('offline_queue', json.encode(_queue));
}
Future<void> _checkConnection() async {
final result = await _connectivity.checkConnectivity();
_updateConnectionStatus(result);
}
void _updateConnectionStatus(List<ConnectivityResult> result) {
_isOnline = result.any((r) => r != ConnectivityResult.none);
if (_isOnline && _queue.isNotEmpty) {
_processQueue();
}
}
Future<void> addToQueue(Map<String, dynamic> transaction) async {
_queue.add(transaction);
await _saveQueue();
if (_isOnline) {
await _processQueue();
}
}
Future<void> _processQueue() async {
if (_syncing || !_isOnline || _queue.isEmpty) return;
_syncing = true;
while (_queue.isNotEmpty && _isOnline) {
final item = _queue.first;
try {
await _processTransaction(item);
_queue.removeAt(0);
await _saveQueue();
} catch (e) {
_syncing = false;
return;
}
}
_syncing = false;
}
Future<void> _processTransaction(Map<String, dynamic> transaction) async {
final sale = transaction['sale'] as Map<String, dynamic>;
final items = (transaction['items'] as List?) ?? [];
final saleId = sale['id'] as String;
final existing = await _withTimeout(
_supabase
.from('sales')
.select('id, total_cost')
.eq('id', saleId)
.maybeSingle(),
'Check existing sale',
);
if (existing == null) {
await _withTimeout(
_supabase.from('sales').insert(sale),
'Insert queued sale',
);
} else {
final existingTotalCost = (existing['total_cost'] as num?)?.toDouble() ?? 0;
if (existingTotalCost <= 0 && sale['total_cost'] != null) {
await _withTimeout(
_supabase
.from('sales')
.update({'total_cost': sale['total_cost']})
.eq('id', saleId),
'Update sale total_cost',
);
}
}
for (final it in items) {
final productId = it['product_id'] as String;
final qty = (it['qty'] as num).toInt();
final product = await _withTimeout(
_supabase
.from('products')
.select()
.eq('id', productId)
.maybeSingle(),
'Load product for stock update',
);
if (product != null) {
final currentStock = (product['stock'] as num).toInt();
final newStock = (currentStock - qty).clamp(0, 1 << 62);
await _withTimeout(
_supabase
.from('products')
.update({'stock': newStock})
.eq('id', productId),
'Update product stock',
);
}
}
}
Future<void> clearQueue() async {
_queue.clear();
await _saveQueue();
}
}

View File

@@ -0,0 +1,223 @@
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/sale.dart';
import '../utils/format.dart';
class PrinterService {
static const String _printerKey = 'selected_printer_mac';
String? _connectedMac;
bool _isConnected = false;
String? get connectedMac => _connectedMac;
bool get isConnected => _isConnected;
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
_connectedMac = prefs.getString(_printerKey);
if (_connectedMac != null) {
await connect(_connectedMac!);
}
}
Future<bool> isBluetoothEnabled() async {
return PrintBluetoothThermal.bluetoothEnabled;
}
Future<List<BluetoothInfo>> getPairedDevices() async {
final list = await PrintBluetoothThermal.pairedBluetooths;
return list;
}
Future<bool> connect(String mac) async {
try {
final connected = await PrintBluetoothThermal.connect(macPrinterAddress: mac);
_isConnected = connected;
if (connected) {
_connectedMac = mac;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_printerKey, mac);
}
return connected;
} catch (_) {
_isConnected = false;
return false;
}
}
Future<void> disconnect() async {
try {
await PrintBluetoothThermal.disconnect;
} catch (_) {}
_isConnected = false;
}
Future<void> forgetPrinter() async {
_connectedMac = null;
_isConnected = false;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_printerKey);
}
Future<List<int>> _buildReceiptBytes(Sale sale, String storeName, {String? kasirName, String? whatsapp, String? notes}) async {
final profile = await CapabilityProfile.load();
final generator = Generator(PaperSize.mm58, profile);
final bytes = <int>[];
bytes.addAll(generator.text(
storeName,
styles: const PosStyles(
align: PosAlign.center,
bold: true,
height: PosTextSize.size2,
),
));
bytes.addAll(generator.text(
formatTanggal(sale.date),
styles: const PosStyles(align: PosAlign.center),
));
bytes.addAll(generator.text(
formatWaktu(sale.date),
styles: const PosStyles(align: PosAlign.center),
));
bytes.addAll(generator.text(
'#${sale.id.toUpperCase()}',
styles: const PosStyles(align: PosAlign.center),
));
bytes.addAll(generator.hr());
for (final it in sale.items) {
bytes.addAll(generator.text('${it.name} x${it.qty}',
styles: const PosStyles(bold: true)));
bytes.addAll(generator.row([
PosColumn(
text: formatRupiah(it.price),
width: 6,
),
PosColumn(
text: formatRupiah(it.subtotal),
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
}
bytes.addAll(generator.hr());
if (sale.discount > 0) {
bytes.addAll(generator.row([
PosColumn(text: 'Diskon', width: 6),
PosColumn(
text: '-${formatRupiah(sale.discount)}',
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
}
bytes.addAll(generator.row([
PosColumn(
text: 'TOTAL',
width: 6,
styles: const PosStyles(bold: true),
),
PosColumn(
text: formatRupiah(sale.total),
width: 6,
styles: const PosStyles(bold: true, align: PosAlign.right),
),
]));
final methodLabel = sale.paymentMethod == 'transfer'
? 'Bank'
: sale.paymentMethod == 'qris'
? 'QRIS'
: sale.paymentMethod == 'split'
? 'Split Payment'
: 'Tunai';
bytes.addAll(generator.row([
PosColumn(text: 'Metode', width: 6),
PosColumn(
text: methodLabel,
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
if (sale.paymentMethod == 'tunai') {
bytes.addAll(generator.row([
PosColumn(text: 'Bayar', width: 6),
PosColumn(
text: formatRupiah(sale.paid),
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
bytes.addAll(generator.row([
PosColumn(text: 'Kembali', width: 6),
PosColumn(
text: formatRupiah(sale.change),
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
}
if (sale.splitPayment != null && sale.splitPayment!.isNotEmpty) {
for (final entry in sale.splitPayment!.entries) {
final label = entry.key == 'tunai'
? 'Tunai'
: entry.key == 'transfer'
? 'Transfer'
: 'QRIS';
bytes.addAll(generator.row([
PosColumn(text: 'Split $label', width: 6),
PosColumn(
text: formatRupiah(entry.value),
width: 6,
styles: const PosStyles(align: PosAlign.right),
),
]));
}
}
bytes.addAll(generator.hr());
if (kasirName != null && kasirName.isNotEmpty) {
bytes.addAll(generator.text('Kasir: $kasirName',
styles: const PosStyles(align: PosAlign.center)));
}
if (whatsapp != null && whatsapp.isNotEmpty) {
bytes.addAll(generator.text('WA: $whatsapp',
styles: const PosStyles(align: PosAlign.center)));
}
if (notes != null && notes.trim().isNotEmpty) {
final noteLines = notes.trim().split('\n');
for (final line in noteLines) {
final text = line.trim();
if (text.isEmpty) continue;
bytes.addAll(generator.text(text,
styles: const PosStyles(align: PosAlign.center)));
}
}
bytes.addAll(generator.hr());
bytes.addAll(generator.text(
'Terima kasih!',
styles: const PosStyles(align: PosAlign.center),
));
bytes.addAll(generator.feed(2));
bytes.addAll(generator.cut());
return bytes;
}
Future<String?> printSale(Sale sale, String storeName, {String? kasirName, String? whatsapp, String? notes}) async {
if (!_isConnected || _connectedMac == null) {
return 'Printer belum terhubung';
}
try {
final bytes = await _buildReceiptBytes(sale, storeName, kasirName: kasirName, whatsapp: whatsapp, notes: notes);
final result = await PrintBluetoothThermal.writeBytes(bytes);
if (result) return null;
return 'Gagal mengirim ke printer';
} catch (e) {
return e.toString();
}
}
}
final printerService = PrinterService();

760
lib/state/app_state.dart Normal file
View File

@@ -0,0 +1,760 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/product.dart';
import '../models/sale.dart';
import '../models/user_profile.dart';
import '../services/offline_queue_service.dart';
import '../utils/format.dart';
import '../config.dart';
class CartLine {
final Product product;
int qty;
bool isReseller;
CartLine({
required this.product,
this.qty = 1,
this.isReseller = false,
});
double get price => isReseller ? product.priceReseller : product.price;
}
class AppState extends ChangeNotifier {
final SupabaseClient _supabase = Supabase.instance.client;
// Client dengan service_role key untuk operasi admin (buat/edit/hapus akun kasir).
// PERINGATAN: key ini bypass RLS — hanya untuk app POS di device terpercaya.
final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey);
static const Duration _timeout = Duration(seconds: 30);
Future<T> _withTimeout<T>(Future<T> future, [String? operation]) async {
try {
return await future.timeout(
_timeout,
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
);
} on TimeoutException catch (e) {
debugPrint('Supabase timeout: $e');
rethrow;
}
}
List<Product> products = [];
List<Sale> sales = [];
List<CartLine> cart = [];
List<Map<String, dynamic>> kasirAccounts = [];
bool loading = true;
String? error;
static const int _pageSize = 30;
int _visibleProductCount = _pageSize;
int _visibleSalesCount = _pageSize;
List<Product> get displayedProducts =>
products.take(_visibleProductCount).toList();
List<Sale> get displayedSales =>
sales.take(_visibleSalesCount).toList();
int get visibleProductCount => _visibleProductCount;
int get visibleSalesCount => _visibleSalesCount;
int get pageSize => _pageSize;
bool get hasMoreProducts => _visibleProductCount < products.length;
bool get hasMoreSales => _visibleSalesCount < sales.length;
void resetPagination() {
_visibleProductCount = _pageSize;
_visibleSalesCount = _pageSize;
notifyListeners();
}
void loadMoreProducts() {
if (!hasMoreProducts) return;
_visibleProductCount = (_visibleProductCount + _pageSize).clamp(0, products.length);
notifyListeners();
}
void loadMoreSales() {
if (!hasMoreSales) return;
_visibleSalesCount = (_visibleSalesCount + _pageSize).clamp(0, sales.length);
notifyListeners();
}
String customerType = 'umum';
bool resellerEnabled = true;
String? userId;
String? userRole;
UserProfile? _profile;
final Map<String, String> _profileNames = {};
double _discount = 0;
bool get isAdmin => userRole == 'admin';
bool get isKasir => userRole == 'kasir';
bool get isLoggedIn => userId != null;
String getProfileName(String? uid) {
if (uid == null) return 'Tidak diketahui';
if (uid == userId) return _profile?.fullName ?? 'Admin';
return _profileNames[uid] ?? 'Kasir';
}
AppState() {
_init();
}
Timer? _pollTimer;
static const Duration _pollInterval = Duration(minutes: 1);
DateTime? _lastManualRefresh;
void recordManualRefresh() {
_lastManualRefresh = DateTime.now();
}
void startPolling() {
stopPolling();
_pollTimer = Timer.periodic(_pollInterval, (_) => _poll());
}
void stopPolling() {
_pollTimer?.cancel();
_pollTimer = null;
}
Future<void> _poll() async {
if (_lastManualRefresh != null) {
final elapsed = DateTime.now().difference(_lastManualRefresh!);
if (elapsed < const Duration(seconds: 15)) return;
}
await refresh();
}
Future<void> refresh() async {
await _loadProducts();
await _loadSales();
notifyListeners();
}
final OfflineQueueService _offlineQueue = OfflineQueueService();
double get subtotal =>
cart.fold(0, (sum, line) => sum + line.price * line.qty);
double get discount => _discount;
double get total => (subtotal - _discount).clamp(0, double.infinity);
bool get canCheckout => cart.isNotEmpty && total > 0;
/* ---------- Init & Realtime ---------- */
Future<void> _init() async {
try {
await _initAuth();
await _loadProducts();
await _loadSales();
await loadKasirAccounts();
await _loadProfileNames();
await _loadSettings();
_subscribe();
await _seedAdminIfEmpty();
await _offlineQueue.init();
startPolling();
} catch (e) {
error = e.toString();
}
loading = false;
notifyListeners();
}
Future<void> _loadSettings() async {
final prefs = await SharedPreferences.getInstance();
resellerEnabled = prefs.getBool('reseller_enabled') ?? true;
notifyListeners();
}
Future<void> setResellerEnabled(bool value) async {
resellerEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('reseller_enabled', value);
if (!value) {
setCustomerType('umum');
for (final line in cart) {
if (line.isReseller) toggleLineMode(line.product.id);
}
}
notifyListeners();
}
Future<void> _initAuth() async {
final session = _supabase.auth.currentSession;
if (session != null) {
userId = session.user.id;
await _loadProfile();
}
}
Future<void> _loadProfile() async {
if (userId == null) return;
try {
final data = await _supabase
.from('profiles')
.select()
.eq('id', userId!)
.maybeSingle();
if (data != null) {
_profile = UserProfile.fromJson(data);
userRole = _profile!.role;
}
} catch (_) {}
}
Future<void> _seedAdminIfEmpty() async {
try {
final count = await _withTimeout(
_admin.from('profiles').select().count(),
'Check admin profiles',
);
if (count.count == 0) {
await _withTimeout(
_admin.auth.admin.createUser(
AdminUserAttributes(email: 'admin@posgrosir.local', password: 'admin123'),
),
'Create default admin',
);
}
} catch (_) {}
}
Future<String?> login(String email, String password) async {
try {
final res = await _withTimeout(
_supabase.auth.signInWithPassword(
email: email,
password: password,
),
'Login',
);
userId = res.user?.id;
await _loadProfile();
notifyListeners();
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
// Reserved for future self-service signup flow.
// Currently unused per PRD §5.3: all devices must be pre-provisioned by admin.
Future<String?> signup(String email, String password) async {
try {
final res = await _withTimeout(
_supabase.auth.signUp(
email: email,
password: password,
),
'Signup',
);
userId = res.user?.id;
await _loadProfile();
notifyListeners();
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<void> logout() async {
await _withTimeout(_supabase.auth.signOut(), 'Logout');
userId = null;
userRole = null;
_profile = null;
cart.clear();
_discount = 0;
notifyListeners();
}
Future<String?> createKasir(String email, String password, String? name) async {
try {
final res = await _withTimeout(
_admin.auth.admin.createUser(
AdminUserAttributes(email: email, password: password, emailConfirm: true),
),
'Create kasir user',
);
final newUserId = res.user?.id;
if (newUserId != null) {
await _withTimeout(
_admin.from('profiles').upsert({
'id': newUserId,
'role': 'kasir',
'full_name': name,
}),
'Create kasir profile',
);
await loadKasirAccounts();
}
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<void> deleteKasir(String id) async {
try {
await _withTimeout(
_admin.from('profiles').delete().eq('id', id),
'Delete kasir profile',
);
await _withTimeout(
_admin.auth.admin.deleteUser(id),
'Delete kasir user',
);
await loadKasirAccounts();
} catch (_) {}
}
Future<String?> updateKasir(String id, String email, String? name, String? password) async {
try {
final updateData = <String, dynamic>{
'full_name': name,
};
if (password != null && password.isNotEmpty) {
await _withTimeout(
_admin.auth.admin.updateUserById(
id,
attributes: AdminUserAttributes(password: password),
),
'Update kasir password',
);
}
await _withTimeout(
_admin.from('profiles').update(updateData).eq('id', id),
'Update kasir profile',
);
await loadKasirAccounts();
return null;
} on AuthException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<void> loadKasirAccounts() async {
try {
final rows = await _withTimeout(
_admin
.from('profiles')
.select()
.eq('role', 'kasir'),
'Load kasir accounts',
);
Map<String, String> emails = {};
try {
final usersRes = await _withTimeout(
_admin.auth.admin.listUsers(),
'List auth users',
);
for (final u in usersRes) {
if (u.email != null && u.email!.isNotEmpty) {
emails[u.id] = u.email!;
}
}
} catch (_) {}
kasirAccounts = rows.map((r) {
final map = Map<String, dynamic>.from(r);
if ((map['email'] == null || map['email'].toString().isEmpty) &&
emails.containsKey(map['id'])) {
map['email'] = emails[map['id']];
}
if ((map['full_name'] == null || map['full_name'].toString().isEmpty) &&
map['email'] != null &&
map['email'].toString().isNotEmpty) {
map['full_name'] = map['email'].toString().split('@').first;
}
return map;
}).toList();
notifyListeners();
} catch (_) {}
}
Future<void> _loadProfileNames() async {
try {
final rows = await _withTimeout(
_admin.from('profiles').select('id,full_name,email'),
'Load profile names',
);
_profileNames.clear();
for (final r in rows) {
final map = Map<String, dynamic>.from(r);
final name = (map['full_name']?.toString().trim().isEmpty == true
? map['email']?.toString().split('@').first
: map['full_name']?.toString().trim()) ??
'';
if (name.isNotEmpty) {
_profileNames[map['id'] as String] = name;
}
}
} catch (_) {}
}
void _subscribe() {
_supabase.auth.onAuthStateChange.listen((event) {
if (event.session != null) {
userId = event.session!.user.id;
_loadProfile();
} else {
userId = null;
userRole = null;
_profile = null;
}
notifyListeners();
});
_supabase.from('products').stream(primaryKey: ['id']).listen(
(rows) {
products = rows.map((e) => Product.fromJson(e)).toList();
notifyListeners();
},
onError: (e) {
debugPrint('REALTIME products stream error: $e');
},
);
_supabase.from('sales').stream(primaryKey: ['id']).listen(
(rows) {
sales = rows.map((e) => Sale.fromJson(e)).toList();
sales.sort((a, b) => b.date.compareTo(a.date));
notifyListeners();
},
onError: (e) {
debugPrint('REALTIME sales stream error: $e');
},
);
}
Future<void> _loadProducts() async {
final rows = await _withTimeout(
_supabase.from('products').select().order('name'),
'Load products',
);
products = rows.map((e) => Product.fromJson(e)).toList();
if (products.isEmpty) _seed();
}
Future<void> _loadSales() async {
final rows = await _withTimeout(
_supabase
.from('sales')
.select()
.order('created_at', ascending: false),
'Load sales',
);
sales = rows.map((e) => Sale.fromJson(e)).toList();
sales.sort((a, b) => b.date.compareTo(a.date));
}
Future<void> _seed() async {
final seeds = [
Product(id: uid(), name: 'Beras 5kg', price: 58000, priceReseller: 55000, stock: 50),
Product(id: uid(), name: 'Minyak 2L', price: 32000, priceReseller: 30000, stock: 40),
Product(id: uid(), name: 'Gula 1kg', price: 14000, priceReseller: 13000, stock: 60),
Product(id: uid(), name: 'Teh Botol', price: 3600, priceReseller: 3200, stock: 120),
Product(id: uid(), name: 'Sabun Cuci', price: 8500, priceReseller: 7800, stock: 35),
Product(id: uid(), name: 'Indomie Dus', price: 95000, priceReseller: 90000, stock: 25),
];
await _withTimeout(
_supabase.from('products').insert(seeds.map((e) => e.toJson()).toList()),
'Seed products',
);
products = seeds;
notifyListeners();
}
/* ---------- Tipe Pembeli ---------- */
void setCustomerType(String type) {
customerType = type;
final reseller = type == 'reseller';
for (final c in cart) {
c.isReseller = reseller;
}
notifyListeners();
}
/* ---------- Produk ---------- */
Future<String?> addProduct(String name, String barcode, double price,
double priceReseller, int stock, int minStock, double hargaPokok) async {
try {
final trimmed = barcode.trim();
if (trimmed.isNotEmpty &&
products.any((p) => p.barcode.trim().toLowerCase() == trimmed.toLowerCase())) {
return 'Barcode sudah digunakan oleh produk lain';
}
final product = Product(
id: uid(),
name: name,
barcode: trimmed,
price: price,
priceReseller: priceReseller,
stock: stock,
minStock: minStock,
hargaPokok: hargaPokok,
);
await _withTimeout(
_supabase.from('products').insert(product.toJson()),
'Add product',
);
} catch (e) {
return e.toString();
}
try {
await _loadProducts();
} catch (_) {}
notifyListeners();
return null;
}
Future<String?> updateProduct(String id, String name, String barcode,
double price, double priceReseller, int stock, int minStock, double hargaPokok) async {
try {
final trimmed = barcode.trim();
if (trimmed.isNotEmpty &&
products.any((p) => p.id != id && p.barcode.trim().toLowerCase() == trimmed.toLowerCase())) {
return 'Barcode sudah digunakan oleh produk lain';
}
await _withTimeout(
_supabase.from('products').update({
'name': name,
'barcode': trimmed,
'price': price,
'price_reseller': priceReseller,
'stock': stock,
'min_stock': minStock,
'harga_pokok': hargaPokok,
}).eq('id', id),
'Update product',
);
} catch (e) {
return e.toString();
}
try {
await _loadProducts();
} catch (_) {}
notifyListeners();
return null;
}
Future<String?> deleteProduct(String id) async {
try {
cart.removeWhere((c) => c.product.id == id);
await _withTimeout(
_supabase.from('products').delete().eq('id', id),
'Delete product',
);
} catch (e) {
return e.toString();
}
try {
await _loadProducts();
} catch (_) {}
notifyListeners();
return null;
}
/* ---------- Cart ---------- */
void addToCart(Product product) {
if (product.stock <= 0) return;
final line = cart.firstWhere(
(c) => c.product.id == product.id,
orElse: () => CartLine(product: product),
);
if (cart.contains(line)) {
if (line.qty < product.stock) line.qty++;
} else {
line.isReseller = customerType == 'reseller';
cart.add(line);
}
notifyListeners();
}
void changeQty(String productId, int delta) {
final line = cart.firstWhere((c) => c.product.id == productId);
line.qty += delta;
if (line.qty <= 0) {
cart.remove(line);
} else if (line.qty > line.product.stock) {
line.qty = line.product.stock;
}
notifyListeners();
}
void toggleLineMode(String productId) {
final line = cart.firstWhere((c) => c.product.id == productId);
line.isReseller = !line.isReseller;
notifyListeners();
}
void setDiscount(double value) {
_discount = value < 0 ? 0 : value.clamp(0, subtotal);
notifyListeners();
}
void clearCart() {
cart.clear();
_discount = 0;
notifyListeners();
}
/* ---------- Checkout ---------- */
Future<Sale> checkout(double paid, {String paymentMethod = 'tunai', Map<String, double>? splitPayment, String paymentStatus = 'paid', double dueAmount = 0.0, String? customerPhone}) async {
final totalCost = cart.fold(0.0, (sum, c) => sum + (c.product.hargaPokok * c.qty));
final sale = Sale(
id: uid(),
date: DateTime.now(),
userId: userId,
items: cart
.map((c) => SaleItem(
productId: c.product.id,
name: c.product.name,
price: c.price,
qty: c.qty,
isReseller: c.isReseller,
))
.toList(),
subtotal: subtotal,
discount: _discount,
total: total,
paid: paid,
change: paid - total,
totalCost: totalCost,
paymentMethod: paymentMethod,
paymentStatus: paymentStatus,
dueAmount: dueAmount,
customerPhone: customerPhone,
splitPayment: splitPayment,
);
try {
await _withTimeout(
_supabase.from('sales').insert(sale.toJson()),
'Create sale',
);
for (final line in cart) {
try {
final current =
products.firstWhere((p) => p.id == line.product.id);
await _withTimeout(
_supabase
.from('products')
.update({'stock': current.stock - line.qty})
.eq('id', line.product.id),
'Update stock',
);
} catch (_) {}
}
await _loadProducts();
await _loadSales();
cart.clear();
_discount = 0;
notifyListeners();
return sale;
} catch (e) {
await _offlineQueue.addToQueue({
'sale': sale.toJson(),
'items': cart.map((c) {
final lineTotalCost = c.product.hargaPokok * c.qty;
return {
'product_id': c.product.id,
'qty': c.qty,
'price': c.price,
'total_cost': lineTotalCost,
};
}).toList(),
});
cart.clear();
_discount = 0;
notifyListeners();
rethrow;
}
}
/* ---------- Retur / Pembatalan ---------- */
Future<String?> cancelSale(String saleId) async {
try {
final sale = sales.firstWhere((s) => s.id == saleId);
await _withTimeout(
_supabase.from('sales').update({'is_cancelled': true}).eq('id', saleId),
'Cancel sale',
);
for (final item in sale.items) {
final product = products.firstWhere(
(p) => p.id == item.productId,
orElse: () => Product(id: '', name: '', price: 0, stock: 0, hargaPokok: 0),
);
if (product.id.isNotEmpty) {
await _withTimeout(
_supabase
.from('products')
.update({'stock': product.stock + item.qty})
.eq('id', product.id),
'Restore stock',
);
}
}
await _loadProducts();
await _loadSales();
notifyListeners();
return null;
} on PostgrestException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
Future<String?> recordPayment(String saleId, double amount) async {
try {
final sale = sales.firstWhere((s) => s.id == saleId);
final newPaid = sale.paid + amount;
final newDue = (sale.dueAmount - amount).clamp(0, double.infinity);
final newStatus = newDue <= 0 ? 'paid' : 'partial';
await _withTimeout(
_supabase.from('sales').update({
'paid': newPaid,
'due_amount': newDue,
'payment_status': newStatus,
}).eq('id', saleId),
'Record payment',
);
await _loadSales();
notifyListeners();
return null;
} on PostgrestException catch (e) {
return e.message;
} catch (e) {
return e.toString();
}
}
}

26
lib/utils/colors.dart Normal file
View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
const kBackground = Color(0xFF0B1220);
const kSurface = Color(0xFF141C2E);
const kSurface2 = Color(0xFF1C2638);
const kBorder = Color(0xFF2A3650);
const kText = Color(0xFFE8EEF7);
const kMuted = Color(0xFF93A1B8);
const kAccent = Color(0xFFF59E0B);
const kSuccess = Color(0xFF22C55E);
const kDanger = Color(0xFFEF4444);
const kRadius = 18.0;
const List<Color> productPalette = [
Color(0xFFF59E0B),
Color(0xFF22C55E),
Color(0xFF3B82F6),
Color(0xFFEC4899),
];
Color productAccent(String name) {
if (name.isEmpty) return productPalette.first;
final index = name.hashCode.abs() % productPalette.length;
return productPalette[index];
}

26
lib/utils/format.dart Normal file
View File

@@ -0,0 +1,26 @@
String formatRupiah(double number) {
final rounded = number.round();
final formatted = rounded
.toString()
.replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (m) => '.');
return 'Rp $formatted';
}
String formatTanggal(DateTime date) {
const bulan = [
'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'
];
return '${date.day} ${bulan[date.month - 1]} ${date.year}';
}
String formatWaktu(DateTime date) {
final j = date.hour.toString().padLeft(2, '0');
final m = date.minute.toString().padLeft(2, '0');
return '$j:$m';
}
String uid() {
return DateTime.now().microsecondsSinceEpoch.toRadixString(36) +
(DateTime.now().millisecond).toRadixString(36);
}

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