From 9d7bd0c6cdd1ee181f4dc27c0eb56ff885101208 Mon Sep 17 00:00:00 2001 From: rhanarmt Date: Thu, 16 Apr 2026 22:03:01 +0700 Subject: [PATCH] implementasi frontend untuk fitur prediksi kebutuhan bahan --- lib/screens/prediction_screen.dart | 157 +++++++++++++++++------------ lib/services/ml_service.dart | 54 +++++++++- ml_model/app.py | 85 +++++++++++++++- 3 files changed, 228 insertions(+), 68 deletions(-) diff --git a/lib/screens/prediction_screen.dart b/lib/screens/prediction_screen.dart index 6e4226f..6b58156 100644 --- a/lib/screens/prediction_screen.dart +++ b/lib/screens/prediction_screen.dart @@ -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 { String? _selectedRecipe; int _productionQuantity = 0; bool _isCalculated = false; + bool _isLoading = true; - // Produk yang bisa dibuat - final List recipes = [ - 'Donat', - 'Roti Putih', - 'Kue Brownies', - 'Kue Tart', - ]; - - // Resep untuk setiap produk (ingredient: gram/butir per unit) - final Map> 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> recipes = []; + Map> recipeIngredients = {}; // Stok saat ini (sama dengan di product list) final Map currentStock = { @@ -69,25 +32,58 @@ class _PredictionScreenState extends State { 'Baking Powder': 60000, // gram }; - // Satuan untuk setiap ingredient - final Map 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 _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 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 = {}; + + ingredients.forEach((productName, details) { + final quantity = (details['quantity'] as num).toInt(); + required[productName] = quantity * _productionQuantity; + }); + + return required; } Map get insufficientStock { @@ -106,6 +102,15 @@ class _PredictionScreenState extends State { 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 { ), ), ), - 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 { ), items: recipes .map((recipe) => - DropdownMenuItem( - value: recipe, + DropdownMenuItem( + 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 { 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 { .map((entry) { final ingredient = entry.key; final deficitAmount = entry.value; - final unit = - ingredientUnits[ingredient] ?? 'gr'; + final unit = _getIngredientUnit(ingredient); return Padding( padding: diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index 95e8d29..a8f865b 100644 --- a/lib/services/ml_service.dart +++ b/lib/services/ml_service.dart @@ -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>> 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>.from(data['recipes']); + } + } + return []; + } catch (e) { + print('Get recipes error: $e'); + return []; + } + } + + /// Get specific recipe by ID with ingredients + static Future?> 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; + } + } } diff --git a/ml_model/app.py b/ml_model/app.py index fd5130b..80fecb6 100644 --- a/ml_model/app.py +++ b/ml_model/app.py @@ -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/', 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)