22 Commits
v1.2.0 ... main

Author SHA1 Message Date
Onie
91aa662fcb PRD: catat rilis v1.3.0+7 + indikator status sinkron offline 2026-10-18 20:29:51 +07:00
Onie
22b4cecad8 Bump version to 1.3.0+7 for release 2026-10-18 20:29:00 +07:00
Onie
903eb9fc42 Offline sync: indikator status sinkron di Riwayat + retry antrean otomatis 2026-10-18 20:21:40 +07:00
Onie
e557a82faa PRD: catat perbaikan sinkronisasi antrean transaksi offline 2026-10-18 17:39:59 +07:00
Onie
67b4d934a4 Offline queue: retry kirim setelah refresh/poll sukses + verifikasi server reachable + log gagal sinkron + izin INTERNET/ACCESS_NETWORK_STATE Android 2026-10-18 17:39:38 +07:00
Onie
8a6541e55f PRD: catat indikator loading produk kasir + empty/error state 2026-10-18 16:31:15 +07:00
Onie
0174ca4f64 Kasir: indikator loading produk + empty/error state + tombol Coba lagi, refresh retry, login tanpa loading global 2026-10-18 16:30:44 +07:00
Onie
aba36f5c60 PRD: catat auth flow lengkap (AuthGate route sessionExpired + bedakan auth mati vs network blip) 2026-10-18 15:47:54 +07:00
Onie
bcea28fcc4 Sempurnakan auth flow: AuthGate route ke LoginScreen saat sessionExpired, _ensureSessionAndAuth bedakan auth mati vs network blip, refresh() stop query saat auth mati, login() reset sessionExpired + muat data langsung 2026-10-18 15:46:46 +07:00
Onie
9e6b610549 PRD: catat cleanup analyzer 20->0 issue 2026-10-18 15:20:14 +07:00
Onie
a52e0ea9b1 Bersihkan semua 20 analyzer issue: hapus _initAuth/_ProductCard/dead code, rename underscore var, print->debugPrint, perbaiki BuildContext async guard (analyze 0 issue) 2026-10-18 15:16:14 +07:00
Onie
a3629a7d82 PRD + rule.md: update fix produk hilang kasir 2026-10-18 12:57:50 +07:00
Onie
06b640dda5 Kasir: refresh() pakai _ensureSessionAndAuth 2026-10-18 12:43:52 +07:00
Onie
da58b3aa56 Kasir: non-blocking auth check + tampilkan login ulang saat session mati 2026-10-18 12:41:56 +07:00
Onie
b968e84152 Fix produk hilang: auth session expired state + non-overwriting products load 2026-10-18 12:29:24 +07:00
Onie
2b7872ddbb Fix produk hilang: _loadProducts try-catch + auto refreshSession sebelum refresh 2026-10-18 12:15:28 +07:00
Onie
bbdb7b7b20 Bersihkan 18 file sampah AI + aturan gitignore file bantu AI (taruh di tmp/) 2026-10-18 11:48:58 +07:00
Onie
86be211beb v1.3.0+6: list kasir + toggle grid, notifikasi admin, badge riwayat, fix muat lebih banyak 2026-10-18 03:46:11 +07:00
Onie
f99324b851 Fix tombol Muat lebih banyak: cek filteredSales.length bukan state.sales (negatif -17) 2026-10-18 03:39:03 +07:00
Onie
812fb13eca Badge unseenSalesCount di tab Riwayat (admin only), reset saat tab dibuka 2026-10-18 03:17:25 +07:00
Onie
808992f9bd Notifikasi transaksi: hanya untuk admin + transaksi dari orang lain (bukan transaksi sendiri) 2026-10-18 03:06:23 +07:00
Onie
ab26487882 Kasir opsi A: default list + toggle grid/list, tap tambah qty berkali-kali, highlight hijau sesaat, tombol keranjang + badge 2026-10-18 02:23:07 +07:00
31 changed files with 905 additions and 933 deletions

11
.gitignore vendored
View File

@@ -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
View File

@@ -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)

View File

@@ -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" />

View File

@@ -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!')

View File

@@ -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!')

View File

@@ -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}')

View File

@@ -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}')

View File

@@ -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}')

View File

@@ -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()}')

View File

@@ -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}')

View File

@@ -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()}')

View File

@@ -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}')

View File

@@ -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()}')

View File

@@ -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!')

View File

@@ -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!')

View File

@@ -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')

View File

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

View File

@@ -1,13 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'config.dart'; import 'config.dart';
import 'models/sale.dart';
import 'state/app_state.dart'; import 'state/app_state.dart';
import 'services/printer_service.dart'; import 'services/printer_service.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/home_page.dart'; import 'screens/home_page.dart';
import 'utils/colors.dart'; import 'utils/colors.dart';
import 'utils/format.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -25,12 +28,48 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> with WidgetsBindingObserver { class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
late final AppState _appState; late final AppState _appState;
final GlobalKey<ScaffoldMessengerState> _scaffoldKey =
GlobalKey<ScaffoldMessengerState>();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_appState = AppState(); _appState = AppState();
WidgetsBinding.instance.addObserver(this); 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 @override
@@ -54,6 +93,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
return ChangeNotifierProvider.value( return ChangeNotifierProvider.value(
value: _appState, value: _appState,
child: MaterialApp( child: MaterialApp(
scaffoldMessengerKey: _scaffoldKey,
title: 'Dutra Store', title: 'Dutra Store',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
@@ -151,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();
},
);
}, },
); );
} }

View File

@@ -35,7 +35,7 @@ class _HomePageState extends State<HomePage> {
return screens; return screens;
} }
List<BottomNavigationBarItem> _navItemsForRole(String? role) { List<BottomNavigationBarItem> _navItemsForRole(String? role, AppState state) {
final items = <BottomNavigationBarItem>[ final items = <BottomNavigationBarItem>[
const BottomNavigationBarItem( const BottomNavigationBarItem(
icon: Icon(Icons.point_of_sale), icon: Icon(Icons.point_of_sale),
@@ -48,8 +48,12 @@ class _HomePageState extends State<HomePage> {
icon: Icon(Icons.inventory_2), icon: Icon(Icons.inventory_2),
label: 'Produk', label: 'Produk',
), ),
const BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.receipt_long), icon: Badge(
isLabelVisible: state.unseenSalesCount > 0,
label: Text('${state.unseenSalesCount}'),
child: const Icon(Icons.receipt_long),
),
label: 'Riwayat', label: 'Riwayat',
), ),
const BottomNavigationBarItem( const BottomNavigationBarItem(
@@ -76,7 +80,7 @@ class _HomePageState extends State<HomePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = context.watch<AppState>(); final state = context.watch<AppState>();
final screens = _screensForRole(state.userRole); final screens = _screensForRole(state.userRole);
final navItems = _navItemsForRole(state.userRole); final navItems = _navItemsForRole(state.userRole, state);
final safeIndex = _index >= screens.length ? 0 : _index; final safeIndex = _index >= screens.length ? 0 : _index;
@@ -109,7 +113,14 @@ class _HomePageState extends State<HomePage> {
bottomNavigationBar: navItems.length >= 2 bottomNavigationBar: navItems.length >= 2
? BottomNavigationBar( ? BottomNavigationBar(
currentIndex: safeIndex, 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, items: navItems,
) )
: null, : null,

View File

@@ -23,14 +23,26 @@ class KasirScreen extends StatefulWidget {
class _KasirScreenState extends State<KasirScreen> { class _KasirScreenState extends State<KasirScreen> {
final TextEditingController _search = TextEditingController(); final TextEditingController _search = TextEditingController();
Timer? _searchDebounce; Timer? _searchDebounce;
bool _isGrid = false; // default: list (foto lebih jelas)
String? _lastAddedId;
Timer? _lastAddedTimer;
@override @override
void dispose() { void dispose() {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
_lastAddedTimer?.cancel();
_search.dispose(); _search.dispose();
super.dispose(); super.dispose();
} }
void _markAdded(String id) {
_lastAddedTimer?.cancel();
setState(() => _lastAddedId = id);
_lastAddedTimer = Timer(const Duration(milliseconds: 900), () {
if (mounted) setState(() => _lastAddedId = null);
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = context.watch<AppState>(); final state = context.watch<AppState>();
@@ -44,12 +56,16 @@ 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: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 6), padding: const EdgeInsets.fromLTRB(12, 12, 12, 6),
child: TextField( child: Row(
children: [
Expanded(
child: TextField(
controller: _search, controller: _search,
onChanged: (_) { onChanged: (_) {
_searchDebounce?.cancel(); _searchDebounce?.cancel();
@@ -71,6 +87,61 @@ class _KasirScreenState extends State<KasirScreen> {
) )
: null, : 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( Padding(
@@ -85,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(
@@ -131,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),
@@ -142,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;
@@ -156,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'),
), ),
), ),
], ],
@@ -177,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']}'),
@@ -218,52 +284,76 @@ class _KasirScreenState extends State<KasirScreen> {
), ),
), ),
Expanded( Expanded(
child: products.isEmpty child: isInitialProductsLoading
? Center( ? const _ProductsLoading()
child: Column( : products.isEmpty
mainAxisSize: MainAxisSize.min, ? state.productsError != null
children: const [ ? _EmptyState(
Icon(Icons.search_off, size: 48, color: kMuted), icon: Icons.cloud_off_outlined,
SizedBox(height: 8), title: 'Gagal memuat produk',
Text('Produk tidak ditemukan', subtitle:
style: TextStyle(color: kMuted)), '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,
), ),
) itemCount: products.length,
: RefreshIndicator( itemBuilder: (context, i) {
onRefresh: () async { final p = products[i];
context.read<AppState>().recordManualRefresh(); // Hijau sesaat hanya untuk produk yang baru ditap,
await context.read<AppState>().refresh(); // BUKAN semua isi keranjang — tap selalu tambah qty.
}, final highlight = _lastAddedId == p.id;
color: kAccent, return _KasirCard(
child: GridView.builder( key: ValueKey(p.id),
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12), product: p,
gridDelegate: price: isReseller ? p.priceReseller : p.price,
const SliverGridDelegateWithFixedCrossAxisCount( inCart: highlight,
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,
onTap: () { onTap: () {
HapticFeedback.lightImpact(); state.addToCart(p);
state.addToCart(products[i]); _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), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row( child: Row(
children: [ children: [
_KasirAvatar(product: line.product, radius: 18), _KasirAvatar(product: line.product, size: 36, radius: 18),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( 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 Product product;
final double displayPrice; final double size;
final bool isReseller; final double? radius;
final VoidCallback onTap; const _KasirAvatar({required this.product, this.size = 56, this.radius});
const _ProductCard({
required this.product,
required this.displayPrice,
required this.isReseller,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { 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( class _KasirInitial extends StatelessWidget {
absorbing: out, final Product product;
child: Opacity( final double size;
opacity: out ? 0.5 : 1, final double radius;
child: InkWell( const _KasirInitial(
onTap: out ? null : onTap, {required this.product, required this.size, this.radius = 12});
borderRadius: BorderRadius.circular(kRadius),
child: Container( @override
padding: const EdgeInsets.all(14), Widget build(BuildContext context) {
decoration: BoxDecoration( final initial =
color: kSurface2, product.name.isNotEmpty ? product.name[0].toUpperCase() : '?';
borderRadius: BorderRadius.circular(kRadius), return Container(
border: Border.all(color: kBorder), width: size,
boxShadow: const [ height: size,
BoxShadow( decoration: BoxDecoration(
color: Colors.black26, color: productAccent(product.name).withValues(alpha: 0.16),
blurRadius: 8, borderRadius: BorderRadius.circular(radius),
offset: Offset(0, 4), ),
), alignment: Alignment.center,
], child: Text(
), initial,
child: Column( style: TextStyle(
crossAxisAlignment: CrossAxisAlignment.start, color: productAccent(product.name),
children: [ fontWeight: FontWeight.bold,
Row( fontSize: size * 0.42,
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)),
),
],
],
),
],
),
),
), ),
), ),
); );
} }
} }
/// Avatar produk di kasir: foto jika ada, fallback huruf pertama nama. // Kartu grid kasir.
class _KasirAvatar extends StatelessWidget { class _KasirCard extends StatelessWidget {
final Product product; final Product product;
final double radius; final double price;
const _KasirAvatar({required this.product, this.radius = 18}); final bool inCart;
final VoidCallback onTap;
const _KasirCard({
super.key,
required this.product,
required this.price,
required this.inCart,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final url = productImageUrl(product.imagePath); return GestureDetector(
return CircleAvatar( onTap: onTap,
radius: radius, child: Container(
backgroundColor: kAccent.withValues(alpha: 0.15), padding: const EdgeInsets.all(10),
backgroundImage: url != null ? NetworkImage(url) : null, decoration: BoxDecoration(
child: url == null color: inCart ? kSuccess.withValues(alpha: 0.12) : kSurface,
? Text( borderRadius: BorderRadius.circular(kRadius),
product.name.isNotEmpty border: Border.all(
? product.name[0].toUpperCase() color: inCart ? kSuccess : kBorder, width: inCart ? 1.5 : 1),
: '?', ),
style: TextStyle( child: Column(
color: kAccent, crossAxisAlignment: CrossAxisAlignment.start,
fontWeight: FontWeight.w700, children: [
fontSize: radius * 0.75), Row(
) children: [
: null, _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 { class _QtyButton extends StatelessWidget {
final IconData icon; final IconData icon;
final VoidCallback onTap; 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,
),
),
),
),
],
),
);
},
);
}
}

View File

@@ -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,

View File

@@ -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();
} }
@@ -395,7 +395,10 @@ class _RiwayatScreenState extends State<RiwayatScreen> {
}, },
color: kAccent, color: kAccent,
child: ListView.separated( child: ListView.separated(
itemCount: displayedSales.length + (state.hasMoreSales ? 1 : 0), itemCount: displayedSales.length +
(filteredSales.length > state.visibleSalesCount
? 1
: 0),
separatorBuilder: (_, __) => separatorBuilder: (_, __) =>
const SizedBox(height: 10), const SizedBox(height: 10),
itemBuilder: (context, i) { 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('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'
@@ -688,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';
@@ -781,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),
@@ -792,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'),
), ),
), ),
], ],
@@ -827,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
@@ -862,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(
@@ -876,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),
@@ -887,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'),
), ),
), ),
], ],
@@ -906,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(

View File

@@ -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;
} }

View File

@@ -27,6 +27,24 @@ class CartLine {
class AppState extends ChangeNotifier { class AppState extends ChangeNotifier {
final SupabaseClient _supabase = Supabase.instance.client; 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). // 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);
@@ -53,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;
@@ -93,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;
@@ -135,16 +162,101 @@ class AppState extends ChangeNotifier {
if (elapsed < const Duration(seconds: 15)) return; if (elapsed < const Duration(seconds: 15)) return;
} }
await refresh(); await refresh();
_checkNewSales();
} }
Future<void> refresh() async { bool _salesFirstLoad = true;
await _loadProducts();
await _loadSales(); /// Deteksi transaksi baru dari polling/refresh dan kirim ke stream
notifyListeners(); /// 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(); 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);
@@ -157,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();
} }
@@ -193,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 {
@@ -243,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();
} }
} }
@@ -282,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();
} }
@@ -420,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();
}); });
@@ -452,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 {
@@ -686,6 +854,11 @@ class AppState extends ChangeNotifier {
cart.clear(); cart.clear();
_discount = 0; _discount = 0;
notifyListeners(); 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; return sale;
} catch (e) { } catch (e) {
await _offlineQueue.addToQueue({ await _offlineQueue.addToQueue({

BIN
logo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

BIN
logo2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -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.2.0+5 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

View File

@@ -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);
});
}

View File

@@ -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')),
],
),
),
),
],
),
);
},
);
}

View File

@@ -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'),
),
),
],
),
),
],
),
);
},
);
}