Fitur Checklist pada kebutuhan bahan

This commit is contained in:
rhanarmt 2026-04-30 14:40:37 +07:00
parent eae95fa4eb
commit e08e9c91e9
3 changed files with 290 additions and 197 deletions

View File

@ -9,6 +9,7 @@ class PredictionController extends ChangeNotifier {
List<Map<String, dynamic>> recipes = []; List<Map<String, dynamic>> recipes = [];
Map<String, Map<String, dynamic>> recipeIngredients = {}; Map<String, Map<String, dynamic>> recipeIngredients = {};
Map<String, bool> ingredientSelections = {};
final Map<String, int> currentStock = { final Map<String, int> currentStock = {
'Tepung Terigu 1kg': 45000, 'Tepung Terigu 1kg': 45000,
@ -46,6 +47,8 @@ class PredictionController extends ChangeNotifier {
} }
} }
_initializeIngredientSelections();
return null; return null;
} catch (e) { } catch (e) {
return 'Gagal memuat resep: $e'; return 'Gagal memuat resep: $e';
@ -58,6 +61,7 @@ class PredictionController extends ChangeNotifier {
void setSelectedRecipe(String? value) { void setSelectedRecipe(String? value) {
selectedRecipe = value; selectedRecipe = value;
isCalculated = false; isCalculated = false;
_initializeIngredientSelections();
notifyListeners(); notifyListeners();
} }
@ -78,9 +82,31 @@ class PredictionController extends ChangeNotifier {
selectedRecipe = null; selectedRecipe = null;
productionQuantity = 0; productionQuantity = 0;
isCalculated = false; isCalculated = false;
ingredientSelections = {};
notifyListeners(); 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, int> get requiredIngredients { Map<String, int> get requiredIngredients {
if (selectedRecipe == null || productionQuantity == 0) { if (selectedRecipe == null || productionQuantity == 0) {
return {}; return {};
@ -90,6 +116,7 @@ class PredictionController extends ChangeNotifier {
final required = <String, int>{}; final required = <String, int>{};
ingredients.forEach((productName, details) { ingredients.forEach((productName, details) {
if (!isIngredientSelected(productName)) return;
final quantity = (details['quantity'] as num).toInt(); final quantity = (details['quantity'] as num).toInt();
required[productName] = quantity * productionQuantity; required[productName] = quantity * productionQuantity;
}); });

View File

@ -31,6 +31,12 @@ class _PredictionScreenState extends State<PredictionScreen> {
return AnimatedBuilder( return AnimatedBuilder(
animation: _controller, animation: _controller,
builder: (context, _) { builder: (context, _) {
final selectedRecipe = _controller.selectedRecipe;
final displayIngredients =
selectedRecipe == null
? <String, Map<String, dynamic>>{}
: (_controller.recipeIngredients[selectedRecipe] ??
<String, Map<String, dynamic>>{});
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: PreferredSize( appBar: PreferredSize(
@ -526,212 +532,272 @@ class _PredictionScreenState extends State<PredictionScreen> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Container( if (displayIngredients.isEmpty)
decoration: BoxDecoration( Container(
color: Colors.white, width: double.infinity,
borderRadius: BorderRadius.circular(12), padding: const EdgeInsets.all(16),
boxShadow: [ decoration: BoxDecoration(
BoxShadow( color: Colors.white,
color: Colors.black.withOpacity(0.05), borderRadius: BorderRadius.circular(12),
blurRadius: 10, boxShadow: [
offset: const Offset(0, 2), BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: const Text(
'Belum ada bahan untuk produk ini.',
style: TextStyle(
fontSize: 12,
color: Color(0xFF6B7280),
), ),
], ),
), )
child: Column( else
children: Container(
_controller.requiredIngredients.entries.toList().asMap().entries.map(( decoration: BoxDecoration(
entry, color: Colors.white,
) { borderRadius: BorderRadius.circular(12),
final isLast = boxShadow: [
entry.key == BoxShadow(
_controller color: Colors.black.withOpacity(0.05),
.requiredIngredients blurRadius: 10,
.length - offset: const Offset(0, 2),
1; ),
final ingredient = entry.value.key; ],
final neededAmount = ),
entry.value.value; child: Column(
final availableAmount = children:
_controller displayIngredients.entries.toList().asMap().entries.map((
.currentStock[ingredient] ?? entry,
0; ) {
final unit = _controller final isLast =
.getIngredientUnit(ingredient); entry.key ==
final isSufficient = displayIngredients.length - 1;
availableAmount >= neededAmount; final ingredient = entry.value.key;
final details = entry.value.value;
final isSelected = _controller
.isIngredientSelected(
ingredient,
);
final unit =
(details['unit'] as String?) ??
_controller.getIngredientUnit(
ingredient,
);
final quantityPerUnit =
(details['quantity'] as num)
.toInt();
final neededAmount =
isSelected
? quantityPerUnit *
_controller
.productionQuantity
: 0;
final availableAmount =
_controller
.currentStock[ingredient] ??
0;
final isSufficient =
availableAmount >= neededAmount;
final statusColor =
isSelected
? (isSufficient
? const Color(
0xFF10B981,
)
: const Color(
0xFFDC2626,
))
: const Color(0xFF9CA3AF);
final statusLabel =
isSelected
? (isSufficient
? 'Aman'
: 'Kurang')
: 'Diabaikan';
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all( padding: const EdgeInsets.all(
14, 14,
), ),
child: Column( child: Column(
children: [ children: [
Row( Row(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment CrossAxisAlignment
.start, .start,
children: [ children: [
Expanded( Checkbox(
child: Column( value: isSelected,
crossAxisAlignment: onChanged: (value) {
CrossAxisAlignment _controller
.start, .setIngredientSelection(
children: [
Text(
_controller
.cleanIngredientName(
ingredient,
),
style: const TextStyle(
fontSize: 13,
fontWeight:
FontWeight
.w600,
color: Color(
0xFF1F2937,
),
),
),
const SizedBox(
height: 6,
),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment
.start,
children: [
const Text(
'Kebutuhan',
style: TextStyle(
fontSize:
11,
color: Color(
0xFF9CA3AF,
),
),
),
Text(
'$neededAmount $unit',
style: const TextStyle(
fontSize:
12,
fontWeight:
FontWeight.w700,
color: Color(
0xFF1F2937,
),
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment
.start,
children: [
const Text(
'Stok Saat Ini',
style: TextStyle(
fontSize:
11,
color: Color(
0xFF9CA3AF,
),
),
),
Text(
'$availableAmount $unit',
style: const TextStyle(
fontSize:
12,
fontWeight:
FontWeight.w700,
color: Color(
0xFF1F2937,
),
),
),
],
),
),
],
),
],
),
),
const SizedBox(
width: 10,
),
Container(
padding:
const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: _controller
.getStatusColor(
ingredient,
)
.withOpacity(
0.15,
),
borderRadius:
BorderRadius.circular(
8,
),
border: Border.all(
color: _controller
.getStatusColor(
ingredient, ingredient,
) value ??
false,
);
},
activeColor:
const Color(
0xFFA89080,
),
),
const SizedBox(
width: 4,
),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment
.start,
children: [
Text(
_controller
.cleanIngredientName(
ingredient,
),
style: const TextStyle(
fontSize:
13,
fontWeight:
FontWeight
.w600,
color: Color(
0xFF1F2937,
),
),
),
const SizedBox(
height: 6,
),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const Text(
'Kebutuhan',
style: TextStyle(
fontSize:
11,
color: Color(
0xFF9CA3AF,
),
),
),
Text(
'$neededAmount $unit',
style: const TextStyle(
fontSize:
12,
fontWeight:
FontWeight.w700,
color: Color(
0xFF1F2937,
),
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const Text(
'Stok Saat Ini',
style: TextStyle(
fontSize:
11,
color: Color(
0xFF9CA3AF,
),
),
),
Text(
'$availableAmount $unit',
style: const TextStyle(
fontSize:
12,
fontWeight:
FontWeight.w700,
color: Color(
0xFF1F2937,
),
),
),
],
),
),
],
),
],
),
),
const SizedBox(
width: 10,
),
Container(
padding:
const EdgeInsets.symmetric(
horizontal:
10,
vertical: 6,
),
decoration: BoxDecoration(
color: statusColor
.withOpacity( .withOpacity(
0.3, 0.15,
), ),
width: 1, borderRadius:
BorderRadius.circular(
8,
),
border: Border.all(
color: statusColor
.withOpacity(
0.3,
),
width: 1,
),
),
child: Text(
statusLabel,
style: TextStyle(
fontSize: 11,
fontWeight:
FontWeight
.w700,
color:
statusColor,
),
), ),
), ),
child: Text( ],
isSufficient ),
? 'Aman' ],
: 'Kurang',
style: TextStyle(
fontSize: 11,
fontWeight:
FontWeight
.w700,
color: _controller
.getStatusColor(
ingredient,
),
),
),
),
],
),
],
),
),
if (!isLast)
Container(
height: 1,
color: const Color(
0xFFF3F4F6,
), ),
), ),
], if (!isLast)
); Container(
}).toList(), height: 1,
color: const Color(
0xFFF3F4F6,
),
),
],
);
}).toList(),
),
), ),
),
const SizedBox(height: 16), const SizedBox(height: 16),
if (!_controller.isStockSufficient) ...[ if (!_controller.isStockSufficient) ...[
const Text( const Text(

View File

@ -5,7 +5,7 @@ class MLService {
// API URL - Change based on environment // API URL - Change based on environment
// Untuk emulator Android: 10.0.2.2 // Untuk emulator Android: 10.0.2.2
// Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local // Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local
static const String baseUrl = 'http://192.168.110.16:5000'; static const String baseUrl = 'http://192.168.1.62:5000 ';
static const int timeoutSeconds = 30; static const int timeoutSeconds = 30;