Fitur foto produk: image_picker + upload Supabase Storage (bucket product-images), thumbnail di daftar produk
This commit is contained in:
@@ -9,6 +9,7 @@ class Product {
|
||||
final int stock;
|
||||
final int minStock;
|
||||
final double hargaPokok;
|
||||
final String? imagePath; // path file di Supabase Storage (bucket product-images)
|
||||
|
||||
Product({
|
||||
required this.id,
|
||||
@@ -19,6 +20,7 @@ class Product {
|
||||
required this.stock,
|
||||
this.minStock = 5,
|
||||
this.hargaPokok = 0,
|
||||
this.imagePath,
|
||||
});
|
||||
|
||||
factory Product.manual(String name, double price) {
|
||||
@@ -48,6 +50,7 @@ class Product {
|
||||
hargaPokok: json['harga_pokok'] != null
|
||||
? (json['harga_pokok'] as num).toDouble()
|
||||
: 0,
|
||||
imagePath: json['image_path'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,5 +63,6 @@ class Product {
|
||||
'stock': stock,
|
||||
'min_stock': minStock,
|
||||
'harga_pokok': hargaPokok,
|
||||
'image_path': imagePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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';
|
||||
@@ -10,6 +12,144 @@ 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<String?> 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<bool> confirmResellerWarning(BuildContext context, double general, double reseller) async {
|
||||
if (reseller <= 0 || reseller <= general) return true;
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -186,6 +326,7 @@ void _showAddForm(BuildContext context, AppState state) {
|
||||
final stock = TextEditingController();
|
||||
final minStockCtrl = TextEditingController(text: '5');
|
||||
final hargaPokokCtrl = TextEditingController();
|
||||
String? imagePath; // path gambar di Supabase Storage
|
||||
final focusNodes = <FocusNode>[
|
||||
FocusNode(),
|
||||
FocusNode(),
|
||||
@@ -274,6 +415,16 @@ void _showAddForm(BuildContext context, AppState state) {
|
||||
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],
|
||||
@@ -387,7 +538,7 @@ void _showAddForm(BuildContext context, AppState state) {
|
||||
.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);
|
||||
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) {
|
||||
@@ -443,6 +594,7 @@ void _showEditDialog(BuildContext context, AppState state, Product product) {
|
||||
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(),
|
||||
@@ -532,6 +684,16 @@ void _showEditDialog(BuildContext context, AppState state, Product product) {
|
||||
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],
|
||||
@@ -608,7 +770,7 @@ void _showEditDialog(BuildContext context, AppState state, Product product) {
|
||||
.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);
|
||||
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) {
|
||||
@@ -663,11 +825,16 @@ class _ProductRow extends StatelessWidget {
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: kAccent.withValues(alpha: 0.15),
|
||||
child: Text(
|
||||
p.name.isNotEmpty ? p.name[0].toUpperCase() : '?',
|
||||
style:
|
||||
TextStyle(color: kAccent, fontWeight: FontWeight.w700),
|
||||
),
|
||||
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(
|
||||
|
||||
@@ -502,7 +502,8 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
/* ---------- Produk ---------- */
|
||||
Future<String?> addProduct(String name, String barcode, double price,
|
||||
double priceReseller, int stock, int minStock, double hargaPokok) async {
|
||||
double priceReseller, int stock, int minStock, double hargaPokok,
|
||||
{String? imagePath}) async {
|
||||
try {
|
||||
final trimmed = barcode.trim();
|
||||
if (trimmed.isNotEmpty &&
|
||||
@@ -518,6 +519,7 @@ class AppState extends ChangeNotifier {
|
||||
stock: stock,
|
||||
minStock: minStock,
|
||||
hargaPokok: hargaPokok,
|
||||
imagePath: imagePath,
|
||||
);
|
||||
await _withTimeout(
|
||||
_supabase.from('products').insert(product.toJson()..['store_id'] = _currentStoreId),
|
||||
@@ -534,7 +536,8 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<String?> updateProduct(String id, String name, String barcode,
|
||||
double price, double priceReseller, int stock, int minStock, double hargaPokok) async {
|
||||
double price, double priceReseller, int stock, int minStock, double hargaPokok,
|
||||
{String? imagePath}) async {
|
||||
try {
|
||||
final trimmed = barcode.trim();
|
||||
if (trimmed.isNotEmpty &&
|
||||
@@ -551,6 +554,7 @@ class AppState extends ChangeNotifier {
|
||||
'min_stock': minStock,
|
||||
'harga_pokok': hargaPokok,
|
||||
'store_id': _currentStoreId,
|
||||
'image_path': imagePath,
|
||||
}).eq('id', id),
|
||||
'Update product',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user