Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91aa662fcb | ||
|
|
22b4cecad8 | ||
|
|
903eb9fc42 | ||
|
|
e557a82faa | ||
|
|
67b4d934a4 | ||
|
|
8a6541e55f | ||
|
|
0174ca4f64 | ||
|
|
aba36f5c60 | ||
|
|
bcea28fcc4 | ||
|
|
9e6b610549 | ||
|
|
a52e0ea9b1 | ||
|
|
a3629a7d82 | ||
|
|
06b640dda5 | ||
|
|
da58b3aa56 | ||
|
|
b968e84152 | ||
|
|
2b7872ddbb | ||
|
|
bbdb7b7b20 | ||
|
|
86be211beb | ||
|
|
f99324b851 | ||
|
|
812fb13eca | ||
|
|
808992f9bd | ||
|
|
ab26487882 |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -37,6 +37,17 @@ migrate_working_dir/
|
||||
lib/config.dart
|
||||
supabse.txt
|
||||
|
||||
# File bantu AI (script cek/debug sekali pakai) — JANGAN commit
|
||||
# Taruh di D:\HomeLabs\tmp\ (tidak masuk repo manapun).
|
||||
# Dilarang menaruh *.py / check_* / fix_* / test_syntax* / *.bak / *.tmp
|
||||
# di root project atau lib/.
|
||||
*_ai_helper.py
|
||||
check_*.py
|
||||
fix_*.py
|
||||
test_syntax*.py
|
||||
*.bak
|
||||
*.orig
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
|
||||
51
PRD.md
51
PRD.md
@@ -2,6 +2,31 @@
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.3.0+7 — 2026-10-18
|
||||
- **Offline sync indikator status:** badge `Menunggu sinkron` di daftar Riwayat + baris `Sinkronisasi: Belum terkirim ke server` di detail transaksi. `AppState.pendingSaleIds`, `isSalePending()`, `pendingOfflineCount`.
|
||||
- **Retry antrean otomatis:** `retryNow()` dipanggil dari `init()`, event connectivity, dan `addToQueue()` setelah proses antrean; verifikasi server reachable (ping 5 detik) sebelum sinkron.
|
||||
- **Release APK:** `app-release.apk` (68.1MB), tag `v1.3.0+7`, di-upload ke Gitea Release.
|
||||
|
||||
### v1.3.0+6 — 2026-10-18
|
||||
- **Kasir list + toggle grid/list:** default list (foto 56px), toggle ke grid. Tap = tambah qty berkali-kali. Highlight hijau sesaat (0.9s). Tombol keranjang + badge jumlah item.
|
||||
- **Notifikasi in-app:** banner SnackBar + getar — hanya untuk admin, hanya untuk transaksi dari orang lain (bukan transaksi admin sendiri). Deteksi via polling (interval 1 menit) + stream `newSaleNotifications`. Load pertama dilewati agar tidak spam.
|
||||
- **Badge Riwayat:** `unseenSalesCount` di tab Riwayat (admin only), reset saat tab dibuka (`markSalesSeen()`).
|
||||
- **Fix tombol "Muat lebih banyak" Riwayat:** cek `filteredSales.length` bukan `state.sales` (sebelumnya negatif -17 saat filter aktif).
|
||||
- **Produk hilang / loading terus di layar Kasir (fix 3 lapis):**
|
||||
1. `_loadProducts()` now non-crashing saat Supabase return 401; empty server result does **not** overwrite in-memory `products` (previously `products = []` wiped the on-screen list).
|
||||
2. `_init()` no longer waits `& _initAuth()` first. Auth check now via `_ensureSessionAndAuth()`: if refresh token expired, immediately set `_sessionExpired = true`, keep `startPolling()` alive, and show a "login ulang" message. This avoids `_loadProducts()` being blocked by a 30s timeout + 401.
|
||||
3. `_refresh()` and realtime `.stream()` products stay alive via 1-minute polling (30 default products, "Muat lebih banyak" to extend).
|
||||
- **Auth flow lengkap (2026-10-18):** `login_screen.dart` + `AuthGate` (main.dart) sudah ada & ter-wiring. Dilengkapi: `AuthGate` sekarang route ke `LoginScreen` saat `state.sessionExpired` true (bukan cuma `!isLoggedIn`) — saat session mati user dipaksa login ulang, bukan nyangkut di HomePage dengan sesi mati. `_ensureSessionAndAuth()` membedakan auth benar-benar mati (`AuthException`/refresh balikin session tak valid → `sessionExpired=true`) vs network blip (error jaringan → TIDAK tandai sessionExpired, jadi tak salah-logout saat internet putus sesaat). `refresh()` stop query saat auth mati (hindari 401 sia-sia + cukup notify agar redirect). `login()` reset `sessionExpired` + langsung muat produk/riwayat (tak tunggu poll 1 menit).
|
||||
- **Kasir: indikator loading produk + empty/error state (2026-10-18):** setelah login user sempat melihat daftar kosong lalu produk muncul beberapa saat kemudian. Sebab otentik: `AuthGate → HomePage` render duluan, `refresh() → _loadProducts()` jalan belakangan tanpa indikator. Perbaikan kini: `AppState.productsLoading/dataReady/productsError` + `retryProducts()`; Kasir menampilkan skeleton daftar `_ProductsLoading` khusus saat muat awal, banner `Gagal memuat produk` + tombol `Coba lagi` saat error, dan empty-state `Belum ada produk`/`Tidak ditemukan` sesuai kasus. `refresh()` diberi retry 1x + try-catch internal sehingga `_loadSales()` tidak men-stop loop; LoginScreen tetap dipakai (tanpa loading global) agar pesan error/login tidak hilang.
|
||||
- **Offline queue: transaksi offline kini disinkron ulang otomatis (2026-10-18):** laporan user — transaksi saat data mati tidak masuk walau data sudah nyala. Sebab: sync antrean hanya dipicu event `connectivity_plus` (radio nyala ≠ server reachable) dan polling tidak menyentuh antrean. Perbaikan: `OfflineQueueService.retryNow()` (re-check koneksi + ping ringan Supabase 5 dtk sebelum proses antrean) dipanggil setiap `refresh()` sukses; log `OfflineQueue gagal sinkron item <id>: <error>`; tambah izin Android `INTERNET` + `ACCESS_NETWORK_STATE` (sebelumnya tidak ada di manifest, deteksi konektivitas tidak andal).
|
||||
- **Indikator status sinkron di Riwayat (2026-10-18):** transaksi yang masih dalam antrean offline ditandai badge `Menunggu sinkron` (ikon jam pasir) di daftar Riwayat + baris `Sinkronisasi: Belum terkirim ke server` di detail transaksi. `AppState.pendingSaleIds`, `isSalePending()`, `pendingOfflineCount` dipakai UI untuk menandai status.
|
||||
- Fix Gitea ROOT_URL (infra): link download APK sebelumnya mengarah ke IP Tailscale lama → fixed ke `https://git.gosambi.uk/`.
|
||||
|
||||
### v1.2.0+5 — 2026-10-17
|
||||
- **Foto produk:** bucket Supabase Storage `product-images` (publik, limit 5MB, jpg/png/webp) + kolom `products.image_path`. `image_picker` ^1.2.0 (kompres 800px/quality80). Tombol Kamera/Galeri/Hapus di form Tambah+Edit (`produk_screen.dart`). Thumbnail di daftar produk (`_ProductRow`) + keranjang kasir (`_KasirAvatar`). AndroidManifest +queries intent kamera/galeri.
|
||||
- **Fix MIME:** `image/jpg` → `image/jpeg` (server 415).
|
||||
- **Fix storage policy:** bucket `storage.objects` tanpa policy → upload 403. Fix: 3 policy SQL (INSERT auth, SELECT public, DELETE auth) — fix di server, APK tidak perlu rebuild.
|
||||
|
||||
### v1.1.0 — 2026-07-24
|
||||
- Dialog pembayaran kasir: tombol "Selesai & Cetak" menampilkan loading state (spinner + "Memproses...") dengan guard anti-doble-klik.
|
||||
- Dialog struk pembayaran: tombol "Cetak Struk" memiliki loading state ("Mencetak...") dan disable saat proses cetak.
|
||||
@@ -493,3 +518,29 @@ Dutra Store adalah aplikasi kasir digital berbasis Flutter untuk toko grosir. Ap
|
||||
4. Jalankan SQL tambahan untuk kolom `store_id` (multi-tenant preparation): tambah tabel `stores`, tambah kolom `store_id` ke `products`, `sales`, `store_settings`, backfill data existing dengan default store ID, dan buat index. RLS dimatikan untuk semua tabel.
|
||||
- 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.
|
||||
|
||||
## Infra & Setup App
|
||||
|
||||
### Backend Supabase (Self-Host)
|
||||
- URL: `https://supa.gosambi.uk` (gateway envoy port 8001)
|
||||
- Keys: `D:\HomeLabs\backups\supabase-keys-local.txt` (ANON + SERVICE)
|
||||
- Backup data: script `D:\HomeLabs\scripts\supabase_backup.py` → `D:\HomeLabs\backups\supabase-nwuupvnodsgxdhqcakqr\`
|
||||
- Schema backup: `backups\...\schema_selfhost.sql`
|
||||
- Config app: `lib\config.dart` (gitignored — berisi URL + keys, jangan commit)
|
||||
- Migrasi cloud → self-host selesai 2026-10-17
|
||||
- Akun: admin@posgrosir.local, kasir1@toko.com, bibi/bubu/baba@toko.com (password sementara `GrosirBaru2026!`)
|
||||
|
||||
### Standar Rilis APK
|
||||
1. Naikkan versi di `pubspec.yaml` (misal `1.2.0+5` → `1.3.0+6`).
|
||||
2. Commit & `git push gitea main` (single remote Gitea; jangan commit APK ke repo).
|
||||
3. `flutter build apk --release` di `D:\HomeLabs\app-grosir` (jika gagal: `.gradlew --stop` + `flutter clean`).
|
||||
4. Verifikasi APK: file `build\app\outputs\flutter-apk\app-release.apk`; pastikan string `supa.gosambi.uk` ada di `libapp.so`.
|
||||
5. Instal ke HP: `adb -s fd683806 install -r app-release.apk`.
|
||||
6. Buat tag `v1.x.y` di Gitea + upload APK ke **Release** (`git.gosambi.uk/onie/app-grosir/releases`), jangan upload APK langsung ke repo Git.
|
||||
7. Catat versi baru di PRD.md ini + rule.md (singkat saja).
|
||||
|
||||
### Device Testing
|
||||
- HP utama: **Realme RMX2061** (Android 11, device ID `fd683806`, package `com.grosir.app_grosir`)
|
||||
- HP admin kedua: sudah ada (untuk testing notifikasi multi-device)
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- Akses jaringan: API Supabase + deteksi konektivitas offline queue -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Camera permission for barcode scanning -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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!')
|
||||
@@ -1,39 +0,0 @@
|
||||
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!')
|
||||
@@ -1,15 +0,0 @@
|
||||
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}')
|
||||
@@ -1,27 +0,0 @@
|
||||
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}')
|
||||
@@ -1,16 +0,0 @@
|
||||
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}')
|
||||
@@ -1,10 +0,0 @@
|
||||
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()}')
|
||||
@@ -1,15 +0,0 @@
|
||||
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}')
|
||||
@@ -1,23 +0,0 @@
|
||||
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()}')
|
||||
@@ -1,29 +0,0 @@
|
||||
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}')
|
||||
@@ -1,23 +0,0 @@
|
||||
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()}')
|
||||
@@ -1,38 +0,0 @@
|
||||
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!')
|
||||
@@ -1,38 +0,0 @@
|
||||
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!')
|
||||
@@ -1,35 +0,0 @@
|
||||
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find the start and end of _showAddForm
|
||||
start = None
|
||||
end = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith('void _showAddForm'):
|
||||
start = i
|
||||
if start is not None and line.strip() == '}' and i > start + 5:
|
||||
# Check if this is the end of _showAddForm
|
||||
# It should be followed by empty line or another top-level declaration
|
||||
if i + 1 < len(lines):
|
||||
next_line = lines[i + 1].strip()
|
||||
if next_line == '' or next_line.startswith('void ') or next_line.startswith('class '):
|
||||
end = i
|
||||
break
|
||||
|
||||
print(f'_showAddForm: lines {start+1} to {end+1}')
|
||||
|
||||
# Remove 2-space indent from _showAddForm function
|
||||
for i in range(start, end + 1):
|
||||
if lines[i].startswith(' '):
|
||||
lines[i] = lines[i][2:]
|
||||
|
||||
# After _showAddForm, there might be an extra class closing }
|
||||
# The class _ProdukScreenState should close right after build method
|
||||
# Let's find the class closing after _showAddForm and remove it if it's extra
|
||||
# After _showAddForm ends, the next line should be empty or a function/class
|
||||
# If there's a } followed by empty line and then another }, remove the first }
|
||||
|
||||
with open('D:\\app\\ai\\app-grosir\\lib\\screens\\produk_screen.dart', 'w') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
print('Done')
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import 'config.dart';
|
||||
import 'models/sale.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';
|
||||
import 'utils/format.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -25,12 +28,48 @@ class MyApp extends StatefulWidget {
|
||||
|
||||
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
late final AppState _appState;
|
||||
final GlobalKey<ScaffoldMessengerState> _scaffoldKey =
|
||||
GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_appState = AppState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
// Dengarkan stream notifikasi transaksi baru (dari HP lain / checkout).
|
||||
_appState.newSaleNotifications.stream.listen(_showSaleNotification);
|
||||
}
|
||||
|
||||
void _showSaleNotification(Sale sale) {
|
||||
final messenger = _scaffoldKey.currentState;
|
||||
if (messenger == null) return;
|
||||
// Getar + bunyi notifikasi.
|
||||
HapticFeedback.heavyImpact();
|
||||
SystemSound.play(SystemSoundType.click);
|
||||
// Banner notifikasi di atas layar.
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle, color: kSuccess, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Transaksi baru: ${sale.items.fold<int>(0, (s, i) => s + i.qty)} item • ${formatRupiah(sale.total)}',
|
||||
style: const TextStyle(color: kText, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: kSurface,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 4),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: kSuccess, width: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -54,6 +93,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
return ChangeNotifierProvider.value(
|
||||
value: _appState,
|
||||
child: MaterialApp(
|
||||
scaffoldMessengerKey: _scaffoldKey,
|
||||
title: 'Dutra Store',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
@@ -151,25 +191,25 @@ class AuthGate extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<AppState, bool>(
|
||||
selector: (context, state) => state.loading,
|
||||
builder: (context, loading, child) {
|
||||
if (loading) {
|
||||
return Selector<AppState, ({bool loading, bool loggedIn, bool expired})>(
|
||||
selector: (context, state) => (
|
||||
loading: state.loading,
|
||||
loggedIn: state.isLoggedIn,
|
||||
expired: state.sessionExpired,
|
||||
),
|
||||
builder: (context, a, child) {
|
||||
// Loading global hanya untuk init aplikasi. Proses login dihandle
|
||||
// tombol LoginScreen, status data produk dihandle spinner khusus
|
||||
// Kasir agar layar tidak melepas LoginScreen saat gagal login.
|
||||
if (a.loading) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(color: kAccent),
|
||||
),
|
||||
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();
|
||||
},
|
||||
);
|
||||
if (a.loggedIn && !a.expired) {
|
||||
return const HomePage();
|
||||
}
|
||||
return const LoginScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class _HomePageState extends State<HomePage> {
|
||||
return screens;
|
||||
}
|
||||
|
||||
List<BottomNavigationBarItem> _navItemsForRole(String? role) {
|
||||
List<BottomNavigationBarItem> _navItemsForRole(String? role, AppState state) {
|
||||
final items = <BottomNavigationBarItem>[
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.point_of_sale),
|
||||
@@ -48,8 +48,12 @@ class _HomePageState extends State<HomePage> {
|
||||
icon: Icon(Icons.inventory_2),
|
||||
label: 'Produk',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.receipt_long),
|
||||
BottomNavigationBarItem(
|
||||
icon: Badge(
|
||||
isLabelVisible: state.unseenSalesCount > 0,
|
||||
label: Text('${state.unseenSalesCount}'),
|
||||
child: const Icon(Icons.receipt_long),
|
||||
),
|
||||
label: 'Riwayat',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
@@ -76,7 +80,7 @@ class _HomePageState extends State<HomePage> {
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<AppState>();
|
||||
final screens = _screensForRole(state.userRole);
|
||||
final navItems = _navItemsForRole(state.userRole);
|
||||
final navItems = _navItemsForRole(state.userRole, state);
|
||||
|
||||
final safeIndex = _index >= screens.length ? 0 : _index;
|
||||
|
||||
@@ -109,7 +113,14 @@ class _HomePageState extends State<HomePage> {
|
||||
bottomNavigationBar: navItems.length >= 2
|
||||
? BottomNavigationBar(
|
||||
currentIndex: safeIndex,
|
||||
onTap: (i) => setState(() => _index = i),
|
||||
onTap: (i) {
|
||||
setState(() => _index = i);
|
||||
// Reset badge saat tab Riwayat dibuka.
|
||||
final riwayatIdx = state.userRole == 'admin' ? 2 : 1;
|
||||
if (i == riwayatIdx) {
|
||||
context.read<AppState>().markSalesSeen();
|
||||
}
|
||||
},
|
||||
items: navItems,
|
||||
)
|
||||
: null,
|
||||
|
||||
@@ -23,14 +23,26 @@ class KasirScreen extends StatefulWidget {
|
||||
class _KasirScreenState extends State<KasirScreen> {
|
||||
final TextEditingController _search = TextEditingController();
|
||||
Timer? _searchDebounce;
|
||||
bool _isGrid = false; // default: list (foto lebih jelas)
|
||||
String? _lastAddedId;
|
||||
Timer? _lastAddedTimer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_lastAddedTimer?.cancel();
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _markAdded(String id) {
|
||||
_lastAddedTimer?.cancel();
|
||||
setState(() => _lastAddedId = id);
|
||||
_lastAddedTimer = Timer(const Duration(milliseconds: 900), () {
|
||||
if (mounted) setState(() => _lastAddedId = null);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<AppState>();
|
||||
@@ -44,12 +56,16 @@ class _KasirScreenState extends State<KasirScreen> {
|
||||
p.name.toLowerCase().contains(query) ||
|
||||
p.barcode.toLowerCase().contains(query))
|
||||
.toList();
|
||||
final isInitialProductsLoading = state.productsLoading && !state.dataReady;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 6),
|
||||
child: TextField(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _search,
|
||||
onChanged: (_) {
|
||||
_searchDebounce?.cancel();
|
||||
@@ -71,6 +87,61 @@ class _KasirScreenState extends State<KasirScreen> {
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Stack(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Buka keranjang',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: kSurface,
|
||||
foregroundColor: kAccent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: kBorder),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.shopping_cart),
|
||||
onPressed: () => _openCartSheet(context, state),
|
||||
),
|
||||
if (state.cart.isNotEmpty)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: kDanger,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'${state.cart.fold(0, (s, c) => s + c.qty)}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: _isGrid ? 'Tampilan list' : 'Tampilan grid',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: kSurface,
|
||||
foregroundColor: kAccent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: kBorder),
|
||||
),
|
||||
),
|
||||
icon: Icon(_isGrid ? Icons.view_list : Icons.grid_view),
|
||||
onPressed: () => setState(() => _isGrid = !_isGrid),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
@@ -85,7 +156,6 @@ class _KasirScreenState extends State<KasirScreen> {
|
||||
final result = await showDialog<Map<String, dynamic>>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
bool submitting = false;
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialog) {
|
||||
return AlertDialog(
|
||||
@@ -131,7 +201,7 @@ class _KasirScreenState extends State<KasirScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: submitting ? null : () => Navigator.pop(ctx),
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: kMuted,
|
||||
side: const BorderSide(color: kBorder),
|
||||
@@ -142,9 +212,7 @@ class _KasirScreenState extends State<KasirScreen> {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: submitting
|
||||
? null
|
||||
: () {
|
||||
onPressed: () {
|
||||
final name = nameCtrl.text.trim();
|
||||
final price = (RupiahInputFormatter.getRawValue(priceCtrl.text) ?? 0).toDouble();
|
||||
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
|
||||
@@ -156,9 +224,7 @@ class _KasirScreenState extends State<KasirScreen> {
|
||||
});
|
||||
},
|
||||
style: FilledButton.styleFrom(backgroundColor: kAccent, foregroundColor: const Color(0xFF06210F)),
|
||||
child: submitting
|
||||
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F)))
|
||||
: const Text('Tambah'),
|
||||
child: const Text('Tambah'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -177,7 +243,7 @@ class _KasirScreenState extends State<KasirScreen> {
|
||||
for (int i = 0; i < (result['qty'] as int); i++) {
|
||||
state.addToCart(manualProduct);
|
||||
}
|
||||
if (mounted) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Pesanan manual ditambahkan: ${result['name']}'),
|
||||
@@ -218,52 +284,76 @@ class _KasirScreenState extends State<KasirScreen> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: products.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Icon(Icons.search_off, size: 48, color: kMuted),
|
||||
SizedBox(height: 8),
|
||||
Text('Produk tidak ditemukan',
|
||||
style: TextStyle(color: kMuted)),
|
||||
],
|
||||
child: isInitialProductsLoading
|
||||
? const _ProductsLoading()
|
||||
: products.isEmpty
|
||||
? state.productsError != null
|
||||
? _EmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
title: 'Gagal memuat produk',
|
||||
subtitle:
|
||||
'Periksa koneksi lalu coba lagi. Detail: ${state.productsError}',
|
||||
actionLabel: 'Coba lagi',
|
||||
)
|
||||
: _EmptyState(
|
||||
icon: state.products.isEmpty
|
||||
? Icons.inventory_2_outlined
|
||||
: Icons.search_off,
|
||||
title: state.products.isEmpty
|
||||
? 'Belum ada produk'
|
||||
: 'Tidak ditemukan',
|
||||
subtitle: state.products.isEmpty
|
||||
? 'Tambahkan produk lewat tab Produk.'
|
||||
: 'Coba kata kunci lain.',
|
||||
)
|
||||
: _isGrid
|
||||
? GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 10,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisExtent: 168,
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
context.read<AppState>().recordManualRefresh();
|
||||
await context.read<AppState>().refresh();
|
||||
},
|
||||
color: kAccent,
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.1,
|
||||
),
|
||||
itemCount: products.length,
|
||||
itemBuilder: (context, i) => _ProductCard(
|
||||
product: products[i],
|
||||
displayPrice:
|
||||
isReseller ? products[i].priceReseller : products[i].price,
|
||||
isReseller: isReseller,
|
||||
itemCount: products.length,
|
||||
itemBuilder: (context, i) {
|
||||
final p = products[i];
|
||||
// Hijau sesaat hanya untuk produk yang baru ditap,
|
||||
// BUKAN semua isi keranjang — tap selalu tambah qty.
|
||||
final highlight = _lastAddedId == p.id;
|
||||
return _KasirCard(
|
||||
key: ValueKey(p.id),
|
||||
product: p,
|
||||
price: isReseller ? p.priceReseller : p.price,
|
||||
inCart: highlight,
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
state.addToCart(products[i]);
|
||||
state.addToCart(p);
|
||||
_markAdded(p.id);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
||||
itemCount: products.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, i) {
|
||||
final p = products[i];
|
||||
final highlight = _lastAddedId == p.id;
|
||||
return _KasirListTile(
|
||||
key: ValueKey(p.id),
|
||||
product: p,
|
||||
price: isReseller ? p.priceReseller : p.price,
|
||||
inCart: highlight,
|
||||
onTap: () {
|
||||
state.addToCart(p);
|
||||
_markAdded(p.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.cart.isNotEmpty)
|
||||
_CartBar(
|
||||
onTap: () => _openCartSheet(context, state),
|
||||
itemCount: state.cart.fold(0, (s, c) => s + c.qty),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1139,7 +1229,7 @@ class _CartSheetContent extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
_KasirAvatar(product: line.product, radius: 18),
|
||||
_KasirAvatar(product: line.product, size: 36, radius: 18),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -1437,153 +1527,246 @@ class _ReceiptRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ProductCard extends StatelessWidget {
|
||||
// Avatar foto produk kasir (56px default) atau huruf inisial.
|
||||
class _KasirAvatar extends StatelessWidget {
|
||||
final Product product;
|
||||
final double displayPrice;
|
||||
final bool isReseller;
|
||||
final VoidCallback onTap;
|
||||
const _ProductCard({
|
||||
required this.product,
|
||||
required this.displayPrice,
|
||||
required this.isReseller,
|
||||
required this.onTap,
|
||||
});
|
||||
final double size;
|
||||
final double? radius;
|
||||
const _KasirAvatar({required this.product, this.size = 56, this.radius});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final out = product.stock <= 0;
|
||||
final url = productImageUrl(product.imagePath);
|
||||
final r = radius ?? 12;
|
||||
if (url != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(r),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_KasirInitial(product: product, size: size, radius: r),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _KasirInitial(product: product, size: size, radius: r);
|
||||
}
|
||||
}
|
||||
|
||||
return AbsorbPointer(
|
||||
absorbing: out,
|
||||
child: Opacity(
|
||||
opacity: out ? 0.5 : 1,
|
||||
child: InkWell(
|
||||
onTap: out ? null : onTap,
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: kSurface2,
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
border: Border.all(color: kBorder),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black26,
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_KasirAvatar(product: product, radius: 18),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: out
|
||||
? kDanger.withValues(alpha: 0.15)
|
||||
: kSuccess.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
out ? 'Habis' : 'Stok ${product.stock}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: out ? kDanger : kSuccess,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
product.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 14),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: kAccent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
formatRupiah(displayPrice),
|
||||
style: TextStyle(
|
||||
color: kAccent,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isReseller) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: kAccent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text('Reseller',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: kAccent)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
class _KasirInitial extends StatelessWidget {
|
||||
final Product product;
|
||||
final double size;
|
||||
final double radius;
|
||||
const _KasirInitial(
|
||||
{required this.product, required this.size, this.radius = 12});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initial =
|
||||
product.name.isNotEmpty ? product.name[0].toUpperCase() : '?';
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: productAccent(product.name).withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: TextStyle(
|
||||
color: productAccent(product.name),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: size * 0.42,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Avatar produk di kasir: foto jika ada, fallback huruf pertama nama.
|
||||
class _KasirAvatar extends StatelessWidget {
|
||||
// Kartu grid kasir.
|
||||
class _KasirCard extends StatelessWidget {
|
||||
final Product product;
|
||||
final double radius;
|
||||
const _KasirAvatar({required this.product, this.radius = 18});
|
||||
final double price;
|
||||
final bool inCart;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _KasirCard({
|
||||
super.key,
|
||||
required this.product,
|
||||
required this.price,
|
||||
required this.inCart,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = productImageUrl(product.imagePath);
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: kAccent.withValues(alpha: 0.15),
|
||||
backgroundImage: url != null ? NetworkImage(url) : null,
|
||||
child: url == null
|
||||
? Text(
|
||||
product.name.isNotEmpty
|
||||
? product.name[0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: kAccent,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: radius * 0.75),
|
||||
)
|
||||
: null,
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: inCart ? kSuccess.withValues(alpha: 0.12) : kSurface,
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
border: Border.all(
|
||||
color: inCart ? kSuccess : kBorder, width: inCart ? 1.5 : 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_KasirAvatar(product: product, size: 40),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: product.stock > 0
|
||||
? kSuccess.withValues(alpha: 0.15)
|
||||
: kDanger.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'Stok ${product.stock}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: product.stock > 0 ? kSuccess : kDanger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
product.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: kText, fontWeight: FontWeight.w600, fontSize: 13),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: kAccent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
formatRupiah(price),
|
||||
style: const TextStyle(
|
||||
color: kAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Baris list kasir: foto 56px + nama/harga/stok + tombol tambah.
|
||||
class _KasirListTile extends StatelessWidget {
|
||||
final Product product;
|
||||
final double price;
|
||||
final bool inCart;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _KasirListTile({
|
||||
super.key,
|
||||
required this.product,
|
||||
required this.price,
|
||||
required this.inCart,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: inCart ? kSuccess.withValues(alpha: 0.12) : kSurface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: inCart ? kSuccess : kBorder, width: inCart ? 1.5 : 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_KasirAvatar(product: product, size: 56),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
product.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: kText,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formatRupiah(price),
|
||||
style: const TextStyle(
|
||||
color: kAccent,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: product.stock > 0
|
||||
? kSuccess.withValues(alpha: 0.15)
|
||||
: kDanger.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'Stok ${product.stock}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: product.stock > 0 ? kSuccess : kDanger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: inCart
|
||||
? kSuccess.withValues(alpha: 0.2)
|
||||
: kAccent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
inCart ? Icons.check : Icons.add,
|
||||
color: inCart ? kSuccess : kAccent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _QtyButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
@@ -1602,3 +1785,103 @@ class _QtyButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String? actionLabel;
|
||||
const _EmptyState({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.actionLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 56, color: kMuted),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: kText,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(color: kMuted, fontSize: 13),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (actionLabel != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.read<AppState>().retryProducts(),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(actionLabel!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProductsLoading extends StatelessWidget {
|
||||
const _ProductsLoading();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
||||
itemCount: 6,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, i) {
|
||||
return Container(
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: kSurface2,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: kBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
margin: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: kBorder,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: kAccent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,25 +338,25 @@ void _showAddForm(BuildContext context, AppState state) {
|
||||
FocusNode(),
|
||||
FocusNode(),
|
||||
];
|
||||
bool _listenersAdded = false;
|
||||
final _sheetController = DraggableScrollableController();
|
||||
bool listenersAdded = false;
|
||||
final sheetController = DraggableScrollableController();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) => DraggableScrollableSheet(
|
||||
controller: _sheetController,
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.65,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.92,
|
||||
builder: (_, scrollController) {
|
||||
if (!_listenersAdded) {
|
||||
_listenersAdded = true;
|
||||
if (!listenersAdded) {
|
||||
listenersAdded = true;
|
||||
for (final node in focusNodes) {
|
||||
node.addListener(() {
|
||||
if (node.hasFocus && context.mounted) {
|
||||
_sheetController.animateTo(
|
||||
sheetController.animateTo(
|
||||
0.92,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
@@ -606,8 +606,8 @@ void _showEditDialog(BuildContext context, AppState state, Product product) {
|
||||
FocusNode(),
|
||||
FocusNode(),
|
||||
];
|
||||
bool _listenersAdded = false;
|
||||
final _sheetController = DraggableScrollableController();
|
||||
bool listenersAdded = false;
|
||||
final sheetController = DraggableScrollableController();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@@ -616,17 +616,17 @@ void _showEditDialog(BuildContext context, AppState state, Product product) {
|
||||
builder: (sheetContext) {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return DraggableScrollableSheet(
|
||||
controller: _sheetController,
|
||||
controller: sheetController,
|
||||
initialChildSize: 0.65,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.92,
|
||||
builder: (_, scrollController) {
|
||||
if (!_listenersAdded) {
|
||||
_listenersAdded = true;
|
||||
if (!listenersAdded) {
|
||||
listenersAdded = true;
|
||||
for (final node in focusNodes) {
|
||||
node.addListener(() {
|
||||
if (node.hasFocus && context.mounted) {
|
||||
_sheetController.animateTo(
|
||||
sheetController.animateTo(
|
||||
0.92,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
|
||||
@@ -211,7 +211,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
lastDate: DateTime.now(),
|
||||
initialDateRange: _dateRange,
|
||||
);
|
||||
if (mounted && picked != null) {
|
||||
if (picked != null && context.mounted) {
|
||||
setState(() => _dateRange = picked);
|
||||
context.read<AppState>().resetPagination();
|
||||
}
|
||||
@@ -395,7 +395,10 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
},
|
||||
color: kAccent,
|
||||
child: ListView.separated(
|
||||
itemCount: displayedSales.length + (state.hasMoreSales ? 1 : 0),
|
||||
itemCount: displayedSales.length +
|
||||
(filteredSales.length > state.visibleSalesCount
|
||||
? 1
|
||||
: 0),
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) {
|
||||
@@ -466,6 +469,28 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.isSalePending(sale.id)) ...[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.hourglass_empty,
|
||||
size: 10,
|
||||
color: kMuted,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Menunggu sinkron',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: kMuted,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -630,6 +655,8 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
_receiptRow('Kembali', formatRupiah(currentSale.change.clamp(0, double.infinity)), false),
|
||||
_receiptRow('Metode', _paymentMethodLabel(currentSale.paymentMethod), false),
|
||||
_receiptRow('Status', currentSale.paymentStatus == 'paid' ? 'Lunas' : 'Belum Lunas', false),
|
||||
if (state.isSalePending(currentSale.id))
|
||||
_receiptRow('Sinkronisasi', 'Belum terkirim ke server', false),
|
||||
if (currentSale.splitPayment != null && currentSale.splitPayment!.isNotEmpty)
|
||||
...currentSale.splitPayment!.entries.map((e) {
|
||||
final label = e.key == 'tunai'
|
||||
@@ -688,7 +715,6 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
final result = await showDialog<double>(
|
||||
context: parentContext,
|
||||
builder: (ctx) {
|
||||
bool submitting = false;
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialog) {
|
||||
final isUnpaid = currentSale.paymentStatus == 'unpaid';
|
||||
@@ -781,7 +807,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: submitting ? null : () => Navigator.pop(ctx),
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: kMuted,
|
||||
side: const BorderSide(color: kBorder),
|
||||
@@ -792,17 +818,13 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: submitting
|
||||
? null
|
||||
: () {
|
||||
onPressed: () {
|
||||
final val = (RupiahInputFormatter.getRawValue(payCtrl.text) ?? 0).toDouble();
|
||||
if (val <= 0) return;
|
||||
Navigator.pop(ctx, val);
|
||||
},
|
||||
style: FilledButton.styleFrom(backgroundColor: kSuccess, foregroundColor: const Color(0xFF06210F)),
|
||||
child: submitting
|
||||
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F)))
|
||||
: const Text('Simpan'),
|
||||
child: const Text('Simpan'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -827,7 +849,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
if (sheetContext.mounted) {
|
||||
Navigator.pop(sheetContext);
|
||||
}
|
||||
if (mounted) {
|
||||
if (parentContext.mounted) {
|
||||
final messenger = ScaffoldMessenger.of(parentContext);
|
||||
final msg = err == null
|
||||
? isNowPaid
|
||||
@@ -862,7 +884,6 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: parentContext,
|
||||
builder: (ctx) {
|
||||
bool cancelling = false;
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialog) {
|
||||
return AlertDialog(
|
||||
@@ -876,7 +897,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: cancelling ? null : () => Navigator.pop(ctx, false),
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: kMuted,
|
||||
side: const BorderSide(color: kBorder),
|
||||
@@ -887,13 +908,9 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: cancelling
|
||||
? null
|
||||
: () => Navigator.pop(ctx, true),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(backgroundColor: kDanger),
|
||||
child: cancelling
|
||||
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Text('Batalkan'),
|
||||
child: const Text('Batalkan'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -906,7 +923,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
||||
);
|
||||
if (confirm == true && mounted) {
|
||||
final err = await state.cancelSale(currentSale.id);
|
||||
if (mounted) {
|
||||
if (sheetContext.mounted) {
|
||||
Navigator.pop(sheetContext);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
@@ -24,7 +25,7 @@ class OfflineQueueService {
|
||||
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
|
||||
);
|
||||
} on TimeoutException catch (e) {
|
||||
print('OfflineQueue timeout: $e');
|
||||
debugPrint('OfflineQueue timeout: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -33,11 +34,35 @@ class OfflineQueueService {
|
||||
bool get isOnline => _isOnline;
|
||||
bool get hasPendingItems => _queue.isNotEmpty;
|
||||
|
||||
/// ID transaksi (sale.id) yang masih menunggu sinkronisasi ke server.
|
||||
/// Dipakai UI untuk menandai transaksi offline di Riwayat.
|
||||
Set<String> get pendingSaleIds {
|
||||
return _queue
|
||||
.map((item) => item['sale']?['id'] as String?)
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
}
|
||||
|
||||
/// Cek apakah transaksi dengan [saleId] masih dalam antrean offline
|
||||
/// (belum terkirim ke server).
|
||||
bool isPending(String? saleId) {
|
||||
if (saleId == null) return false;
|
||||
return _queue.any((item) => item['sale']?['id'] == saleId);
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
await _loadQueue();
|
||||
_connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
|
||||
_connectivity.onConnectivityChanged.listen((result) {
|
||||
_updateConnectionStatus(result);
|
||||
if (_isOnline && _queue.isNotEmpty) {
|
||||
_processQueue().then((_) => retryNow());
|
||||
}
|
||||
});
|
||||
await _checkConnection();
|
||||
if (_isOnline) await _processQueue();
|
||||
if (_isOnline) {
|
||||
await retryNow();
|
||||
retryNow();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadQueue() async {
|
||||
@@ -59,10 +84,31 @@ class OfflineQueueService {
|
||||
_updateConnectionStatus(result);
|
||||
}
|
||||
|
||||
/// Coba kirim antrean sekarang. Dipanggil setiap refresh/poll sukses
|
||||
/// (server terbukti reachable), bukan hanya saat event connectivity
|
||||
/// berubah. Dipanggil juga dari `init()` agar offline queue dikosongkan
|
||||
/// pas app nyala, dan setiap kali server terbukti reachable lagi.
|
||||
Future<void> retryNow() async {
|
||||
await _checkConnection();
|
||||
if (!_isOnline || _queue.isEmpty || _syncing) return;
|
||||
// Verifikasi server benar-benar bisa dijangkau (radio nyala belum tentu
|
||||
// berarti Supabase reachable, misal sinyal buruk). Ping ringan 5 detik.
|
||||
try {
|
||||
await _supabase.from('products').select('id').limit(1).timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw TimeoutException('Ping server timeout'),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('OfflineQueue skip sync: server tidak terjangkau: $e');
|
||||
return;
|
||||
}
|
||||
await _processQueue();
|
||||
}
|
||||
|
||||
void _updateConnectionStatus(List<ConnectivityResult> result) {
|
||||
_isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||
if (_isOnline && _queue.isNotEmpty) {
|
||||
_processQueue();
|
||||
_processQueue().then((_) => retryNow());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +116,7 @@ class OfflineQueueService {
|
||||
_queue.add(transaction);
|
||||
await _saveQueue();
|
||||
if (_isOnline) {
|
||||
await _processQueue();
|
||||
await _processQueue().then((_) => retryNow());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +131,7 @@ class OfflineQueueService {
|
||||
_queue.removeAt(0);
|
||||
await _saveQueue();
|
||||
} catch (e) {
|
||||
debugPrint('OfflineQueue gagal sinkron item ${item['sale']?['id']}: $e');
|
||||
_syncing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,24 @@ class CartLine {
|
||||
class AppState extends ChangeNotifier {
|
||||
final SupabaseClient _supabase = Supabase.instance.client;
|
||||
|
||||
// Stream notifikasi transaksi baru (dipakai UI untuk banner+suara+getar).
|
||||
// Diisi setiap createSale sukses DAN setiap realtime INSERT dari HP lain.
|
||||
final StreamController<Sale> newSaleNotifications =
|
||||
StreamController<Sale>.broadcast();
|
||||
final Set<String> _notifiedSaleIds = {};
|
||||
|
||||
// Jumlah transaksi baru yang belum dilihat admin (badge di tab Riwayat).
|
||||
int unseenSalesCount = 0;
|
||||
|
||||
void markSalesSeen() {
|
||||
if (unseenSalesCount != 0) {
|
||||
unseenSalesCount = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> retryProducts() => refresh();
|
||||
|
||||
// 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);
|
||||
@@ -53,6 +71,9 @@ class AppState extends ChangeNotifier {
|
||||
List<Map<String, dynamic>> kasirAccounts = [];
|
||||
bool loading = true;
|
||||
String? error;
|
||||
bool dataReady = false;
|
||||
bool productsLoading = false;
|
||||
String? productsError;
|
||||
|
||||
static const int _pageSize = 30;
|
||||
int _visibleProductCount = _pageSize;
|
||||
@@ -93,6 +114,12 @@ class AppState extends ChangeNotifier {
|
||||
String? userId;
|
||||
String? userRole;
|
||||
UserProfile? _profile;
|
||||
|
||||
/// True jika session auth mati total (refresh token pun kedaluwarsa).
|
||||
/// UI (kasir) memakai ini untuk menampilkan info "sesi habis" +
|
||||
/// tombol login ulang, bukan list kosong yang membingungkan.
|
||||
bool _sessionExpired = false;
|
||||
bool get sessionExpired => _sessionExpired;
|
||||
final Map<String, String> _profileNames = {};
|
||||
|
||||
double _discount = 0;
|
||||
@@ -135,16 +162,101 @@ class AppState extends ChangeNotifier {
|
||||
if (elapsed < const Duration(seconds: 15)) return;
|
||||
}
|
||||
await refresh();
|
||||
_checkNewSales();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await _loadProducts();
|
||||
await _loadSales();
|
||||
notifyListeners();
|
||||
bool _salesFirstLoad = true;
|
||||
|
||||
/// Deteksi transaksi baru dari polling/refresh dan kirim ke stream
|
||||
/// notifikasi. Load pertama dilewati agar tidak spam saat start.
|
||||
void _checkNewSales() {
|
||||
// Hanya admin yang perlu notifikasi — kasir tidak (mereka yang
|
||||
// transaksi, jadi sudah tahu).
|
||||
if (userRole != 'admin') {
|
||||
for (final s in sales) {
|
||||
_notifiedSaleIds.add(s.id);
|
||||
}
|
||||
_salesFirstLoad = false;
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
bool newSeen = false;
|
||||
for (final s in sales) {
|
||||
if (!_notifiedSaleIds.contains(s.id)) {
|
||||
_notifiedSaleIds.add(s.id);
|
||||
// Lewati transaksi lama (>5 menit) agar tidak spam notif saat start.
|
||||
// Lewati juga transaksi sendiri — admin tahu kalau dia yang jualan.
|
||||
if (!_salesFirstLoad &&
|
||||
s.userId != userId &&
|
||||
now.difference(s.date).inMinutes.abs() <= 5) {
|
||||
newSeen = true;
|
||||
unseenSalesCount++;
|
||||
if (!newSaleNotifications.isClosed) {
|
||||
newSaleNotifications.add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newSeen) notifyListeners();
|
||||
_salesFirstLoad = false;
|
||||
}
|
||||
|
||||
/// Refresh produk + riwayat dengan retry terbatas untuk kegagalan sementara.
|
||||
/// Nyalakan indikator loading hanya saat data pertama belum siap, supaya
|
||||
/// Kasir bisa menampilkan status memuat/gagal/kosong dengan jelas.
|
||||
/// Nonaktifkan indikator apa pun yang menyebabkan masalah refresh berulang.
|
||||
/// Kirim sinyal hanya pada pemuatan awal; polling latar tidak mengganggu.
|
||||
Future<void> refresh({bool notifyLoading = true, int retries = 1}) async {
|
||||
final authOk = await _ensureSessionAndAuth();
|
||||
if (!authOk) {
|
||||
// Auth mati: jangan lanjut query (pasti 401). Cukup notify agar
|
||||
// AuthGate redirect ke LoginScreen.
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final showProductsLoading = notifyLoading && !dataReady;
|
||||
if (showProductsLoading) {
|
||||
productsLoading = true;
|
||||
notifyListeners();
|
||||
}
|
||||
try {
|
||||
await _loadProducts();
|
||||
await _loadSales();
|
||||
dataReady = true;
|
||||
// Server terbukti reachable → coba kirim antrean transaksi offline.
|
||||
await _offlineQueue.retryNow();
|
||||
} catch (e) {
|
||||
productsError ??= e.toString();
|
||||
if (retries > 0) {
|
||||
// Coba sekali lagi untuk kegagalan sementara (misal koneksi putus).
|
||||
productsError = null;
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
await refresh(notifyLoading: notifyLoading, retries: retries - 1);
|
||||
return;
|
||||
}
|
||||
debugPrint('Gagal refresh: $e');
|
||||
} finally {
|
||||
if (showProductsLoading) {
|
||||
productsLoading = false;
|
||||
}
|
||||
if (notifyLoading) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final OfflineQueueService _offlineQueue = OfflineQueueService();
|
||||
|
||||
/// ID transaksi yang masih dalam antrean offline (belum terkirim ke server).
|
||||
Set<String> get pendingSaleIds => _offlineQueue.pendingSaleIds;
|
||||
|
||||
/// True jika transaksi dengan [saleId] masih menunggu sinkronisasi.
|
||||
bool isSalePending(String? saleId) => _offlineQueue.isPending(saleId);
|
||||
|
||||
/// Jumlah transaksi offline yang belum tersinkron (badge di Riwayat).
|
||||
int get pendingOfflineCount => _offlineQueue.pendingSaleIds.length;
|
||||
|
||||
double get subtotal =>
|
||||
cart.fold(0, (sum, line) => sum + line.price * line.qty);
|
||||
|
||||
@@ -157,16 +269,27 @@ class AppState extends ChangeNotifier {
|
||||
/* ---------- 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();
|
||||
final authOk = await _ensureSessionAndAuth();
|
||||
if (!authOk) {
|
||||
// Session mati total (refresh token ikut kadaluwarsa). Jangan lanjut:
|
||||
// _loadProducts() akan timeout 30s + 401, bakal menjebak loading walau
|
||||
// produk sendiri masih ada. Tandai sessionExpired, jalankan polling
|
||||
// saja (real-time masih bisa update), dan biarkan UI tampil
|
||||
// "login ulang".
|
||||
_sessionExpired = true;
|
||||
startPolling();
|
||||
error = 'Sesi auth belum tersedia, silakan login ulang.';
|
||||
} else {
|
||||
await _loadProducts();
|
||||
await _loadSales();
|
||||
await loadKasirAccounts();
|
||||
await _loadProfileNames();
|
||||
await _loadSettings();
|
||||
_subscribe();
|
||||
await _seedAdminIfEmpty();
|
||||
await _offlineQueue.init();
|
||||
startPolling();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
}
|
||||
@@ -193,14 +316,6 @@ class AppState extends ChangeNotifier {
|
||||
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 {
|
||||
@@ -243,12 +358,21 @@ class AppState extends ChangeNotifier {
|
||||
'Login',
|
||||
);
|
||||
userId = res.user?.id;
|
||||
await _loadProfile();
|
||||
_sessionExpired = false;
|
||||
dataReady = false;
|
||||
productsLoading = true;
|
||||
productsError = null;
|
||||
notifyListeners();
|
||||
await _loadProfile();
|
||||
await refresh();
|
||||
return null;
|
||||
} on AuthException catch (e) {
|
||||
productsLoading = false;
|
||||
notifyListeners();
|
||||
return e.message;
|
||||
} catch (e) {
|
||||
productsLoading = false;
|
||||
notifyListeners();
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
@@ -282,6 +406,10 @@ class AppState extends ChangeNotifier {
|
||||
_profile = null;
|
||||
cart.clear();
|
||||
_discount = 0;
|
||||
products = [];
|
||||
dataReady = false;
|
||||
productsLoading = false;
|
||||
productsError = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -420,11 +548,15 @@ class AppState extends ChangeNotifier {
|
||||
_supabase.auth.onAuthStateChange.listen((event) {
|
||||
if (event.session != null) {
|
||||
userId = event.session!.user.id;
|
||||
_sessionExpired = false;
|
||||
_loadProfile();
|
||||
} else {
|
||||
// Sesi mati total (logout / refresh token ikut kedaluwarsa):
|
||||
// tandai agar UI bisa menampilkan pesan "sesi habis", bukan diam.
|
||||
userId = null;
|
||||
userRole = null;
|
||||
_profile = null;
|
||||
_sessionExpired = true;
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
@@ -452,12 +584,48 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _loadProducts() async {
|
||||
final rows = await _withTimeout(
|
||||
_supabase.from('products').select().eq('store_id', _currentStoreId).order('name'),
|
||||
'Load products',
|
||||
);
|
||||
products = rows.map((e) => Product.fromJson(e)).toList();
|
||||
if (products.isEmpty) _seed();
|
||||
try {
|
||||
final rows = await _withTimeout(
|
||||
_supabase.from('products').select().eq('store_id', _currentStoreId).order('name'),
|
||||
'Load products',
|
||||
);
|
||||
final loaded = rows.map((e) => Product.fromJson(e)).toList();
|
||||
// Hasil kosong dari server JANGAN menimpa data lama — itu tanda
|
||||
// query gagal diam-diam (misal JWT expired), bukan toko kehabisan produk.
|
||||
if (loaded.isNotEmpty) {
|
||||
products = loaded;
|
||||
} else if (products.isEmpty) {
|
||||
_seed();
|
||||
}
|
||||
} catch (e) {
|
||||
// Jangan crash refresh: produk lama tetap ditampilkan, error tercatat di log.
|
||||
debugPrint('Gagal load products: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Cek langkah demi langkah sampai session valid atau mati total.
|
||||
/// Stop segera kalau auth mati, jangan tunggu timeout 30s per request.
|
||||
Future<bool> _ensureSessionAndAuth() async {
|
||||
final session = _supabase.auth.currentSession;
|
||||
if (session != null && !session.isExpired) {
|
||||
return true;
|
||||
}
|
||||
// Session kadaluwarsa: coba refresh token dulu.
|
||||
try {
|
||||
final res = await _supabase.auth.refreshSession();
|
||||
if (res.session != null && !res.session!.isExpired) {
|
||||
return true;
|
||||
}
|
||||
// Refresh jalan tapi session tetap tak valid → auth benar-benar mati.
|
||||
_sessionExpired = true;
|
||||
} on AuthException {
|
||||
// Token refresh ditolak server (invalid/kedaluwarsa) → auth mati.
|
||||
_sessionExpired = true;
|
||||
} catch (_) {
|
||||
// Error jaringan/timeout: JANGAN tandai sessionExpired (bisa network
|
||||
// blip). Data gagal load sementara, session belum dianggap mati.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _loadSales() async {
|
||||
@@ -686,6 +854,11 @@ class AppState extends ChangeNotifier {
|
||||
cart.clear();
|
||||
_discount = 0;
|
||||
notifyListeners();
|
||||
|
||||
// Tandai ID transaksi lokal agar tidak di-notify lagi via polling
|
||||
// (admin yang jualan sendiri tidak perlu notif transaksinya sendiri).
|
||||
_notifiedSaleIds.add(sale.id);
|
||||
|
||||
return sale;
|
||||
} catch (e) {
|
||||
await _offlineQueue.addToQueue({
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.2.0+5
|
||||
version: 1.3.0+7
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB |
@@ -1,30 +0,0 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:app_grosir/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void test(BuildContext context) {
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.92 - bottomInset,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 16 + bottomInset),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(decoration: InputDecoration(labelText: 'Test 1')),
|
||||
TextField(decoration: InputDecoration(labelText: 'Test 2')),
|
||||
TextField(decoration: InputDecoration(labelText: 'Test 3')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void test(BuildContext context) {
|
||||
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(),
|
||||
];
|
||||
final _scrollTimers = <FocusNode, Timer>{};
|
||||
for (final node in focusNodes) {
|
||||
node.addListener(() {
|
||||
if (!node.hasFocus) {
|
||||
_scrollTimers[node]?.cancel();
|
||||
_scrollTimers.remove(node);
|
||||
} else if (context.mounted) {
|
||||
_scrollTimers[node]?.cancel();
|
||||
_scrollTimers[node] = Timer(const Duration(milliseconds: 350), () {
|
||||
if (node.context != null && node.context!.mounted) {
|
||||
Scrollable.ensureVisible(
|
||||
node.context!,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
alignment: 0.1,
|
||||
);
|
||||
}
|
||||
_scrollTimers.remove(node);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.92 - bottomInset,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border(top: BorderSide(color: Colors.grey)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.add_box_outlined, color: Colors.amber),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Tambah Produk',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.grey),
|
||||
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: InputDecoration(
|
||||
labelText: 'Barcode / No. Produk',
|
||||
prefixIcon: const Icon(Icons.qr_code_2_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.qr_code_scanner_outlined, color: Colors.amber),
|
||||
onPressed: () async {
|
||||
final code = await Navigator.push<String>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const Scaffold(body: Center(child: Text('Scan'))),
|
||||
),
|
||||
);
|
||||
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,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Harga Umum',
|
||||
prefixText: 'Rp ',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: priceReseller,
|
||||
focusNode: focusNodes[3],
|
||||
keyboardType: TextInputType.number,
|
||||
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,
|
||||
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 = (price.text.isEmpty ? 0 : double.tryParse(price.text) ?? 0);
|
||||
final prR = (priceReseller.text.isEmpty ? pr : double.tryParse(priceReseller.text) ?? pr);
|
||||
final st = int.tryParse(stock.text) ?? 0;
|
||||
final ms = int.tryParse(minStockCtrl.text) ?? 5;
|
||||
final hp = (hargaPokokCtrl.text.isEmpty ? 0 : double.tryParse(hargaPokokCtrl.text) ?? 0);
|
||||
if (n.isEmpty || pr <= 0) return;
|
||||
final err = 'OK'; // 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: Colors.green,
|
||||
),
|
||||
);
|
||||
Navigator.pop(sheetContext);
|
||||
} else {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Gagal menambah produk: $err'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Tambah Produk'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user