MIF_E31232435/lib/screens/prediction/prediction_controller.dart

472 lines
14 KiB
Dart

import 'package:finalproject/services/ml_service.dart';
import 'package:flutter/material.dart';
class PredictionController extends ChangeNotifier {
String? selectedRecipe;
int productionQuantity = 0;
bool isCalculated = false;
bool isLoading = true;
bool isSubmitting = false;
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, int> productIds = {};
final Map<String, String> productUnits = {};
static const double eggGramPerButir = 50;
Future<String?> loadRecipes() async {
isLoading = true;
notifyListeners();
try {
final fetchedRecipes = await MLService.getRecipes();
final fetchedProducts = await MLService.getProducts();
recipes = fetchedRecipes;
recipeIngredients = {};
for (final recipe in recipes) {
final recipeName = recipe['recipe_name'] as String?;
if (recipeName == null) continue;
recipeIngredients[recipeName] = {};
if (recipe['ingredients'] != null) {
for (final ingredient in recipe['ingredients']) {
recipeIngredients[recipeName]![ingredient['product_name']] = {
'quantity': ingredient['quantity_needed'],
'unit': ingredient['unit'],
};
}
}
}
_applyProductStocks(fetchedProducts);
_initializeIngredientSelections();
return null;
} catch (e) {
return 'Gagal memuat resep: $e';
} finally {
isLoading = false;
notifyListeners();
}
}
Future<void> refreshStock() async {
try {
final fetchedProducts = await MLService.getProducts();
_applyProductStocks(fetchedProducts);
} catch (_) {
// Ignore refresh errors; keep existing stock
} finally {
notifyListeners();
}
}
void _applyProductStocks(List<Map<String, dynamic>> products) {
if (products.isEmpty) return;
currentStock.clear();
productIds.clear();
productUnits.clear();
for (final product in products) {
final name =
(product['name'] ?? product['product_name'] ?? '').toString();
if (name.isEmpty) continue;
final id = _toInt(product['id']);
final unit = (product['unit'] ?? '').toString();
final stock = _toDouble(product['current_stock'] ?? product['stock']);
currentStock[name] = stock;
if (id != null) {
productIds[name] = id;
}
if (unit.isNotEmpty) {
productUnits[name] = unit;
}
}
}
void setSelectedRecipe(String? value) {
selectedRecipe = value;
isCalculated = false;
_initializeIngredientSelections();
notifyListeners();
}
void setProductionQuantity(String value) {
productionQuantity = int.tryParse(value) ?? 0;
isCalculated = false;
notifyListeners();
}
bool get canCalculate => selectedRecipe != null && productionQuantity > 0;
void calculate() {
isCalculated = true;
notifyListeners();
}
void reset() {
selectedRecipe = null;
productionQuantity = 0;
isCalculated = false;
ingredientSelections = {};
notifyListeners();
}
void setIngredientSelection(String ingredient, bool isSelected) {
ingredientSelections[ingredient] = isSelected;
notifyListeners();
}
bool isIngredientSelected(String ingredient) {
return ingredientSelections[ingredient] ?? true;
}
void _initializeIngredientSelections() {
if (selectedRecipe == null) {
ingredientSelections = {};
return;
}
final ingredients = recipeIngredients[selectedRecipe] ?? {};
ingredientSelections = {
for (final ingredient in ingredients.keys) ingredient: true,
};
}
Map<String, double> get requiredIngredients {
if (selectedRecipe == null || productionQuantity == 0) {
return {};
}
final ingredients = recipeIngredients[selectedRecipe] ?? {};
final required = <String, double>{};
ingredients.forEach((productName, details) {
if (!isIngredientSelected(productName)) return;
final quantity = _toDouble(details['quantity']);
required[productName] = quantity * productionQuantity;
});
return required;
}
Map<String, double> get insufficientStock {
final required = requiredIngredients;
final insufficient = <String, double>{};
required.forEach((ingredient, neededAmount) {
final requiredInStockUnit = _convertToStockUnit(
ingredient: ingredient,
amount: neededAmount,
fromUnit: getIngredientUnit(ingredient),
);
final available = getCurrentStock(ingredient);
if (available < requiredInStockUnit) {
insufficient[ingredient] = requiredInStockUnit - available;
}
});
return insufficient;
}
bool get isStockSufficient => insufficientStock.isEmpty;
String getIngredientUnit(String ingredient) {
if (selectedRecipe == null) return 'gr';
final recipeIngs = recipeIngredients[selectedRecipe];
if (recipeIngs == null) return 'gr';
final ingData = recipeIngs[ingredient];
if (ingData == null) return 'gr';
return ingData['unit'] as String? ?? 'gr';
}
String getStockUnit(String ingredient) {
final key = _matchingProductName(ingredient);
final unit = key == null ? productUnits[ingredient] : productUnits[key];
if (unit != null && unit.isNotEmpty) return _normalizeUnit(unit);
return 'kg';
}
double getCurrentStock(String ingredient) {
final key = _matchingProductName(ingredient);
return key == null ? currentStock[ingredient] ?? 0 : currentStock[key] ?? 0;
}
int? getProductId(String ingredient) {
final key = _matchingProductName(ingredient);
return key == null ? productIds[ingredient] : productIds[key];
}
double toGram({required double amount, required String unit}) {
final normalized = _normalizeUnit(unit);
if (normalized == 'gr') return amount;
if (normalized == 'kg') return amount * 1000;
if (normalized == 'ml') return amount;
if (normalized == 'l') return amount * 1000;
if (normalized == 'butir') return amount * eggGramPerButir;
return amount;
}
double getRequiredInStockUnit(String ingredient) {
final required = requiredIngredients[ingredient] ?? 0;
return _convertToStockUnit(
ingredient: ingredient,
amount: required,
fromUnit: getIngredientUnit(ingredient),
);
}
Color getStatusColor(String ingredient) {
final required = _convertToStockUnit(
ingredient: ingredient,
amount: requiredIngredients[ingredient] ?? 0,
fromUnit: getIngredientUnit(ingredient),
);
final available = getCurrentStock(ingredient);
return available >= required
? const Color(0xFF10B981)
: const Color(0xFFDC2626);
}
String cleanIngredientName(String ingredient) {
return ingredient
.replaceAll(
RegExp(
r'\s+\d+([,.]\d+)?\s*(kg|kilogram|g|gr|gram|ml|l|liter|ltr|butir|pcs)\b',
caseSensitive: false,
),
'',
)
.trim();
}
String formatQuantity(double value) {
if (value % 1 == 0) {
return value.toInt().toString();
}
return value
.toStringAsFixed(2)
.replaceAll(RegExp(r'0+$'), '')
.replaceAll(RegExp(r'\.$'), '');
}
String formatStockQuantity(String ingredient, double value) {
final unit = getStockUnit(ingredient);
if (unit == 'kg' && value > 0 && value < 1) {
return '${formatQuantity(value * 1000)} gr';
}
if (unit == 'l' && value > 0 && value < 1) {
return '${formatQuantity(value * 1000)} ml';
}
return '${formatQuantity(value)} $unit';
}
String _normalizeUnit(String unit) {
final normalized = unit.trim().toLowerCase();
if (['g', 'gr', 'gram', 'grams'].contains(normalized)) return 'gr';
if (['kg', 'kilogram', 'kilograms'].contains(normalized)) return 'kg';
if (['ml', 'mili', 'mililiter', 'milliliter'].contains(normalized)) {
return 'ml';
}
if (['l', 'lt', 'ltr', 'liter', 'litre'].contains(normalized)) return 'l';
if (['butir', 'pcs', 'piece', 'pieces'].contains(normalized)) {
return 'butir';
}
return normalized;
}
double _toDouble(dynamic value) {
if (value is num) return value.toDouble();
return double.tryParse(value?.toString() ?? '') ?? 0;
}
int? _toInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}
double _convertToStockUnit({
required String ingredient,
required double amount,
required String fromUnit,
}) {
final stockUnit = _normalizeUnit(getStockUnit(ingredient));
final unit = _normalizeUnit(fromUnit);
if (unit == stockUnit) return amount;
if (unit == 'gr' && stockUnit == 'kg') return amount / 1000;
if (unit == 'kg' && stockUnit == 'gr') return amount * 1000;
if (unit == 'butir' && stockUnit == 'kg') {
return (amount * eggGramPerButir) / 1000;
}
if (unit == 'butir' && stockUnit == 'gr') {
return amount * eggGramPerButir;
}
if (unit == 'ml' && stockUnit == 'kg') return amount / 1000;
if (unit == 'l' && stockUnit == 'kg') return amount;
if (unit == 'kg' && stockUnit == 'ml') return amount * 1000;
if (unit == 'kg' && stockUnit == 'l') return amount;
if (unit == 'ml' && stockUnit == 'gr') return amount;
if (unit == 'gr' && stockUnit == 'ml') return amount;
if (unit == 'l' && stockUnit == 'ml') return amount * 1000;
if (unit == 'ml' && stockUnit == 'l') return amount / 1000;
if (unit == 'kg' && stockUnit == 'butir') {
return (amount * 1000) / eggGramPerButir;
}
if (unit == 'gr' && stockUnit == 'butir') {
return amount / eggGramPerButir;
}
return amount;
}
String _ingredientKey(String value) {
return cleanIngredientName(
value,
).toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
}
String? _matchingProductName(String ingredient) {
if (currentStock.containsKey(ingredient)) return ingredient;
final target = _ingredientKey(ingredient);
for (final name in currentStock.keys) {
if (_ingredientKey(name) == target) return name;
}
return null;
}
Map<String, double> get stockUsage {
final required = requiredIngredients;
final usage = <String, double>{};
required.forEach((ingredient, neededAmount) {
if (!isIngredientSelected(ingredient)) return;
final unit = getIngredientUnit(ingredient);
usage[ingredient] = _convertToStockUnit(
ingredient: ingredient,
amount: neededAmount,
fromUnit: unit,
);
});
return usage;
}
String buildEstimatedNeedsText() {
final usage = stockUsage;
if (usage.isEmpty) return 'Belum tersedia';
return usage.entries
.where((entry) => entry.value > 0)
.map((entry) {
final name = cleanIngredientName(entry.key);
final amount = formatStockQuantity(entry.key, entry.value);
return '$name: $amount';
})
.join(', ');
}
Future<Map<String, dynamic>> submitProduction() async {
if (isSubmitting) {
return {
'status': 'error',
'message': 'Permintaan sedang diproses, mohon tunggu',
};
}
isSubmitting = true;
notifyListeners();
try {
if (selectedRecipe == null || !isCalculated) {
return {
'status': 'error',
'message': 'Hitung kebutuhan terlebih dahulu',
};
}
await refreshStock();
if (insufficientStock.isNotEmpty) {
return {
'status': 'error',
'message': 'Stok masih kurang, silakan update stok dulu',
};
}
final usage = stockUsage;
if (usage.isEmpty) {
return {'status': 'error', 'message': 'Tidak ada bahan yang dipilih'};
}
final items =
usage.entries.where((entry) => entry.value > 0).map((entry) {
final ingredient = entry.key;
final quantity = entry.value;
return {
'product_id': getProductId(ingredient),
'product_name': ingredient,
'quantity': quantity,
'unit': getStockUnit(ingredient),
};
}).toList();
final result = await MLService.consumeStock(
items: items,
recipeName: selectedRecipe,
productionQuantity: productionQuantity,
);
if (result['status'] == 'success') {
// Simpan hasil prediksi ke database agar laporan total prediksi ter-update.
await MLService.savePrediction(
productName: selectedRecipe ?? 'Produk',
category: 'Produk',
unitPrice: 0,
predictionDate: DateTime.now().toIso8601String().split('T').first,
predictedQuantity: productionQuantity,
estimatedNeeds: buildEstimatedNeedsText(),
);
for (final entry in usage.entries) {
final ingredient = entry.key;
final quantity = entry.value;
final key = _matchingProductName(ingredient) ?? ingredient;
final available = currentStock[key] ?? 0;
currentStock[key] = (available - quantity).clamp(0, double.infinity);
}
await refreshStock();
}
return result;
} catch (e) {
return {'status': 'error', 'message': 'Gagal submit: $e'};
} finally {
isSubmitting = false;
notifyListeners();
}
}
}