Update Flutter product and prediction flows

This commit is contained in:
rhanarmt 2026-06-16 21:29:57 +07:00
parent 1a34474c37
commit 9b55f02ade
11 changed files with 829 additions and 91 deletions

View File

@ -5,6 +5,7 @@ class Transaction {
final int quantity;
final int unitPrice;
final int totalPrice;
final String unit;
final DateTime date;
Transaction({
@ -14,6 +15,7 @@ class Transaction {
required this.quantity,
required this.unitPrice,
required this.totalPrice,
required this.unit,
required this.date,
});
@ -25,6 +27,7 @@ class Transaction {
quantity: json['quantity'] as int,
unitPrice: json['unit_price'] as int,
totalPrice: json['total_price'] as int,
unit: (json['unit'] as String?) ?? 'pcs',
date: DateTime.parse(json['date'] as String),
);
}
@ -37,6 +40,7 @@ class Transaction {
'quantity': quantity,
'unit_price': unitPrice,
'total_price': totalPrice,
'unit': unit,
'date': date.toIso8601String(),
};
}
@ -48,6 +52,7 @@ class Transaction {
int? quantity,
int? unitPrice,
int? totalPrice,
String? unit,
DateTime? date,
}) {
return Transaction(
@ -57,6 +62,7 @@ class Transaction {
quantity: quantity ?? this.quantity,
unitPrice: unitPrice ?? this.unitPrice,
totalPrice: totalPrice ?? this.totalPrice,
unit: unit ?? this.unit,
date: date ?? this.date,
);
}

View File

@ -50,7 +50,7 @@ class DashboardController extends ChangeNotifier {
_applyPenggunaanBahan(summary['penggunaan_bahan']);
} else {
errorMessage = summary['message']?.toString();
_applyDummyPenggunaan();
penggunaanBahan.clear();
}
totalProduk = products.length;
@ -59,7 +59,7 @@ class DashboardController extends ChangeNotifier {
errorMessage = 'Gagal memuat dashboard: $e';
bahanDigunakanError = 'Gagal memuat bahan digunakan hari ini';
_resetBahanDigunakanHariIni();
_applyDummyPenggunaan();
penggunaanBahan.clear();
lowStockItems.clear();
} finally {
isLoading = false;
@ -90,7 +90,6 @@ class DashboardController extends ChangeNotifier {
void _applyPenggunaanBahan(dynamic data) {
penggunaanBahan.clear();
if (data is! List || data.isEmpty) {
_applyDummyPenggunaan();
return;
}
for (final item in data) {
@ -133,18 +132,6 @@ class DashboardController extends ChangeNotifier {
);
}
void _applyDummyPenggunaan() {
penggunaanBahan
..clear()
..addAll([
{'name': 'Tepung Terigu', 'total': 120.0, 'unit': 'kg'},
{'name': 'Gula', 'total': 80.0, 'unit': 'kg'},
{'name': 'Mentega', 'total': 45.0, 'unit': 'kg'},
{'name': 'Telur', 'total': 30.0, 'unit': 'kg'},
{'name': 'Coklat Bubuk', 'total': 20.0, 'unit': 'kg'},
]);
}
String _formatStock(double value) {
if (value % 1 == 0) {
return value.toInt().toString();

View File

@ -7,25 +7,56 @@ class PredictionController extends ChangeNotifier {
bool isCalculated = false;
bool isLoading = true;
bool isSubmitting = false;
bool isPredicting = false;
int? predictedDemand;
double? predictionRawValue;
double? predictionR2;
double? predictionMae;
double? predictionRmse;
String? predictionModelProduct;
List<Map<String, dynamic>> recipes = [];
Map<String, Map<String, dynamic>> recipeIngredients = {};
Map<String, bool> ingredientSelections = {};
final Map<String, double> currentStock = {
'Tepung Terigu 1kg': 45000,
'Telur 1kg': 12,
'Gula Pasir 1kg': 28000,
'Susu Bubuk': 8000,
'Cokelat Bubuk 250gr': 22000,
'Mentega 500gr': 15000,
'Keju Parut 250gr': 3000,
'Baking Powder': 60000,
};
final Map<String, double> currentStock = {};
final Map<String, int> productIds = {};
final Map<String, String> productUnits = {};
final Map<String, String> productCategories = {};
final Map<String, int> productPrices = {};
static const double eggGramPerButir = 50;
static const Map<String, double> datasetPackageSizes = {
'Baking Powder 45gr': 45,
'Cokelat Bubuk 250gr': 250,
'Gula Pasir 1kg': 1000,
'Keju Parut 250gr': 250,
'Mentega 500gr': 500,
'Susu Bubuk 27gr': 27,
'Susu Bubuk': 27,
'Telur 1kg': 1000,
'Tepung Terigu 1kg': 1000,
};
static const Map<String, String> datasetCategories = {
'Baking Powder 45gr': 'Bahan Tambahan',
'Cokelat Bubuk 250gr': 'Cokelat',
'Gula Pasir 1kg': 'Gula',
'Keju Parut 250gr': 'Keju',
'Mentega 500gr': 'Mentega',
'Susu Bubuk 27gr': 'Susu',
'Telur 1kg': 'Telur',
'Tepung Terigu 1kg': 'Tepung',
};
static const Map<String, int> datasetMedianPrices = {
'Baking Powder 45gr': 8180,
'Cokelat Bubuk 250gr': 21036,
'Gula Pasir 1kg': 14608,
'Keju Parut 250gr': 23373,
'Mentega 500gr': 17529,
'Susu Bubuk 27gr': 17529,
'Telur 1kg': 26878,
'Tepung Terigu 1kg': 11687,
};
Future<String?> loadRecipes() async {
isLoading = true;
@ -82,6 +113,8 @@ class PredictionController extends ChangeNotifier {
currentStock.clear();
productIds.clear();
productUnits.clear();
productCategories.clear();
productPrices.clear();
for (final product in products) {
final name =
@ -91,6 +124,8 @@ class PredictionController extends ChangeNotifier {
final id = _toInt(product['id']);
final unit = (product['unit'] ?? '').toString();
final stock = _toDouble(product['current_stock'] ?? product['stock']);
final category = (product['category'] ?? '').toString();
final price = _toInt(product['price'] ?? product['unit_price']) ?? 0;
currentStock[name] = stock;
if (id != null) {
@ -99,12 +134,17 @@ class PredictionController extends ChangeNotifier {
if (unit.isNotEmpty) {
productUnits[name] = unit;
}
if (category.isNotEmpty) {
productCategories[name] = category;
}
productPrices[name] = price;
}
}
void setSelectedRecipe(String? value) {
selectedRecipe = value;
isCalculated = false;
_clearPredictionResult();
_initializeIngredientSelections();
notifyListeners();
}
@ -112,20 +152,84 @@ class PredictionController extends ChangeNotifier {
void setProductionQuantity(String value) {
productionQuantity = int.tryParse(value) ?? 0;
isCalculated = false;
_clearPredictionResult();
notifyListeners();
}
bool get canCalculate => selectedRecipe != null && productionQuantity > 0;
bool get canCalculate => selectedRecipe != null && !isPredicting;
void calculate() {
isCalculated = true;
Future<String?> calculate() async {
if (selectedRecipe == null) {
return 'Pilih produk terlebih dahulu';
}
isPredicting = true;
isCalculated = false;
_clearPredictionResult();
notifyListeners();
try {
await refreshStock();
final recipeIngredient = _primaryIngredientForPrediction();
if (recipeIngredient == null) {
return 'Resep ini belum memiliki bahan yang cocok dengan dataset model';
}
final modelProduct = _datasetProductName(recipeIngredient)!;
final manualQuantity = productionQuantity;
final plannedQuantity = manualQuantity > 0 ? manualQuantity : 1;
final result = await MLService.simplePrediksi(
productName: modelProduct,
category: _datasetCategory(modelProduct),
unitPrice: _datasetPrice(modelProduct),
plannedQuantity: plannedQuantity,
);
if (result['status'] != 'success') {
return result['message']?.toString() ?? 'Prediksi gagal diproses';
}
final prediction = result['prediksi'] as Map<String, dynamic>? ?? {};
final accuracy = result['model_accuracy'] as Map<String, dynamic>? ?? {};
final rawValue = _toDouble(prediction['nilai_raw']);
final demand =
_toInt(prediction['jumlah_unit']) ??
rawValue.round().clamp(1, 999999).toInt();
final demandInStockUnit = _datasetDemandToStockUnit(
ingredient: recipeIngredient,
demand: demand.toDouble(),
);
final perUnitNeed = _quantityPerUnitInStockUnit(recipeIngredient);
final modelProduction =
perUnitNeed > 0 ? (demandInStockUnit / perUnitNeed).round() : demand;
productionQuantity =
manualQuantity > 0
? manualQuantity
: modelProduction.clamp(1, 999999);
predictedDemand = manualQuantity > 0 ? manualQuantity : demand;
predictionRawValue = rawValue;
predictionR2 = _toDouble(accuracy['r2_score']);
predictionMae = _toDouble(accuracy['mae']);
predictionRmse = _toDouble(accuracy['rmse']);
predictionModelProduct = modelProduct;
isCalculated = true;
return null;
} catch (e) {
return 'Gagal menjalankan prediksi Random Forest: $e';
} finally {
isPredicting = false;
notifyListeners();
}
}
void reset() {
selectedRecipe = null;
productionQuantity = 0;
isCalculated = false;
_clearPredictionResult();
ingredientSelections = {};
notifyListeners();
}
@ -215,6 +319,20 @@ class PredictionController extends ChangeNotifier {
return key == null ? productIds[ingredient] : productIds[key];
}
String getProductCategory(String ingredient) {
final key = _matchingProductName(ingredient);
return key == null
? productCategories[ingredient] ?? 'Bahan Tambahan'
: productCategories[key] ?? 'Bahan Tambahan';
}
int getProductPrice(String ingredient) {
final key = _matchingProductName(ingredient);
return key == null
? productPrices[ingredient] ?? 0
: productPrices[key] ?? 0;
}
double toGram({required double amount, required String unit}) {
final normalized = _normalizeUnit(unit);
if (normalized == 'gr') return amount;
@ -308,8 +426,11 @@ class PredictionController extends ChangeNotifier {
required String ingredient,
required double amount,
required String fromUnit,
String? overrideStockUnit,
}) {
final stockUnit = _normalizeUnit(getStockUnit(ingredient));
final stockUnit = _normalizeUnit(
overrideStockUnit ?? getStockUnit(ingredient),
);
final unit = _normalizeUnit(fromUnit);
if (unit == stockUnit) return amount;
@ -358,6 +479,129 @@ class PredictionController extends ChangeNotifier {
return null;
}
String? _datasetProductName(String ingredient) {
final value = ingredient.toLowerCase();
if (value.contains('tepung terigu') || value == 'tepung') {
return 'Tepung Terigu 1kg';
}
if (value.contains('telur')) return 'Telur 1kg';
if (value.contains('gula pasir') || value == 'gula') {
return 'Gula Pasir 1kg';
}
if (value.contains('susu bubuk') || value == 'susu') {
return 'Susu Bubuk 27gr';
}
if (value.contains('cokelat') || value.contains('coklat')) {
return 'Cokelat Bubuk 250gr';
}
if (value.contains('mentega')) return 'Mentega 500gr';
if (value.contains('keju')) return 'Keju Parut 250gr';
if (value.contains('baking powder')) return 'Baking Powder 45gr';
return null;
}
String _datasetCategory(String datasetProductName) {
return datasetCategories[datasetProductName] ?? 'Bahan Tambahan';
}
int _datasetPrice(String datasetProductName) {
return datasetMedianPrices[datasetProductName] ?? 1;
}
void _clearPredictionResult() {
predictedDemand = null;
predictionRawValue = null;
predictionR2 = null;
predictionMae = null;
predictionRmse = null;
predictionModelProduct = null;
}
String? _primaryIngredientForPrediction() {
if (selectedRecipe == null) return null;
final ingredients = recipeIngredients[selectedRecipe] ?? {};
if (ingredients.isEmpty) return null;
String? selectedIngredient;
double highestNeed = -1;
ingredients.forEach((ingredient, details) {
if (!isIngredientSelected(ingredient)) return;
if (_datasetProductName(ingredient) == null) return;
final quantity = _toDouble(details['quantity']);
final unit = details['unit']?.toString() ?? getIngredientUnit(ingredient);
final stockNeed = _convertToStockUnit(
ingredient: ingredient,
amount: quantity,
fromUnit: unit,
);
if (stockNeed > highestNeed) {
highestNeed = stockNeed;
selectedIngredient = ingredient;
}
});
return selectedIngredient;
}
double _quantityPerUnitInStockUnit(String ingredient) {
if (selectedRecipe == null) return 0;
final details = recipeIngredients[selectedRecipe]?[ingredient];
if (details == null) return 0;
return _convertToStockUnit(
ingredient: ingredient,
amount: _toDouble(details['quantity']),
fromUnit: details['unit']?.toString() ?? getIngredientUnit(ingredient),
);
}
double _datasetDemandToStockUnit({
required String ingredient,
required double demand,
}) {
final packageGram = _datasetPackageGram(ingredient);
return _convertToStockUnit(
ingredient: ingredient,
amount: demand * packageGram,
fromUnit: 'gr',
overrideStockUnit: getStockUnit(ingredient),
);
}
double _datasetPackageGram(String ingredient) {
final matchedName = _matchingProductName(ingredient) ?? ingredient;
for (final entry in datasetPackageSizes.entries) {
if (_ingredientKey(entry.key) == _ingredientKey(matchedName) ||
_ingredientKey(entry.key) == _ingredientKey(ingredient)) {
return entry.value;
}
}
final lower = matchedName.toLowerCase();
final gramMatch = RegExp(
r'(\d+(?:[,.]\d+)?)\s*(gr|g|gram)\b',
).firstMatch(lower);
if (gramMatch != null) {
return double.parse(gramMatch.group(1)!.replaceAll(',', '.'));
}
final kgMatch = RegExp(
r'(\d+(?:[,.]\d+)?)\s*(kg|kilogram)\b',
).firstMatch(lower);
if (kgMatch != null) {
return double.parse(kgMatch.group(1)!.replaceAll(',', '.')) * 1000;
}
return 1000;
}
Map<String, double> get stockUsage {
final required = requiredIngredients;
final usage = <String, double>{};
@ -447,7 +691,10 @@ class PredictionController extends ChangeNotifier {
unitPrice: 0,
predictionDate: DateTime.now().toIso8601String().split('T').first,
predictedQuantity: productionQuantity,
rawValue: predictionRawValue,
estimatedNeeds: buildEstimatedNeedsText(),
accuracyR2: predictionR2,
errorMae: predictionMae,
);
for (final entry in usage.entries) {

View File

@ -446,7 +446,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
CrossAxisAlignment.start,
children: [
const Text(
'Jumlah Produksi',
'Jumlah Produksi Manual (opsional)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
@ -491,6 +491,14 @@ class _PredictionScreenState extends State<PredictionScreen> {
),
),
),
const SizedBox(height: 6),
const Text(
'Kosongkan untuk memakai hasil prediksi Random Forest.',
style: TextStyle(
fontSize: 11,
color: Color(0xFF9CA3AF),
),
),
],
),
const SizedBox(height: 20),
@ -501,18 +509,51 @@ class _PredictionScreenState extends State<PredictionScreen> {
onPressed:
_controller.canCalculate
? () async {
await _controller
.refreshStock();
_controller.calculate();
final error =
await _controller
.calculate();
_productionController
.text = _controller
.productionQuantity
.toString();
if (!context.mounted ||
error == null) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
error,
),
),
);
}
: null,
icon: const Icon(
Icons.calculate,
size: 18,
),
label: const Text(
'Hitung Kebutuhan',
style: TextStyle(
icon:
_controller.isPredicting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<
Color
>(Colors.white),
),
)
: const Icon(
Icons.auto_graph,
size: 18,
),
label: Text(
_controller.isPredicting
? 'Memprediksi...'
: 'Prediksi Random Forest',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
@ -569,6 +610,124 @@ class _PredictionScreenState extends State<PredictionScreen> {
),
const SizedBox(height: 24),
if (_controller.isCalculated) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(
0xFFA89080,
).withOpacity(0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: const Color(
0xFFA89080,
).withOpacity(0.25),
),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(
0xFFA89080,
).withOpacity(0.18),
borderRadius:
BorderRadius.circular(10),
),
child: const Icon(
Icons.auto_graph,
color: Color(0xFFA89080),
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const Text(
'Hasil Prediksi Random Forest',
style: TextStyle(
fontSize: 14,
fontWeight:
FontWeight.w700,
color: Color(0xFF1F2937),
),
),
const SizedBox(height: 2),
Text(
'Bahan acuan: ${_controller.cleanIngredientName(_controller.predictionModelProduct ?? '-')}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
),
),
],
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _PredictionMetric(
label: 'Permintaan',
value:
'${_controller.predictedDemand ?? 0}',
),
),
const SizedBox(width: 10),
Expanded(
child: _PredictionMetric(
label: 'Produksi',
value:
'${_controller.productionQuantity} pcs',
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _PredictionMetric(
label: 'R2',
value:
_controller.predictionR2 ==
null
? '-'
: _controller
.predictionR2!
.toStringAsFixed(4),
),
),
const SizedBox(width: 10),
Expanded(
child: _PredictionMetric(
label: 'MAE',
value:
_controller.predictionMae ==
null
? '-'
: _controller
.predictionMae!
.toStringAsFixed(2),
),
),
],
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@ -1293,6 +1452,45 @@ class _PredictionScreenState extends State<PredictionScreen> {
}
}
class _PredictionMetric extends StatelessWidget {
const _PredictionMetric({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 11, color: Color(0xFF6B7280)),
),
const SizedBox(height: 4),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Color(0xFF1F2937),
),
),
],
),
);
}
}
class _EmptyCalcIcon extends StatelessWidget {
const _EmptyCalcIcon();

View File

@ -10,6 +10,19 @@ class ProductListController extends ChangeNotifier {
List<Product> products = [];
static const Map<String, String> datasetUnits = {
'Baking Powder 45gr': 'gr',
'Baking Powder': 'gr',
'Cokelat Bubuk 250gr': 'gr',
'Gula Pasir 1kg': 'kg',
'Keju Parut 250gr': 'gr',
'Mentega 500gr': 'gr',
'Susu Bubuk 27gr': 'gr',
'Susu Bubuk': 'gr',
'Telur 1kg': 'kg',
'Tepung Terigu 1kg': 'kg',
};
Future<void> loadProducts() async {
isLoading = true;
notifyListeners();
@ -22,12 +35,14 @@ class ProductListController extends ChangeNotifier {
final stock = StockStatusUtils.parseStock(p['current_stock']);
final minStock = StockStatusUtils.parseStock(p['min_stock']);
final category = p['category'] ?? '';
final name = p['name'] ?? '';
final unit =
p['unit'] ??
datasetUnits[name] ??
(category.toString().toLowerCase() == 'barang' ? 'pcs' : 'kg');
return Product(
id: p['id'] ?? 0,
name: p['name'] ?? '',
name: name,
category: category,
price: p['price'] ?? 0,
stock: stock,
@ -128,6 +143,44 @@ class ProductListController extends ChangeNotifier {
.replaceAll(RegExp(r'\.$'), '');
}
String minimumStockUnit(Product product) {
final unit = product.unit.toLowerCase();
if (product.category.toLowerCase() == 'barang' || unit == 'pcs') {
return 'pcs';
}
if (unit == 'ml' || unit == 'l') {
return 'L';
}
return 'kg';
}
Future<String?> updateProduct({
required Product product,
required String name,
required String category,
required int price,
required double stock,
required double minStock,
required String unit,
}) async {
final result = await MLService.updateProduct(
productId: product.id,
name: name,
category: category,
price: price,
currentStock: stock,
minStock: minStock,
unit: unit,
);
if (result['status'] != 'success') {
return result['message']?.toString() ?? 'Gagal mengubah produk';
}
await loadProducts();
return null;
}
String getStatusLabel(String status) {
return StockStatusUtils.label(status, withIcon: true);
}

View File

@ -702,7 +702,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
icon: Icons.low_priority_outlined,
label: 'Minimum',
value:
'${_controller.formatStock(product.minStock)} ${product.unit}',
'${_controller.formatStock(product.minStock)} ${_controller.minimumStockUnit(product)}',
),
_buildDetailTile(
icon: Icons.straighten_outlined,
@ -791,7 +791,26 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
),
),
),
const SizedBox(width: 12),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
_showEditProductDialog(product);
},
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryBrown,
side: BorderSide(color: AppColors.primaryBrown),
padding: const EdgeInsets.symmetric(vertical: 13),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: () {
@ -820,6 +839,193 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
);
}
void _showEditProductDialog(Product product) {
final nameController = TextEditingController(text: product.name);
final categoryController = TextEditingController(text: product.category);
final priceController = TextEditingController(
text: product.price.toString(),
);
final stockController = TextEditingController(
text: _controller.formatStock(product.stock),
);
final minStockController = TextEditingController(
text: _controller.formatStock(product.minStock),
);
final unitOptions = ['kg', 'gr', 'L', 'ml', 'butir', 'pcs'];
var selectedUnit = product.unit == 'l' ? 'L' : product.unit;
if (!unitOptions.contains(selectedUnit)) {
selectedUnit = product.unit.toLowerCase();
}
if (!unitOptions.contains(selectedUnit)) {
selectedUnit = unitOptions.first;
}
var isSaving = false;
showDialog(
context: context,
builder:
(context) => StatefulBuilder(
builder:
(context, setStateDialog) => AlertDialog(
title: const Text('Edit Produk'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Nama Produk',
),
),
const SizedBox(height: 12),
TextField(
controller: categoryController,
decoration: const InputDecoration(
labelText: 'Kategori',
),
),
const SizedBox(height: 12),
TextField(
controller: priceController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'Harga'),
),
const SizedBox(height: 12),
TextField(
controller: stockController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(labelText: 'Stok'),
),
const SizedBox(height: 12),
TextField(
controller: minStockController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Minimum',
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: selectedUnit,
items:
unitOptions
.map(
(unit) => DropdownMenuItem(
value: unit,
child: Text(unit),
),
)
.toList(),
onChanged:
isSaving
? null
: (value) {
if (value == null) return;
setStateDialog(() => selectedUnit = value);
},
decoration: const InputDecoration(
labelText: 'Satuan',
),
),
],
),
),
actions: [
TextButton(
onPressed:
isSaving ? null : () => Navigator.of(context).pop(),
child: const Text('Batal'),
),
ElevatedButton(
onPressed:
isSaving
? null
: () async {
final name = nameController.text.trim();
final category = categoryController.text.trim();
final price =
int.tryParse(priceController.text.trim()) ??
0;
final stock =
double.tryParse(
stockController.text.trim().replaceAll(
',',
'.',
),
) ??
-1;
final minStock =
double.tryParse(
minStockController.text.trim().replaceAll(
',',
'.',
),
) ??
-1;
if (name.isEmpty ||
category.isEmpty ||
price <= 0 ||
stock < 0 ||
minStock < 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'Isi data produk dengan benar',
),
backgroundColor: AppColors.statusError,
),
);
return;
}
setStateDialog(() => isSaving = true);
final error = await _controller.updateProduct(
product: product,
name: name,
category: category,
price: price,
stock: stock,
minStock: minStock,
unit: selectedUnit,
);
if (!context.mounted) return;
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
error ?? 'Produk berhasil diperbarui',
),
backgroundColor:
error == null
? AppColors.statusSuccess
: AppColors.statusError,
),
);
},
child:
isSaving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Simpan'),
),
],
),
),
);
}
Widget _buildDetailGrid({required List<Widget> children}) {
return LayoutBuilder(
builder: (context, constraints) {

View File

@ -15,8 +15,8 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
late final SettingsController _controller;
String _userName = 'Ibu Sulastri';
String _userEmail = 'sulastri.aritanto10@gmail.com';
String _userName = '';
String _userEmail = '';
@override
void initState() {

View File

@ -7,6 +7,7 @@ class CartItem {
final String productName;
final String category;
final int unitPrice;
final String unit;
int quantity;
CartItem({
@ -14,6 +15,7 @@ class CartItem {
required this.productName,
required this.category,
required this.unitPrice,
required this.unit,
required this.quantity,
});
@ -109,6 +111,9 @@ class TransactionController extends ChangeNotifier {
productName: selectedProduct!,
category: productCategories[selectedProduct!]!,
unitPrice: productPrices[selectedProduct!]!,
unit:
productUnits[selectedProduct!] ??
_defaultUnitFromCategory(productCategories[selectedProduct!]!),
quantity: quantity,
),
);
@ -179,6 +184,7 @@ class TransactionController extends ChangeNotifier {
quantity: item.quantity,
unitPrice: item.unitPrice,
totalPrice: item.totalPrice,
unit: item.unit,
date: date,
),
);

View File

@ -108,7 +108,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
final newStockController = TextEditingController();
final newMinStockController = TextEditingController();
const unitOptionsByType = {
'Bahan': ['kg'],
'Bahan': ['kg', 'gr', 'L', 'ml'],
'Barang': ['pcs'],
};
String selectedProductType = 'Bahan';
@ -593,7 +593,10 @@ class _TransactionScreenState extends State<TransactionScreen> {
controller: _controller.quantityController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
hintText: 'Masukkan jumlah unit',
hintText:
_controller.selectedProduct == null
? 'Masukkan jumlah'
: 'Masukkan jumlah ${_controller.productUnits[_controller.selectedProduct] ?? ''}',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
@ -715,7 +718,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
),
const SizedBox(height: 4),
Text(
'Rp ${item.unitPrice} / unit',
'Rp ${item.unitPrice} / ${item.unit}',
style: AppTextStyles
.labelSmall
.copyWith(
@ -1023,7 +1026,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
MainAxisAlignment.spaceBetween,
children: [
Text(
'${tx.quantity} unit × Rp ${tx.unitPrice}',
'${tx.quantity} ${tx.unit} x Rp ${tx.unitPrice}',
style: AppTextStyles.labelMedium.copyWith(
color: AppColors.textSecondary,
),

View File

@ -25,13 +25,12 @@ class AuthService {
static Future<String> getUserName() async {
final preferences = await SharedPreferences.getInstance();
return preferences.getString(_userNameKey) ?? 'Ibu Sulastri';
return preferences.getString(_userNameKey) ?? '';
}
static Future<String> getUserEmail() async {
final preferences = await SharedPreferences.getInstance();
return preferences.getString(_userEmailKey) ??
'sulastri.aritanto10@gmail.com';
return preferences.getString(_userEmailKey) ?? '';
}
static Future<void> logout() async {

View File

@ -3,8 +3,9 @@ import 'dart:async';
import 'dart:convert';
class MLService {
// API URL Railway production backend.
static const String baseUrl = 'https://web-production-c3c06.up.railway.app';
// API URL local Flask backend.
static const String baseUrl = 'http://192.168.1.67:5000';
//https://web-production-c3c06.up.railway.app/api
static const int timeoutSeconds = 30;
@ -174,49 +175,34 @@ class MLService {
required String productName,
required String category,
required int unitPrice,
int plannedQuantity = 1,
DateTime? predictionDate,
}) async {
try {
final date = predictionDate ?? DateTime.now();
// Map product names to encoded values (adjust based on your encoding)
final productMap = {
'Tepung Terigu 1kg': 1,
'Telur 1kg': 2,
'Gula Pasir 1kg': 3,
'Susu Bubuk': 4,
'Cokelat Bubuk 250gr': 5,
'Mentega 500gr': 6,
'Keju Parut 250gr': 7,
'Baking Powder': 8,
final response = await http
.post(
Uri.parse('$baseUrl/prediksi'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'product_name': productName,
'category': category,
'unit_price': unitPrice,
'planned_quantity': plannedQuantity,
'prediction_date': date.toIso8601String().split('T').first,
}),
)
.timeout(Duration(seconds: timeoutSeconds));
if (response.statusCode == 200) {
return jsonDecode(response.body);
}
return {
'status': 'error',
'message': 'Server error: ${response.statusCode}',
};
final categoryMap = {
'Tepung': 1,
'Telur': 2,
'Gula': 3,
'Susu': 4,
'Cokelat': 5,
'Mentega': 6,
'Keju': 7,
'Bahan Tambahan': 8,
};
final produkEncoded = productMap[productName] ?? 1;
final kategoriEncoded = categoryMap[category] ?? 1;
return await prediksiStok(
tahun: date.year,
bulan: date.month,
hari: date.day,
hariDalamMinggu: date.weekday,
hariMinggu: date.weekday,
hargaSatuanUpdate: unitPrice,
totalHargaUpdate: unitPrice, // Simplified
produkEncoded: produkEncoded,
namaProdukEncoded: produkEncoded,
kategoriProdukEncoded: kategoriEncoded,
);
} catch (e) {
return {'status': 'error', 'message': 'Connection error: $e'};
}
@ -567,6 +553,53 @@ class MLService {
}
}
/// Update product in database
static Future<Map<String, dynamic>> updateProduct({
required int productId,
String? name,
String? category,
int? price,
double? currentStock,
double? minStock,
String? unit,
}) async {
try {
final data = {
if (name != null) 'name': name,
if (category != null) 'category': category,
if (price != null) 'price': price,
if (currentStock != null) 'current_stock': currentStock,
if (minStock != null) 'min_stock': minStock,
if (unit != null) 'unit': unit,
};
final response = await http
.put(
Uri.parse('$baseUrl/products/$productId'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(data),
)
.timeout(Duration(seconds: timeoutSeconds));
final body =
response.body.isNotEmpty
? jsonDecode(response.body) as Map<String, dynamic>
: <String, dynamic>{};
if (response.statusCode >= 200 && response.statusCode < 300) {
return body;
}
return {
'status': 'error',
'message': body['message'] ?? 'Server error: ${response.statusCode}',
};
} catch (e) {
print('Update product error: $e');
return {'status': 'error', 'message': 'Connection error: $e'};
}
}
/// Save transaction and update stock automatically
static Future<Map<String, dynamic>> addTransactionWithStockUpdate({
required int productId,