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