implementasi frontend untuk fitur prediksi kebutuhan bahan

This commit is contained in:
rhanarmt 2026-04-16 22:03:01 +07:00
parent 8c2c61330e
commit 9d7bd0c6cd
3 changed files with 228 additions and 68 deletions

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/colors.dart';
import 'package:finalproject/theme/text_styles.dart'; import 'package:finalproject/theme/text_styles.dart';
import 'package:finalproject/services/ml_service.dart';
class PredictionScreen extends StatefulWidget { class PredictionScreen extends StatefulWidget {
const PredictionScreen({Key? key}) : super(key: key); const PredictionScreen({Key? key}) : super(key: key);
@ -13,49 +14,11 @@ class _PredictionScreenState extends State<PredictionScreen> {
String? _selectedRecipe; String? _selectedRecipe;
int _productionQuantity = 0; int _productionQuantity = 0;
bool _isCalculated = false; bool _isCalculated = false;
bool _isLoading = true;
// Produk yang bisa dibuat // Data dari API
final List<String> recipes = [ List<Map<String, dynamic>> recipes = [];
'Donat', Map<String, Map<String, dynamic>> recipeIngredients = {};
'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,
},
};
// Stok saat ini (sama dengan di product list) // Stok saat ini (sama dengan di product list)
final Map<String, int> currentStock = { final Map<String, int> currentStock = {
@ -69,25 +32,58 @@ class _PredictionScreenState extends State<PredictionScreen> {
'Baking Powder': 60000, // gram 'Baking Powder': 60000, // gram
}; };
// Satuan untuk setiap ingredient @override
final Map<String, String> ingredientUnits = { void initState() {
'Tepung Terigu 1kg': 'gr', super.initState();
'Telur 1kg': 'butir', _loadRecipes();
'Gula Pasir 1kg': 'gr', }
'Susu Bubuk': 'gr',
'Cokelat Bubuk 250gr': 'gr', Future<void> _loadRecipes() async {
'Mentega 500gr': 'gr', try {
'Keju Parut 250gr': 'gr', final fetchedRecipes = await MLService.getRecipes();
'Baking Powder': 'gr',
}; 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 { Map<String, int> get requiredIngredients {
if (_selectedRecipe == null || _productionQuantity == 0) { if (_selectedRecipe == null || _productionQuantity == 0) {
return {}; return {};
} }
final recipe = recipeDetails[_selectedRecipe]!;
return recipe.map((ingredient, amount) => final ingredients = recipeIngredients[_selectedRecipe] ?? {};
MapEntry(ingredient, amount * _productionQuantity)); 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 { Map<String, int> get insufficientStock {
@ -106,6 +102,15 @@ class _PredictionScreenState extends State<PredictionScreen> {
bool get isStockSufficient => insufficientStock.isEmpty; 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) { Color _getStatusColor(String ingredient) {
final required = requiredIngredients[ingredient] ?? 0; final required = requiredIngredients[ingredient] ?? 0;
final available = currentStock[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( child: Column(
children: [ children: [
Padding( Padding(
@ -313,13 +345,13 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
items: recipes items: recipes
.map((recipe) => .map((recipe) =>
DropdownMenuItem( DropdownMenuItem<String>(
value: recipe, value: recipe['recipe_name'] as String,
child: Padding( child: Padding(
padding: padding:
const EdgeInsets.symmetric( const EdgeInsets.symmetric(
horizontal: 12), horizontal: 12),
child: Text(recipe), child: Text(recipe['recipe_name'] as String),
), ),
)) ))
.toList(), .toList(),
@ -561,7 +593,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
final neededAmount = entry.value.value; final neededAmount = entry.value.value;
final availableAmount = final availableAmount =
currentStock[ingredient] ?? 0; currentStock[ingredient] ?? 0;
final unit = ingredientUnits[ingredient] ?? 'gr'; final unit = _getIngredientUnit(ingredient);
final isSufficient = final isSufficient =
availableAmount >= neededAmount; availableAmount >= neededAmount;
@ -727,8 +759,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
.map((entry) { .map((entry) {
final ingredient = entry.key; final ingredient = entry.key;
final deficitAmount = entry.value; final deficitAmount = entry.value;
final unit = final unit = _getIngredientUnit(ingredient);
ingredientUnits[ingredient] ?? 'gr';
return Padding( return Padding(
padding: padding:

View File

@ -3,8 +3,9 @@ import 'dart:convert';
class MLService { class MLService {
// API URL - Change based on environment // API URL - Change based on environment
static const String baseUrl = 'http://127.0.0.1:5000'; // Untuk emulator Android: 10.0.2.2
// For remote access: 'http://192.168.1.75:5000' // 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; static const int timeoutSeconds = 30;
@ -159,7 +160,7 @@ class MLService {
} else { } else {
return { return {
'status': 'error', 'status': 'error',
'message': 'Server error: ${response.statusCode}' 'message': 'Server error: ${response.statusCode}',
}; };
} }
} catch (e) { } catch (e) {
@ -315,7 +316,8 @@ class MLService {
'prediction_date': predictionDate, 'prediction_date': predictionDate,
'predicted_quantity': predictedQuantity, 'predicted_quantity': predictedQuantity,
if (rawValue != null) 'raw_value': rawValue, 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 (accuracyR2 != null) 'accuracy_r2': accuracyR2,
if (errorMae != null) 'error_mae': errorMae, if (errorMae != null) 'error_mae': errorMae,
}; };
@ -338,4 +340,48 @@ class MLService {
return false; 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;
}
}
} }

View File

@ -481,6 +481,89 @@ def save_prediction():
return jsonify({'status': 'error', 'message': str(e)}), 500 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) @app.errorhandler(404)
def not_found(error): def not_found(error):
return jsonify({'status': 'error', 'message': 'Endpoint tidak ditemukan'}), 404 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"Model: {metadata['model_type']}")
logger.info(f"Accuracy (R²): {metadata['r2_score']:.4f}") logger.info(f"Accuracy (R²): {metadata['r2_score']:.4f}")
logger.info(f"Features: {len(feature_columns)}") 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("Access API at: http://localhost:5000")
logger.info("=" * 80) logger.info("=" * 80)