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: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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue