diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5c18006..f792ede 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -9,6 +9,17 @@
+
+
+
+
+
+
+
+
+
+
+
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(
@@ -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(),
@@ -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(),
@@ -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(
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index 1a59251..ccaaadf 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -502,7 +502,8 @@ class AppState extends ChangeNotifier {
/* ---------- Produk ---------- */
Future 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 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',
);
diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc
index 3792af4..e12c657 100644
--- a/linux/flutter/generated_plugin_registrant.cc
+++ b/linux/flutter/generated_plugin_registrant.cc
@@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h"
+#include
#include
#include
void fl_register_plugins(FlPluginRegistry* registry) {
+ g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
+ fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
+ file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake
index 21d8f8b..2127acd 100644
--- a/linux/flutter/generated_plugins.cmake
+++ b/linux/flutter/generated_plugins.cmake
@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
+ file_selector_linux
gtk
url_launcher_linux
)
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index 161825b..db7adab 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -7,6 +7,7 @@ import Foundation
import app_links
import connectivity_plus
+import file_selector_macos
import mobile_scanner
import package_info_plus
import path_provider_foundation
@@ -18,6 +19,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
+ FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
diff --git a/pubspec.lock b/pubspec.lock
index 0d5ff64..574a6b9 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -217,6 +217,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
+ file_selector_linux:
+ dependency: transitive
+ description:
+ name: file_selector_linux
+ sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.4"
+ file_selector_macos:
+ dependency: transitive
+ description:
+ name: file_selector_macos
+ sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.5"
+ file_selector_platform_interface:
+ dependency: transitive
+ description:
+ name: file_selector_platform_interface
+ sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.7.0"
+ file_selector_windows:
+ dependency: transitive
+ description:
+ name: file_selector_windows
+ sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.9.3+5"
fixnum:
dependency: transitive
description:
@@ -238,6 +270,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
+ flutter_plugin_android_lifecycle:
+ dependency: transitive
+ description:
+ name: flutter_plugin_android_lifecycle
+ sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.34"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -304,6 +344,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.0"
+ image_picker:
+ dependency: "direct main"
+ description:
+ name: image_picker
+ sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.2"
+ image_picker_android:
+ dependency: transitive
+ description:
+ name: image_picker_android
+ sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.8.13+17"
+ image_picker_for_web:
+ dependency: transitive
+ description:
+ name: image_picker_for_web
+ sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.1"
+ image_picker_ios:
+ dependency: transitive
+ description:
+ name: image_picker_ios
+ sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.8.13+3"
+ image_picker_linux:
+ dependency: transitive
+ description:
+ name: image_picker_linux
+ sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.2"
+ image_picker_macos:
+ dependency: transitive
+ description:
+ name: image_picker_macos
+ sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.2+1"
+ image_picker_platform_interface:
+ dependency: transitive
+ description:
+ name: image_picker_platform_interface
+ sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.11.1"
+ image_picker_windows:
+ dependency: transitive
+ description:
+ name: image_picker_windows
+ sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.2.2"
jni:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 18c0fc8..6ac24cf 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -47,6 +47,7 @@ dependencies:
esc_pos_utils_plus: ^2.0.4
uuid: ^4.0.0
package_info_plus: ^8.0.0
+ image_picker: ^1.2.0
dev_dependencies:
flutter_test:
diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc
index 4dbc5cb..d75e022 100644
--- a/windows/flutter/generated_plugin_registrant.cc
+++ b/windows/flutter/generated_plugin_registrant.cc
@@ -8,6 +8,7 @@
#include
#include
+#include
#include
#include
#include
@@ -17,6 +18,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("AppLinksPluginCApi"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
+ FileSelectorWindowsRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("FileSelectorWindows"));
PrintBluetoothThermalPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PrintBluetoothThermalPluginCApi"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake
index 805a9c3..55a9254 100644
--- a/windows/flutter/generated_plugins.cmake
+++ b/windows/flutter/generated_plugins.cmake
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
app_links
connectivity_plus
+ file_selector_windows
print_bluetooth_thermal
share_plus
url_launcher_windows