implementasi frontend untuk fitur prediksi kebutuhan bahan
This commit is contained in:
parent
8c2c61330e
commit
9d7bd0c6cd
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:finalproject/theme/colors.dart';
|
||||
import 'package:finalproject/theme/text_styles.dart';
|
||||
import 'package:finalproject/services/ml_service.dart';
|
||||
|
||||
class PredictionScreen extends StatefulWidget {
|
||||
const PredictionScreen({Key? key}) : super(key: key);
|
||||
|
|
@ -13,49 +14,11 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
String? _selectedRecipe;
|
||||
int _productionQuantity = 0;
|
||||
bool _isCalculated = false;
|
||||
bool _isLoading = true;
|
||||
|
||||
// Produk yang bisa dibuat
|
||||
final List<String> recipes = [
|
||||
'Donat',
|
||||
'Roti Putih',
|
||||
'Kue Brownies',
|
||||
'Kue Tart',
|
||||
];
|
||||
|
||||
// Resep untuk setiap produk (ingredient: gram/butir per unit)
|
||||
final Map<String, Map<String, int>> recipeDetails = {
|
||||
'Donat': {
|
||||
'Tepung Terigu 1kg': 500,
|
||||
'Telur 1kg': 2,
|
||||
'Gula Pasir 1kg': 100,
|
||||
'Mentega 500gr': 50,
|
||||
'Baking Powder': 5,
|
||||
},
|
||||
'Roti Putih': {
|
||||
'Tepung Terigu 1kg': 800,
|
||||
'Telur 1kg': 3,
|
||||
'Gula Pasir 1kg': 80,
|
||||
'Mentega 500gr': 80,
|
||||
'Susu Bubuk': 50,
|
||||
'Baking Powder': 8,
|
||||
},
|
||||
'Kue Brownies': {
|
||||
'Tepung Terigu 1kg': 300,
|
||||
'Cokelat Bubuk 250gr': 100,
|
||||
'Telur 1kg': 4,
|
||||
'Gula Pasir 1kg': 200,
|
||||
'Mentega 500gr': 150,
|
||||
'Baking Powder': 5,
|
||||
},
|
||||
'Kue Tart': {
|
||||
'Tepung Terigu 1kg': 400,
|
||||
'Telur 1kg': 5,
|
||||
'Gula Pasir 1kg': 150,
|
||||
'Mentega 500gr': 200,
|
||||
'Keju Parut 250gr': 100,
|
||||
'Susu Bubuk': 80,
|
||||
},
|
||||
};
|
||||
// Data dari API
|
||||
List<Map<String, dynamic>> recipes = [];
|
||||
Map<String, Map<String, dynamic>> recipeIngredients = {};
|
||||
|
||||
// Stok saat ini (sama dengan di product list)
|
||||
final Map<String, int> currentStock = {
|
||||
|
|
@ -69,25 +32,58 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
'Baking Powder': 60000, // gram
|
||||
};
|
||||
|
||||
// Satuan untuk setiap ingredient
|
||||
final Map<String, String> ingredientUnits = {
|
||||
'Tepung Terigu 1kg': 'gr',
|
||||
'Telur 1kg': 'butir',
|
||||
'Gula Pasir 1kg': 'gr',
|
||||
'Susu Bubuk': 'gr',
|
||||
'Cokelat Bubuk 250gr': 'gr',
|
||||
'Mentega 500gr': 'gr',
|
||||
'Keju Parut 250gr': 'gr',
|
||||
'Baking Powder': 'gr',
|
||||
};
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRecipes();
|
||||
}
|
||||
|
||||
Future<void> _loadRecipes() async {
|
||||
try {
|
||||
final fetchedRecipes = await MLService.getRecipes();
|
||||
|
||||
setState(() {
|
||||
recipes = fetchedRecipes;
|
||||
|
||||
// Build recipeIngredients map
|
||||
for (var recipe in recipes) {
|
||||
recipeIngredients[recipe['recipe_name']] = {};
|
||||
|
||||
if (recipe['ingredients'] != null) {
|
||||
for (var ingredient in recipe['ingredients']) {
|
||||
recipeIngredients[recipe['recipe_name']]![ingredient['product_name']] = {
|
||||
'quantity': ingredient['quantity_needed'],
|
||||
'unit': ingredient['unit'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
print('Error loading recipes: $e');
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Gagal memuat resep: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, int> get requiredIngredients {
|
||||
if (_selectedRecipe == null || _productionQuantity == 0) {
|
||||
return {};
|
||||
}
|
||||
final recipe = recipeDetails[_selectedRecipe]!;
|
||||
return recipe.map((ingredient, amount) =>
|
||||
MapEntry(ingredient, amount * _productionQuantity));
|
||||
|
||||
final ingredients = recipeIngredients[_selectedRecipe] ?? {};
|
||||
final required = <String, int>{};
|
||||
|
||||
ingredients.forEach((productName, details) {
|
||||
final quantity = (details['quantity'] as num).toInt();
|
||||
required[productName] = quantity * _productionQuantity;
|
||||
});
|
||||
|
||||
return required;
|
||||
}
|
||||
|
||||
Map<String, int> get insufficientStock {
|
||||
|
|
@ -106,6 +102,15 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
|
||||
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';
|
||||
}
|
||||
|
||||
Color _getStatusColor(String ingredient) {
|
||||
final required = requiredIngredients[ingredient] ?? 0;
|
||||
final available = currentStock[ingredient] ?? 0;
|
||||
|
|
@ -236,7 +241,34 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
body: _isLoading
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Memuat resep...'),
|
||||
],
|
||||
),
|
||||
)
|
||||
: recipes.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: Color(0xFFDC2626)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Gagal memuat resep'),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: _loadRecipes,
|
||||
child: const Text('Coba Lagi'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
|
|
@ -313,13 +345,13 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
),
|
||||
items: recipes
|
||||
.map((recipe) =>
|
||||
DropdownMenuItem(
|
||||
value: recipe,
|
||||
DropdownMenuItem<String>(
|
||||
value: recipe['recipe_name'] as String,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 12),
|
||||
child: Text(recipe),
|
||||
child: Text(recipe['recipe_name'] as String),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
|
|
@ -561,7 +593,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
final neededAmount = entry.value.value;
|
||||
final availableAmount =
|
||||
currentStock[ingredient] ?? 0;
|
||||
final unit = ingredientUnits[ingredient] ?? 'gr';
|
||||
final unit = _getIngredientUnit(ingredient);
|
||||
final isSufficient =
|
||||
availableAmount >= neededAmount;
|
||||
|
||||
|
|
@ -727,8 +759,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
.map((entry) {
|
||||
final ingredient = entry.key;
|
||||
final deficitAmount = entry.value;
|
||||
final unit =
|
||||
ingredientUnits[ingredient] ?? 'gr';
|
||||
final unit = _getIngredientUnit(ingredient);
|
||||
|
||||
return Padding(
|
||||
padding:
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import 'dart:convert';
|
|||
|
||||
class MLService {
|
||||
// API URL - Change based on environment
|
||||
static const String baseUrl = 'http://127.0.0.1:5000';
|
||||
// For remote access: 'http://192.168.1.75:5000'
|
||||
// Untuk emulator Android: 10.0.2.2
|
||||
// Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local
|
||||
static const String baseUrl = 'http://192.168.1.13:5000';
|
||||
|
||||
static const int timeoutSeconds = 30;
|
||||
|
||||
|
|
@ -159,7 +160,7 @@ class MLService {
|
|||
} else {
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Server error: ${response.statusCode}'
|
||||
'message': 'Server error: ${response.statusCode}',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -315,7 +316,8 @@ class MLService {
|
|||
'prediction_date': predictionDate,
|
||||
'predicted_quantity': predictedQuantity,
|
||||
if (rawValue != null) 'raw_value': rawValue,
|
||||
if (estimatedTotalPrice != null) 'estimated_total_price': estimatedTotalPrice,
|
||||
if (estimatedTotalPrice != null)
|
||||
'estimated_total_price': estimatedTotalPrice,
|
||||
if (accuracyR2 != null) 'accuracy_r2': accuracyR2,
|
||||
if (errorMae != null) 'error_mae': errorMae,
|
||||
};
|
||||
|
|
@ -338,4 +340,48 @@ class MLService {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// RECIPES ENDPOINTS
|
||||
// ========================================================================
|
||||
|
||||
/// Get all recipes with ingredients
|
||||
static Future<List<Map<String, dynamic>>> getRecipes() async {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$baseUrl/recipes'))
|
||||
.timeout(Duration(seconds: timeoutSeconds));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['status'] == 'success') {
|
||||
return List<Map<String, dynamic>>.from(data['recipes']);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
print('Get recipes error: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get specific recipe by ID with ingredients
|
||||
static Future<Map<String, dynamic>?> getRecipe(int recipeId) async {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$baseUrl/recipes/$recipeId'))
|
||||
.timeout(Duration(seconds: timeoutSeconds));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['status'] == 'success') {
|
||||
return data['recipe'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('Get recipe error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -481,6 +481,89 @@ def save_prediction():
|
|||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# RECIPES ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@app.route('/recipes', methods=['GET'])
|
||||
def get_recipes():
|
||||
"""Get all recipes with their ingredients"""
|
||||
try:
|
||||
connection = get_db_connection()
|
||||
if not connection:
|
||||
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
|
||||
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# Get all recipes
|
||||
cursor.execute("SELECT id, recipe_name, description FROM recipes ORDER BY recipe_name")
|
||||
recipes = cursor.fetchall()
|
||||
|
||||
# Get ingredients for each recipe
|
||||
for recipe in recipes:
|
||||
cursor.execute("""
|
||||
SELECT product_name, quantity_needed, unit
|
||||
FROM recipe_ingredients
|
||||
WHERE recipe_id = %s
|
||||
ORDER BY product_name
|
||||
""", (recipe['id'],))
|
||||
recipe['ingredients'] = cursor.fetchall()
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'total': len(recipes),
|
||||
'recipes': recipes
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get recipes error: {str(e)}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/recipes/<int:recipe_id>', methods=['GET'])
|
||||
def get_recipe(recipe_id):
|
||||
"""Get specific recipe with ingredients"""
|
||||
try:
|
||||
connection = get_db_connection()
|
||||
if not connection:
|
||||
return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500
|
||||
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
|
||||
# Get recipe
|
||||
cursor.execute("SELECT id, recipe_name, description FROM recipes WHERE id = %s", (recipe_id,))
|
||||
recipe = cursor.fetchone()
|
||||
|
||||
if not recipe:
|
||||
cursor.close()
|
||||
connection.close()
|
||||
return jsonify({'status': 'error', 'message': 'Recipe not found'}), 404
|
||||
|
||||
# Get ingredients
|
||||
cursor.execute("""
|
||||
SELECT product_name, quantity_needed, unit
|
||||
FROM recipe_ingredients
|
||||
WHERE recipe_id = %s
|
||||
ORDER BY product_name
|
||||
""", (recipe_id,))
|
||||
recipe['ingredients'] = cursor.fetchall()
|
||||
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
return jsonify({
|
||||
'status': 'success',
|
||||
'recipe': recipe
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get recipe error: {str(e)}")
|
||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return jsonify({'status': 'error', 'message': 'Endpoint tidak ditemukan'}), 404
|
||||
|
|
@ -501,7 +584,7 @@ if __name__ == '__main__':
|
|||
logger.info(f"Model: {metadata['model_type']}")
|
||||
logger.info(f"Accuracy (R²): {metadata['r2_score']:.4f}")
|
||||
logger.info(f"Features: {len(feature_columns)}")
|
||||
logger.info("Endpoints: /health, /metadata, /info, /prediksi, /batch-prediksi")
|
||||
logger.info("Endpoints: /health, /metadata, /info, /prediksi, /batch-prediksi, /products, /transactions, /predictions, /recipes")
|
||||
logger.info("Access API at: http://localhost:5000")
|
||||
logger.info("=" * 80)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue