import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:provider/provider.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import '../models/product.dart'; import '../state/app_state.dart'; import '../utils/colors.dart'; import '../utils/format.dart'; import '../utils/rupiah_input.dart'; import 'scan_screen.dart'; const String kProductImageBucket = 'product-images'; /// URL publik gambar produk dari Supabase Storage (null jika tidak ada). String? productImageUrl(String? path) { if (path == null || path.isEmpty) return null; return Supabase.instance.client.storage.from(kProductImageBucket).getPublicUrl(path); } /// Pilih gambar (galeri/kamera), kompres otomatis, lalu upload ke Storage. /// Mengembalikan path file di bucket, atau null jika batal/gagal. Future pickAndUploadProductImage(BuildContext context, {ImageSource source = ImageSource.gallery}) async { final picker = ImagePicker(); try { final picked = await picker.pickImage( source: source, maxWidth: 800, maxHeight: 800, imageQuality: 80, ); if (picked == null) return null; final bytes = await picked.readAsBytes(); var ext = 'jpg'; final dot = picked.name.lastIndexOf('.'); if (dot > -1) { final raw = picked.name.substring(dot + 1).toLowerCase(); if (['jpg', 'jpeg', 'png', 'webp'].contains(raw)) ext = raw; } final path = 'prod-${DateTime.now().millisecondsSinceEpoch}.$ext'; await Supabase.instance.client.storage .from(kProductImageBucket) .uploadBinary(path, bytes, fileOptions: FileOptions(contentType: 'image/$ext', upsert: true)); return path; } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('Upload gambar gagal: $e'), backgroundColor: kDanger, )); } return null; } } /// Widget preview + tombol pilih/hapus gambar untuk form produk. Widget productImageField({ required BuildContext context, required void Function(String? path) onChanged, required String? currentPath, bool enabled = true, }) { final url = productImageUrl(currentPath); return Padding( padding: const EdgeInsets.only(bottom: 12), child: Row( children: [ Container( width: 72, height: 72, decoration: BoxDecoration( color: kAccent.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(12), border: Border.all(color: kBorder), ), clipBehavior: Clip.antiAlias, child: url != null ? Image.network(url, fit: BoxFit.cover, errorBuilder: (_, __, ___) => const Center(child: Icon(Icons.broken_image_outlined, color: kMuted, size: 26))) : const Center( child: Icon(Icons.image_outlined, color: kMuted, size: 26)), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Foto produk', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), const SizedBox(height: 2), Text( url != null ? 'Foto tersimpan' : 'Belum ada foto (opsional)', style: const TextStyle(fontSize: 11, color: kMuted), ), const SizedBox(height: 6), Wrap( spacing: 6, runSpacing: 6, children: [ ActionChip( avatar: const Icon(Icons.photo_camera_outlined, size: 16), label: const Text('Kamera', style: TextStyle(fontSize: 12)), onPressed: !enabled ? null : () async { final p = await pickAndUploadProductImage(context, source: ImageSource.camera); if (p != null) onChanged(p); }, ), ActionChip( avatar: const Icon(Icons.photo_library_outlined, size: 16), label: const Text('Galeri', style: TextStyle(fontSize: 12)), onPressed: !enabled ? null : () async { final p = await pickAndUploadProductImage(context); if (p != null) onChanged(p); }, ), if (currentPath != null && currentPath.isNotEmpty) ActionChip( avatar: const Icon(Icons.delete_outline, size: 16, color: kDanger), label: const Text('Hapus', style: TextStyle(fontSize: 12, color: kDanger)), side: const BorderSide(color: kBorder), onPressed: !enabled ? null : () { // Best-effort hapus file lama di storage Supabase.instance.client.storage .from(kProductImageBucket) .remove([currentPath]).ignore(); onChanged(null); }, ), ], ), ], ), ), ], ), ); } Future confirmResellerWarning(BuildContext context, double general, double reseller) async { if (reseller <= 0 || reseller <= general) return true; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( icon: Icon(Icons.warning_amber_rounded, color: kAccent, size: 28), title: const Text('Peringatan'), content: Text('Harga reseller (${formatRupiah(reseller)}) lebih tinggi dari harga umum (${formatRupiah(general)}).'), actions: [ SizedBox( width: double.infinity, child: OutlinedButton( onPressed: () => Navigator.pop(ctx, false), style: OutlinedButton.styleFrom( foregroundColor: kMuted, side: const BorderSide(color: kBorder), ), child: const Text('Batal'), ), ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: FilledButton( onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom(backgroundColor: kAccent), child: const Text('Tetap Simpan'), ), ), ], ), ); return confirmed ?? false; } class ProdukScreen extends StatefulWidget { const ProdukScreen({super.key}); @override State createState() => _ProdukScreenState(); } class _ProdukScreenState extends State { final TextEditingController _search = TextEditingController(); @override void initState() { super.initState(); _search.addListener(() { if (mounted) context.read().resetPagination(); }); } @override void dispose() { _search.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final state = context.watch(); if (state.loading) { return const Center(child: CircularProgressIndicator(color: kAccent)); } final query = _search.text.toLowerCase(); final filtered = state.products .where((p) => p.name.toLowerCase().contains(query) || p.barcode.toLowerCase().contains(query)) .toList(); final products = filtered.take(state.visibleProductCount).toList(); return RefreshIndicator( onRefresh: () async { context.read().recordManualRefresh(); await context.read().refresh(); }, color: kAccent, child: Stack( children: [ SingleChildScrollView( padding: const EdgeInsets.all(12), child: Column( children: [ TextField( controller: _search, onChanged: (_) => setState(() {}), style: const TextStyle(color: kText), decoration: InputDecoration( hintText: 'Cari produk...', prefixIcon: const Icon(Icons.search, color: kMuted), suffixIcon: _search.text.isNotEmpty ? IconButton( icon: const Icon(Icons.close, color: kMuted), onPressed: () { _search.clear(); setState(() {}); }, ) : null, ), ), const SizedBox(height: 12), Row( children: [ Text( 'Daftar Produk', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700), ), const Spacer(), FilledButton.icon( onPressed: () => _showAddForm(context, state), icon: const Icon(Icons.add, size: 18), label: const Text('Tambah Produk'), style: FilledButton.styleFrom( backgroundColor: kAccent, foregroundColor: const Color(0xFF06210F), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), visualDensity: VisualDensity.compact, ), ), ], ), const SizedBox(height: 12), if (products.isEmpty) const Center( child: Padding( padding: EdgeInsets.symmetric(vertical: 40), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.inventory_2_outlined, size: 48, color: kMuted), SizedBox(height: 8), Text('Belum ada produk', style: TextStyle(color: kMuted)), ], ), ), ) else ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: products.length, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (context, i) => _ProductRow(p: products[i], state: state), ), if (state.hasMoreProducts) Padding( padding: const EdgeInsets.only(top: 12), child: FilledButton.icon( onPressed: () => state.loadMoreProducts(), icon: const Icon(Icons.expand_more, size: 18), label: Text('Muat lebih banyak (${state.products.length - state.visibleProductCount} tersisa)'), ), ), ], ), ), ], ), ); } } void _showAddForm(BuildContext context, AppState state) { final name = TextEditingController(); final barcode = TextEditingController(); final price = TextEditingController(); final priceReseller = TextEditingController(); final stock = TextEditingController(); final minStockCtrl = TextEditingController(text: '5'); final hargaPokokCtrl = TextEditingController(); String? imagePath; // path gambar di Supabase Storage final focusNodes = [ FocusNode(), FocusNode(), FocusNode(), FocusNode(), FocusNode(), FocusNode(), FocusNode(), ]; bool _listenersAdded = false; final _sheetController = DraggableScrollableController(); showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (sheetContext) => DraggableScrollableSheet( controller: _sheetController, initialChildSize: 0.65, minChildSize: 0.5, maxChildSize: 0.92, builder: (_, scrollController) { if (!_listenersAdded) { _listenersAdded = true; for (final node in focusNodes) { node.addListener(() { if (node.hasFocus && context.mounted) { _sheetController.animateTo( 0.92, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); Future.delayed(const Duration(milliseconds: 350), () { if (node.context != null && node.context!.mounted) { Scrollable.ensureVisible( node.context!, duration: const Duration(milliseconds: 250), alignment: 0.0, ); } }); } }); } } return Container( decoration: const BoxDecoration( color: kSurface, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), border: Border(top: BorderSide(color: kBorder)), ), child: Column( children: [ Container( margin: const EdgeInsets.only(top: 12), width: 40, height: 4, decoration: BoxDecoration( color: kBorder, borderRadius: BorderRadius.circular(4), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Row( children: [ const Icon(Icons.add_box_outlined, color: kAccent), const SizedBox(width: 8), const Text('Tambah Produk', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w700)), const Spacer(), IconButton( icon: const Icon(Icons.close, color: kMuted), onPressed: () => Navigator.pop(sheetContext), tooltip: 'Tutup', ), ], ), ), const Divider(height: 12), Expanded( child: SingleChildScrollView( controller: scrollController, padding: EdgeInsets.fromLTRB(16, 8, 16, MediaQuery.of(context).viewInsets.bottom + 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ StatefulBuilder(builder: (ctx, setSheetState) { return productImageField( context: context, currentPath: imagePath, onChanged: (p) { imagePath = p; setSheetState(() {}); }, ); }), TextField( controller: barcode, focusNode: focusNodes[0], decoration: InputDecoration( labelText: 'Barcode / No. Produk', prefixIcon: const Icon(Icons.qr_code_2_outlined), suffixIcon: IconButton( icon: const Icon(Icons.qr_code_scanner_outlined, color: kAccent), onPressed: () async { final code = await Navigator.push( context, MaterialPageRoute( builder: (_) => const ScanScreen( title: 'Scan Barcode Produk', ), ), ); if (code != null && context.mounted) { barcode.text = code; } }, tooltip: 'Scan barcode', )), ), const SizedBox(height: 10), TextField( controller: name, focusNode: focusNodes[1], decoration: const InputDecoration( labelText: 'Nama produk', prefixIcon: Icon(Icons.label_outline)), ), const SizedBox(height: 10), Row( children: [ Expanded( child: TextField( controller: price, focusNode: focusNodes[2], keyboardType: TextInputType.number, inputFormatters: [RupiahInputFormatter()], decoration: const InputDecoration( labelText: 'Harga Umum', prefixText: 'Rp ', ), ), ), const SizedBox(width: 10), Expanded( child: TextField( controller: priceReseller, focusNode: focusNodes[3], keyboardType: TextInputType.number, inputFormatters: [RupiahInputFormatter()], decoration: const InputDecoration( labelText: 'Harga Reseller', prefixText: 'Rp ', ), ), ), ], ), const SizedBox(height: 10), TextField( controller: stock, focusNode: focusNodes[4], keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'Stok awal', prefixIcon: Icon(Icons.numbers), ), ), const SizedBox(height: 10), TextField( controller: hargaPokokCtrl, focusNode: focusNodes[5], keyboardType: TextInputType.number, inputFormatters: [RupiahInputFormatter()], decoration: const InputDecoration( labelText: 'Harga Pokok (modal)', prefixText: 'Rp ', helperText: 'Kosongkan jika tidak diketahui', ), ), const SizedBox(height: 10), TextField( controller: minStockCtrl, focusNode: focusNodes[6], keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'Stok minimum (warning)', prefixIcon: Icon(Icons.warning_outlined), helperText: 'Default: 5', ), ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () async { final n = name.text.trim(); final bc = barcode.text.trim(); final pr = (RupiahInputFormatter.getRawValue(price.text) ?? 0) .toDouble(); final prR = (RupiahInputFormatter.getRawValue(priceReseller.text) ?? pr) .toDouble(); final st = int.tryParse(stock.text) ?? 0; final ms = int.tryParse(minStockCtrl.text) ?? 5; final hp = (RupiahInputFormatter.getRawValue(hargaPokokCtrl.text) ?? 0) .toDouble(); if (n.isEmpty || pr <= 0) return; if (!await confirmResellerWarning(context, pr, prR)) return; final err = await state.addProduct(n, bc, pr, prR, st, ms, hp, imagePath: imagePath); if (!context.mounted) return; final messenger = ScaffoldMessenger.of(context); if (err == null) { name.clear(); barcode.clear(); price.clear(); priceReseller.clear(); stock.clear(); minStockCtrl.clear(); hargaPokokCtrl.clear(); messenger.showSnackBar( const SnackBar( content: Text('Produk berhasil ditambahkan'), backgroundColor: kSuccess, ), ); Navigator.pop(sheetContext); } else { messenger.showSnackBar( SnackBar( content: Text('Gagal menambah produk: '), backgroundColor: kDanger, ), ); } }, icon: const Icon(Icons.add), label: const Text('Tambah Produk'), ), ), ], ), ), ), ], ), ); }, ), ); } void _showEditDialog(BuildContext context, AppState state, Product product) { final name = TextEditingController(text: product.name); final barcode = TextEditingController(text: product.barcode); final price = TextEditingController( text: RupiahInputFormatter.formatNumber(product.price.toInt())); final priceReseller = TextEditingController( text: RupiahInputFormatter.formatNumber(product.priceReseller.toInt())); final stock = TextEditingController(text: product.stock.toString()); final minStockCtrl = TextEditingController(text: product.minStock.toString()); final hargaPokokCtrl = TextEditingController( text: product.hargaPokok > 0 ? RupiahInputFormatter.formatNumber(product.hargaPokok.toInt()) : ''); String? imagePath = product.imagePath; // path gambar saat ini (null = tidak ada) final focusNodes = [ FocusNode(), FocusNode(), FocusNode(), FocusNode(), FocusNode(), FocusNode(), FocusNode(), ]; bool _listenersAdded = false; final _sheetController = DraggableScrollableController(); showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (sheetContext) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; return DraggableScrollableSheet( controller: _sheetController, initialChildSize: 0.65, minChildSize: 0.5, maxChildSize: 0.92, builder: (_, scrollController) { if (!_listenersAdded) { _listenersAdded = true; for (final node in focusNodes) { node.addListener(() { if (node.hasFocus && context.mounted) { _sheetController.animateTo( 0.92, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); Future.delayed(const Duration(milliseconds: 350), () { if (node.context != null && node.context!.mounted) { Scrollable.ensureVisible( node.context!, duration: const Duration(milliseconds: 250), alignment: 0.0, ); } }); } }); } } return Container( decoration: const BoxDecoration( color: kSurface, borderRadius: BorderRadius.vertical(top: Radius.circular(24)), border: Border(top: BorderSide(color: kBorder)), ), child: Column( children: [ Container( margin: const EdgeInsets.only(top: 12), width: 40, height: 4, decoration: BoxDecoration( color: kBorder, borderRadius: BorderRadius.circular(4), ), ), Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Row( children: [ const Icon(Icons.edit_outlined, color: kAccent), const SizedBox(width: 8), const Text('Edit Produk', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), const Spacer(), IconButton( icon: const Icon(Icons.close, color: kMuted), onPressed: () => Navigator.pop(sheetContext), tooltip: 'Tutup', ), ], ), ), const Divider(height: 12), Expanded( child: SingleChildScrollView( controller: scrollController, padding: EdgeInsets.fromLTRB(16, 8, 16, 16 + bottomInset), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ StatefulBuilder(builder: (ctx, setSheetState) { return productImageField( context: context, currentPath: imagePath, onChanged: (p) { imagePath = p; setSheetState(() {}); }, ); }), TextField( controller: barcode, focusNode: focusNodes[0], decoration: const InputDecoration( labelText: 'Barcode / No. Produk', prefixIcon: Icon(Icons.qr_code_2_outlined)), ), const SizedBox(height: 10), TextField( controller: name, focusNode: focusNodes[1], decoration: const InputDecoration(labelText: 'Nama'), ), const SizedBox(height: 10), TextField( controller: price, focusNode: focusNodes[2], keyboardType: TextInputType.number, inputFormatters: [RupiahInputFormatter()], decoration: const InputDecoration(labelText: 'Harga Umum', prefixText: 'Rp '), ), const SizedBox(height: 10), TextField( controller: priceReseller, focusNode: focusNodes[3], keyboardType: TextInputType.number, inputFormatters: [RupiahInputFormatter()], decoration: const InputDecoration(labelText: 'Harga Reseller', prefixText: 'Rp '), ), const SizedBox(height: 10), TextField( controller: hargaPokokCtrl, focusNode: focusNodes[4], keyboardType: TextInputType.number, inputFormatters: [RupiahInputFormatter()], decoration: const InputDecoration( labelText: 'Harga Pokok (modal)', prefixText: 'Rp ', helperText: 'Kosongkan jika tidak diketahui', ), ), const SizedBox(height: 10), TextField( controller: stock, focusNode: focusNodes[5], keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Stok'), ), const SizedBox(height: 10), TextField( controller: minStockCtrl, focusNode: focusNodes[6], keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'Stok minimum (warning)', helperText: 'Default: 5', ), ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () async { final n = name.text.trim(); final bc = barcode.text.trim(); final pr = (RupiahInputFormatter.getRawValue(price.text) ?? 0) .toDouble(); final prR = (RupiahInputFormatter.getRawValue(priceReseller.text) ?? pr) .toDouble(); final st = int.tryParse(stock.text) ?? 0; final ms = int.tryParse(minStockCtrl.text) ?? 5; final hp = (RupiahInputFormatter.getRawValue(hargaPokokCtrl.text) ?? 0) .toDouble(); if (n.isEmpty || pr <= 0) return; if (!await confirmResellerWarning(context, pr, prR)) return; final err = await state.updateProduct(product.id, n, bc, pr, prR, st, ms, hp, imagePath: imagePath); if (!context.mounted) return; final messenger = ScaffoldMessenger.of(context); if (err == null) { messenger.showSnackBar( const SnackBar( content: Text('Produk berhasil diperbarui'), backgroundColor: kSuccess, ), ); Navigator.pop(sheetContext); } else { messenger.showSnackBar( SnackBar( content: Text('Gagal memperbarui produk: '), backgroundColor: kDanger, ), ); } }, icon: const Icon(Icons.save), label: const Text('Simpan'), ), ), ], ), ), ), ], ), ); }, ); }, ); } class _ProductRow extends StatelessWidget { final Product p; final AppState state; const _ProductRow({required this.p, required this.state}); @override Widget build(BuildContext context) { final out = p.stock <= 0; final lowStock = p.stock > 0 && p.stock <= p.minStock; return Card( child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ CircleAvatar( radius: 22, backgroundColor: kAccent.withValues(alpha: 0.15), backgroundImage: productImageUrl(p.imagePath) != null ? NetworkImage(productImageUrl(p.imagePath)!) : null, child: productImageUrl(p.imagePath) == null ? Text( p.name.isNotEmpty ? p.name[0].toUpperCase() : '?', style: TextStyle(color: kAccent, fontWeight: FontWeight.w700), ) : null, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(p.name, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600)), if (p.barcode.isNotEmpty) ...[ const SizedBox(height: 2), Text( p.barcode, style: const TextStyle( fontSize: 11, color: kMuted, fontFamily: 'monospace'), ), ], const SizedBox(height: 4), Wrap( spacing: 10, runSpacing: 6, children: [ _Tag(formatRupiah(p.price), kSuccess), if (p.priceReseller > 0) _Tag('Rsl ${formatRupiah(p.priceReseller)}', kAccent), if (p.hargaPokok > 0) _Tag('Modal ${formatRupiah(p.hargaPokok)}', kAccent), if (out) _Tag('Stok habis', kDanger) else if (lowStock) _Tag('Stok rendah (${p.stock}/${p.minStock})', kAccent) else _Tag('Stok ${p.stock}', kMuted), ], ), ], ), ), Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.edit_outlined, size: 20), color: kAccent, onPressed: () => _showEditDialog(context, state, p), ), IconButton( icon: const Icon(Icons.delete_outline, size: 20), color: kDanger, onPressed: () async { HapticFeedback.mediumImpact(); final messenger = ScaffoldMessenger.of(context); final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( icon: Icon(Icons.delete_outline, color: kDanger, size: 28), title: const Text('Hapus Produk?'), content: Text('Yakin ingin menghapus "${p.name}"?'), actions: [ SizedBox( width: double.infinity, child: OutlinedButton( onPressed: () => Navigator.pop(ctx, false), style: OutlinedButton.styleFrom( foregroundColor: kMuted, side: const BorderSide(color: kBorder), ), child: const Text('Batal'), ), ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: FilledButton( onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: kDanger, ), child: const Text('Hapus'), ), ), ], ), ); if (confirm == true && context.mounted) { final err = await state.deleteProduct(p.id); if (err == null) { messenger.showSnackBar( const SnackBar( content: Text('Produk berhasil dihapus'), backgroundColor: kSuccess, ), ); } else { messenger.showSnackBar( SnackBar( content: Text('Gagal menghapus produk: $err'), backgroundColor: kDanger, ), ); } } }, ), ], ), ], ), ), ); } } class _Tag extends StatelessWidget { final String label; final Color color; const _Tag(this.label, this.color); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), child: Text(label, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: color)), ); } }