chore: initial commit Dutra Store POS app
49
.gitignore
vendored
Normal 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
|
||||
195
.kilo/agent/uiux-pos-grosir.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
44
android/app/build.gradle.kts
Normal 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 = "../.."
|
||||
}
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
64
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.grosir.app_grosir
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal 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>
|
||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||
18
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
7
android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal 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
@@ -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)
|
||||
}
|
||||
663
android/build/reports/problems/problems-report.html
Normal file
3
android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
26
android/settings.gradle.kts
Normal 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
|
After Width: | Height: | Size: 1.1 MiB |
38
check_addform.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()}')
|
||||
29
check_parens_no_strings.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
34
ios/.gitignore
vendored
Normal 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
|
||||
26
ios/Flutter/AppFrameworkInfo.plist
Normal 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>
|
||||
1
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
1
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1 @@
|
||||
#include "Generated.xcconfig"
|
||||
616
ios/Runner.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
101
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal 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>
|
||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
13
ios/Runner/AppDelegate.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal 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.
|
||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal 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
@@ -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>
|
||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
12
ios/RunnerTests/RunnerTests.swift
Normal 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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
25
lib/models/user_profile.dart
Normal 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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
1450
lib/screens/kasir_screen.dart
Normal file
372
lib/screens/kelola_kasir_screen.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
135
lib/screens/login_screen.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
262
lib/screens/pengaturan_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
279
lib/screens/printer_screen.dart
Normal 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'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
802
lib/screens/produk_screen.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
1026
lib/screens/riwayat_screen.dart
Normal file
54
lib/screens/scan_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
167
lib/services/export_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
156
lib/services/offline_queue_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
223
lib/services/printer_service.dart
Normal 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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||