Bersihkan 18 file sampah AI + aturan gitignore file bantu AI (taruh di tmp/)

This commit is contained in:
Onie
2026-10-18 11:48:58 +07:00
parent 86be211beb
commit bbdb7b7b20
22 changed files with 49 additions and 665 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

38
PRD.md
View File

@@ -2,6 +2,18 @@
## Changelog ## Changelog
### 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).
- 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 +505,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,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

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

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