Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91aa662fcb | ||
|
|
22b4cecad8 | ||
|
|
903eb9fc42 | ||
|
|
e557a82faa | ||
|
|
67b4d934a4 | ||
|
|
8a6541e55f | ||
|
|
0174ca4f64 | ||
|
|
aba36f5c60 | ||
|
|
bcea28fcc4 | ||
|
|
9e6b610549 | ||
|
|
a52e0ea9b1 | ||
|
|
a3629a7d82 | ||
|
|
06b640dda5 | ||
|
|
da58b3aa56 | ||
|
|
b968e84152 | ||
|
|
2b7872ddbb | ||
|
|
bbdb7b7b20 |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -37,6 +37,17 @@ migrate_working_dir/
|
|||||||
lib/config.dart
|
lib/config.dart
|
||||||
supabse.txt
|
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
|
# Symbolication related
|
||||||
app.*.symbols
|
app.*.symbols
|
||||||
|
|
||||||
|
|||||||
51
PRD.md
51
PRD.md
@@ -2,6 +2,31 @@
|
|||||||
|
|
||||||
## Changelog
|
## 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
|
### v1.1.0 — 2026-07-24
|
||||||
- Dialog pembayaran kasir: tombol "Selesai & Cetak" menampilkan loading state (spinner + "Memproses...") dengan guard anti-doble-klik.
|
- 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.
|
- 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.
|
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.
|
- 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.
|
- 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">
|
<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 -->
|
<!-- Camera permission for barcode scanning -->
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<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')
|
|
||||||
@@ -191,25 +191,25 @@ class AuthGate extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Selector<AppState, bool>(
|
return Selector<AppState, ({bool loading, bool loggedIn, bool expired})>(
|
||||||
selector: (context, state) => state.loading,
|
selector: (context, state) => (
|
||||||
builder: (context, loading, child) {
|
loading: state.loading,
|
||||||
if (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(
|
return const Scaffold(
|
||||||
body: Center(
|
body: Center(child: CircularProgressIndicator(color: kAccent)),
|
||||||
child: CircularProgressIndicator(color: kAccent),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Selector<AppState, bool>(
|
if (a.loggedIn && !a.expired) {
|
||||||
selector: (context, state) => state.isLoggedIn,
|
return const HomePage();
|
||||||
builder: (context, loggedIn, child) {
|
}
|
||||||
if (loggedIn) {
|
return const LoginScreen();
|
||||||
return const HomePage();
|
|
||||||
}
|
|
||||||
return const LoginScreen();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class _KasirScreenState extends State<KasirScreen> {
|
|||||||
p.name.toLowerCase().contains(query) ||
|
p.name.toLowerCase().contains(query) ||
|
||||||
p.barcode.toLowerCase().contains(query))
|
p.barcode.toLowerCase().contains(query))
|
||||||
.toList();
|
.toList();
|
||||||
|
final isInitialProductsLoading = state.productsLoading && !state.dataReady;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -155,7 +156,6 @@ class _KasirScreenState extends State<KasirScreen> {
|
|||||||
final result = await showDialog<Map<String, dynamic>>(
|
final result = await showDialog<Map<String, dynamic>>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
bool submitting = false;
|
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setDialog) {
|
builder: (context, setDialog) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
@@ -201,7 +201,7 @@ class _KasirScreenState extends State<KasirScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: submitting ? null : () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: kMuted,
|
foregroundColor: kMuted,
|
||||||
side: const BorderSide(color: kBorder),
|
side: const BorderSide(color: kBorder),
|
||||||
@@ -212,9 +212,7 @@ class _KasirScreenState extends State<KasirScreen> {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: submitting
|
onPressed: () {
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
final name = nameCtrl.text.trim();
|
final name = nameCtrl.text.trim();
|
||||||
final price = (RupiahInputFormatter.getRawValue(priceCtrl.text) ?? 0).toDouble();
|
final price = (RupiahInputFormatter.getRawValue(priceCtrl.text) ?? 0).toDouble();
|
||||||
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
|
final qty = int.tryParse(qtyCtrl.text.trim()) ?? 1;
|
||||||
@@ -226,9 +224,7 @@ class _KasirScreenState extends State<KasirScreen> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
style: FilledButton.styleFrom(backgroundColor: kAccent, foregroundColor: const Color(0xFF06210F)),
|
style: FilledButton.styleFrom(backgroundColor: kAccent, foregroundColor: const Color(0xFF06210F)),
|
||||||
child: submitting
|
child: const Text('Tambah'),
|
||||||
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F)))
|
|
||||||
: const Text('Tambah'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -247,7 +243,7 @@ class _KasirScreenState extends State<KasirScreen> {
|
|||||||
for (int i = 0; i < (result['qty'] as int); i++) {
|
for (int i = 0; i < (result['qty'] as int); i++) {
|
||||||
state.addToCart(manualProduct);
|
state.addToCart(manualProduct);
|
||||||
}
|
}
|
||||||
if (mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Pesanan manual ditambahkan: ${result['name']}'),
|
content: Text('Pesanan manual ditambahkan: ${result['name']}'),
|
||||||
@@ -288,8 +284,30 @@ class _KasirScreenState extends State<KasirScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _isGrid
|
child: isInitialProductsLoading
|
||||||
? GridView.builder(
|
? 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),
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
|
||||||
gridDelegate:
|
gridDelegate:
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
@@ -1748,125 +1766,6 @@ class _KasirListTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProductCard 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,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final out = product.stock <= 0;
|
|
||||||
|
|
||||||
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, size: 36, 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 _QtyButton extends StatelessWidget {
|
class _QtyButton extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
@@ -1886,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(),
|
||||||
FocusNode(),
|
FocusNode(),
|
||||||
];
|
];
|
||||||
bool _listenersAdded = false;
|
bool listenersAdded = false;
|
||||||
final _sheetController = DraggableScrollableController();
|
final sheetController = DraggableScrollableController();
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (sheetContext) => DraggableScrollableSheet(
|
builder: (sheetContext) => DraggableScrollableSheet(
|
||||||
controller: _sheetController,
|
controller: sheetController,
|
||||||
initialChildSize: 0.65,
|
initialChildSize: 0.65,
|
||||||
minChildSize: 0.5,
|
minChildSize: 0.5,
|
||||||
maxChildSize: 0.92,
|
maxChildSize: 0.92,
|
||||||
builder: (_, scrollController) {
|
builder: (_, scrollController) {
|
||||||
if (!_listenersAdded) {
|
if (!listenersAdded) {
|
||||||
_listenersAdded = true;
|
listenersAdded = true;
|
||||||
for (final node in focusNodes) {
|
for (final node in focusNodes) {
|
||||||
node.addListener(() {
|
node.addListener(() {
|
||||||
if (node.hasFocus && context.mounted) {
|
if (node.hasFocus && context.mounted) {
|
||||||
_sheetController.animateTo(
|
sheetController.animateTo(
|
||||||
0.92,
|
0.92,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
@@ -606,8 +606,8 @@ void _showEditDialog(BuildContext context, AppState state, Product product) {
|
|||||||
FocusNode(),
|
FocusNode(),
|
||||||
FocusNode(),
|
FocusNode(),
|
||||||
];
|
];
|
||||||
bool _listenersAdded = false;
|
bool listenersAdded = false;
|
||||||
final _sheetController = DraggableScrollableController();
|
final sheetController = DraggableScrollableController();
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -616,17 +616,17 @@ void _showEditDialog(BuildContext context, AppState state, Product product) {
|
|||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
controller: _sheetController,
|
controller: sheetController,
|
||||||
initialChildSize: 0.65,
|
initialChildSize: 0.65,
|
||||||
minChildSize: 0.5,
|
minChildSize: 0.5,
|
||||||
maxChildSize: 0.92,
|
maxChildSize: 0.92,
|
||||||
builder: (_, scrollController) {
|
builder: (_, scrollController) {
|
||||||
if (!_listenersAdded) {
|
if (!listenersAdded) {
|
||||||
_listenersAdded = true;
|
listenersAdded = true;
|
||||||
for (final node in focusNodes) {
|
for (final node in focusNodes) {
|
||||||
node.addListener(() {
|
node.addListener(() {
|
||||||
if (node.hasFocus && context.mounted) {
|
if (node.hasFocus && context.mounted) {
|
||||||
_sheetController.animateTo(
|
sheetController.animateTo(
|
||||||
0.92,
|
0.92,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
|||||||
lastDate: DateTime.now(),
|
lastDate: DateTime.now(),
|
||||||
initialDateRange: _dateRange,
|
initialDateRange: _dateRange,
|
||||||
);
|
);
|
||||||
if (mounted && picked != null) {
|
if (picked != null && context.mounted) {
|
||||||
setState(() => _dateRange = picked);
|
setState(() => _dateRange = picked);
|
||||||
context.read<AppState>().resetPagination();
|
context.read<AppState>().resetPagination();
|
||||||
}
|
}
|
||||||
@@ -469,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -633,6 +655,8 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
|||||||
_receiptRow('Kembali', formatRupiah(currentSale.change.clamp(0, double.infinity)), false),
|
_receiptRow('Kembali', formatRupiah(currentSale.change.clamp(0, double.infinity)), false),
|
||||||
_receiptRow('Metode', _paymentMethodLabel(currentSale.paymentMethod), false),
|
_receiptRow('Metode', _paymentMethodLabel(currentSale.paymentMethod), false),
|
||||||
_receiptRow('Status', currentSale.paymentStatus == 'paid' ? 'Lunas' : 'Belum Lunas', 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)
|
if (currentSale.splitPayment != null && currentSale.splitPayment!.isNotEmpty)
|
||||||
...currentSale.splitPayment!.entries.map((e) {
|
...currentSale.splitPayment!.entries.map((e) {
|
||||||
final label = e.key == 'tunai'
|
final label = e.key == 'tunai'
|
||||||
@@ -691,7 +715,6 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
|||||||
final result = await showDialog<double>(
|
final result = await showDialog<double>(
|
||||||
context: parentContext,
|
context: parentContext,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
bool submitting = false;
|
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setDialog) {
|
builder: (context, setDialog) {
|
||||||
final isUnpaid = currentSale.paymentStatus == 'unpaid';
|
final isUnpaid = currentSale.paymentStatus == 'unpaid';
|
||||||
@@ -784,7 +807,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: submitting ? null : () => Navigator.pop(ctx),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: kMuted,
|
foregroundColor: kMuted,
|
||||||
side: const BorderSide(color: kBorder),
|
side: const BorderSide(color: kBorder),
|
||||||
@@ -795,17 +818,13 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: submitting
|
onPressed: () {
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
final val = (RupiahInputFormatter.getRawValue(payCtrl.text) ?? 0).toDouble();
|
final val = (RupiahInputFormatter.getRawValue(payCtrl.text) ?? 0).toDouble();
|
||||||
if (val <= 0) return;
|
if (val <= 0) return;
|
||||||
Navigator.pop(ctx, val);
|
Navigator.pop(ctx, val);
|
||||||
},
|
},
|
||||||
style: FilledButton.styleFrom(backgroundColor: kSuccess, foregroundColor: const Color(0xFF06210F)),
|
style: FilledButton.styleFrom(backgroundColor: kSuccess, foregroundColor: const Color(0xFF06210F)),
|
||||||
child: submitting
|
child: const Text('Simpan'),
|
||||||
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF06210F)))
|
|
||||||
: const Text('Simpan'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -830,7 +849,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
|||||||
if (sheetContext.mounted) {
|
if (sheetContext.mounted) {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
}
|
}
|
||||||
if (mounted) {
|
if (parentContext.mounted) {
|
||||||
final messenger = ScaffoldMessenger.of(parentContext);
|
final messenger = ScaffoldMessenger.of(parentContext);
|
||||||
final msg = err == null
|
final msg = err == null
|
||||||
? isNowPaid
|
? isNowPaid
|
||||||
@@ -865,7 +884,6 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
|||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
context: parentContext,
|
context: parentContext,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
bool cancelling = false;
|
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (context, setDialog) {
|
builder: (context, setDialog) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
@@ -879,7 +897,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: cancelling ? null : () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: kMuted,
|
foregroundColor: kMuted,
|
||||||
side: const BorderSide(color: kBorder),
|
side: const BorderSide(color: kBorder),
|
||||||
@@ -890,13 +908,9 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: cancelling
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
? null
|
|
||||||
: () => Navigator.pop(ctx, true),
|
|
||||||
style: FilledButton.styleFrom(backgroundColor: kDanger),
|
style: FilledButton.styleFrom(backgroundColor: kDanger),
|
||||||
child: cancelling
|
child: const Text('Batalkan'),
|
||||||
? const SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
|
||||||
: const Text('Batalkan'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -909,7 +923,7 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
|
|||||||
);
|
);
|
||||||
if (confirm == true && mounted) {
|
if (confirm == true && mounted) {
|
||||||
final err = await state.cancelSale(currentSale.id);
|
final err = await state.cancelSale(currentSale.id);
|
||||||
if (mounted) {
|
if (sheetContext.mounted) {
|
||||||
Navigator.pop(sheetContext);
|
Navigator.pop(sheetContext);
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
@@ -24,7 +25,7 @@ class OfflineQueueService {
|
|||||||
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
|
onTimeout: () => throw TimeoutException('${operation ?? 'Operasi'} timeout setelah $_timeout'),
|
||||||
);
|
);
|
||||||
} on TimeoutException catch (e) {
|
} on TimeoutException catch (e) {
|
||||||
print('OfflineQueue timeout: $e');
|
debugPrint('OfflineQueue timeout: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,11 +34,35 @@ class OfflineQueueService {
|
|||||||
bool get isOnline => _isOnline;
|
bool get isOnline => _isOnline;
|
||||||
bool get hasPendingItems => _queue.isNotEmpty;
|
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 {
|
Future<void> init() async {
|
||||||
await _loadQueue();
|
await _loadQueue();
|
||||||
_connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
|
_connectivity.onConnectivityChanged.listen((result) {
|
||||||
|
_updateConnectionStatus(result);
|
||||||
|
if (_isOnline && _queue.isNotEmpty) {
|
||||||
|
_processQueue().then((_) => retryNow());
|
||||||
|
}
|
||||||
|
});
|
||||||
await _checkConnection();
|
await _checkConnection();
|
||||||
if (_isOnline) await _processQueue();
|
if (_isOnline) {
|
||||||
|
await retryNow();
|
||||||
|
retryNow();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadQueue() async {
|
Future<void> _loadQueue() async {
|
||||||
@@ -59,10 +84,31 @@ class OfflineQueueService {
|
|||||||
_updateConnectionStatus(result);
|
_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) {
|
void _updateConnectionStatus(List<ConnectivityResult> result) {
|
||||||
_isOnline = result.any((r) => r != ConnectivityResult.none);
|
_isOnline = result.any((r) => r != ConnectivityResult.none);
|
||||||
if (_isOnline && _queue.isNotEmpty) {
|
if (_isOnline && _queue.isNotEmpty) {
|
||||||
_processQueue();
|
_processQueue().then((_) => retryNow());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +116,7 @@ class OfflineQueueService {
|
|||||||
_queue.add(transaction);
|
_queue.add(transaction);
|
||||||
await _saveQueue();
|
await _saveQueue();
|
||||||
if (_isOnline) {
|
if (_isOnline) {
|
||||||
await _processQueue();
|
await _processQueue().then((_) => retryNow());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +131,7 @@ class OfflineQueueService {
|
|||||||
_queue.removeAt(0);
|
_queue.removeAt(0);
|
||||||
await _saveQueue();
|
await _saveQueue();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
debugPrint('OfflineQueue gagal sinkron item ${item['sale']?['id']}: $e');
|
||||||
_syncing = false;
|
_syncing = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class AppState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> retryProducts() => refresh();
|
||||||
|
|
||||||
// Client dengan service_role key untuk operasi admin (buat/edit/hapus akun kasir).
|
// 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.
|
// PERINGATAN: key ini bypass RLS — hanya untuk app POS di device terpercaya.
|
||||||
final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey);
|
final SupabaseClient _admin = SupabaseClient(supabaseUrl, supabaseServiceKey);
|
||||||
@@ -69,6 +71,9 @@ class AppState extends ChangeNotifier {
|
|||||||
List<Map<String, dynamic>> kasirAccounts = [];
|
List<Map<String, dynamic>> kasirAccounts = [];
|
||||||
bool loading = true;
|
bool loading = true;
|
||||||
String? error;
|
String? error;
|
||||||
|
bool dataReady = false;
|
||||||
|
bool productsLoading = false;
|
||||||
|
String? productsError;
|
||||||
|
|
||||||
static const int _pageSize = 30;
|
static const int _pageSize = 30;
|
||||||
int _visibleProductCount = _pageSize;
|
int _visibleProductCount = _pageSize;
|
||||||
@@ -109,6 +114,12 @@ class AppState extends ChangeNotifier {
|
|||||||
String? userId;
|
String? userId;
|
||||||
String? userRole;
|
String? userRole;
|
||||||
UserProfile? _profile;
|
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 = {};
|
final Map<String, String> _profileNames = {};
|
||||||
|
|
||||||
double _discount = 0;
|
double _discount = 0;
|
||||||
@@ -190,14 +201,62 @@ class AppState extends ChangeNotifier {
|
|||||||
_salesFirstLoad = false;
|
_salesFirstLoad = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
/// Refresh produk + riwayat dengan retry terbatas untuk kegagalan sementara.
|
||||||
await _loadProducts();
|
/// Nyalakan indikator loading hanya saat data pertama belum siap, supaya
|
||||||
await _loadSales();
|
/// Kasir bisa menampilkan status memuat/gagal/kosong dengan jelas.
|
||||||
notifyListeners();
|
/// 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();
|
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 =>
|
double get subtotal =>
|
||||||
cart.fold(0, (sum, line) => sum + line.price * line.qty);
|
cart.fold(0, (sum, line) => sum + line.price * line.qty);
|
||||||
|
|
||||||
@@ -210,16 +269,27 @@ class AppState extends ChangeNotifier {
|
|||||||
/* ---------- Init & Realtime ---------- */
|
/* ---------- Init & Realtime ---------- */
|
||||||
Future<void> _init() async {
|
Future<void> _init() async {
|
||||||
try {
|
try {
|
||||||
await _initAuth();
|
final authOk = await _ensureSessionAndAuth();
|
||||||
await _loadProducts();
|
if (!authOk) {
|
||||||
await _loadSales();
|
// Session mati total (refresh token ikut kadaluwarsa). Jangan lanjut:
|
||||||
await loadKasirAccounts();
|
// _loadProducts() akan timeout 30s + 401, bakal menjebak loading walau
|
||||||
await _loadProfileNames();
|
// produk sendiri masih ada. Tandai sessionExpired, jalankan polling
|
||||||
await _loadSettings();
|
// saja (real-time masih bisa update), dan biarkan UI tampil
|
||||||
_subscribe();
|
// "login ulang".
|
||||||
await _seedAdminIfEmpty();
|
_sessionExpired = true;
|
||||||
await _offlineQueue.init();
|
startPolling();
|
||||||
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) {
|
} catch (e) {
|
||||||
error = e.toString();
|
error = e.toString();
|
||||||
}
|
}
|
||||||
@@ -246,14 +316,6 @@ class AppState extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initAuth() async {
|
|
||||||
final session = _supabase.auth.currentSession;
|
|
||||||
if (session != null) {
|
|
||||||
userId = session.user.id;
|
|
||||||
await _loadProfile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadProfile() async {
|
Future<void> _loadProfile() async {
|
||||||
if (userId == null) return;
|
if (userId == null) return;
|
||||||
try {
|
try {
|
||||||
@@ -296,12 +358,21 @@ class AppState extends ChangeNotifier {
|
|||||||
'Login',
|
'Login',
|
||||||
);
|
);
|
||||||
userId = res.user?.id;
|
userId = res.user?.id;
|
||||||
await _loadProfile();
|
_sessionExpired = false;
|
||||||
|
dataReady = false;
|
||||||
|
productsLoading = true;
|
||||||
|
productsError = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _loadProfile();
|
||||||
|
await refresh();
|
||||||
return null;
|
return null;
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
|
productsLoading = false;
|
||||||
|
notifyListeners();
|
||||||
return e.message;
|
return e.message;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
productsLoading = false;
|
||||||
|
notifyListeners();
|
||||||
return e.toString();
|
return e.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,6 +406,10 @@ class AppState extends ChangeNotifier {
|
|||||||
_profile = null;
|
_profile = null;
|
||||||
cart.clear();
|
cart.clear();
|
||||||
_discount = 0;
|
_discount = 0;
|
||||||
|
products = [];
|
||||||
|
dataReady = false;
|
||||||
|
productsLoading = false;
|
||||||
|
productsError = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,11 +548,15 @@ class AppState extends ChangeNotifier {
|
|||||||
_supabase.auth.onAuthStateChange.listen((event) {
|
_supabase.auth.onAuthStateChange.listen((event) {
|
||||||
if (event.session != null) {
|
if (event.session != null) {
|
||||||
userId = event.session!.user.id;
|
userId = event.session!.user.id;
|
||||||
|
_sessionExpired = false;
|
||||||
_loadProfile();
|
_loadProfile();
|
||||||
} else {
|
} else {
|
||||||
|
// Sesi mati total (logout / refresh token ikut kedaluwarsa):
|
||||||
|
// tandai agar UI bisa menampilkan pesan "sesi habis", bukan diam.
|
||||||
userId = null;
|
userId = null;
|
||||||
userRole = null;
|
userRole = null;
|
||||||
_profile = null;
|
_profile = null;
|
||||||
|
_sessionExpired = true;
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
@@ -505,12 +584,48 @@ class AppState extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadProducts() async {
|
Future<void> _loadProducts() async {
|
||||||
final rows = await _withTimeout(
|
try {
|
||||||
_supabase.from('products').select().eq('store_id', _currentStoreId).order('name'),
|
final rows = await _withTimeout(
|
||||||
'Load products',
|
_supabase.from('products').select().eq('store_id', _currentStoreId).order('name'),
|
||||||
);
|
'Load products',
|
||||||
products = rows.map((e) => Product.fromJson(e)).toList();
|
);
|
||||||
if (products.isEmpty) _seed();
|
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 {
|
Future<void> _loadSales() async {
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.3.0+6
|
version: 1.3.0+7
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
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