memperbarui beberapa fitur 3
This commit is contained in:
parent
b0ed5950c2
commit
5f2f97d693
|
|
@ -571,17 +571,17 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.all(12),
|
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: 18,
|
left: 20,
|
||||||
right: 18,
|
right: 20,
|
||||||
top: 12,
|
top: 10,
|
||||||
bottom: 18 + MediaQuery.of(context).padding.bottom,
|
bottom: 16 + MediaQuery.of(context).padding.bottom,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.bgWhite,
|
color: AppColors.bgWhite,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
boxShadow: [AppColors.shadowMedium],
|
boxShadow: [AppColors.shadowLight],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|
@ -592,26 +592,26 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 4,
|
height: 4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.grey200,
|
color: AppColors.grey300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 20),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 56,
|
width: 52,
|
||||||
height: 56,
|
height: 52,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: statusColor.withValues(alpha: 0.12),
|
color: statusColor.withValues(alpha: 0.12),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_controller.getCategoryIcon(product.category),
|
_controller.getCategoryIcon(product.category),
|
||||||
color: statusColor,
|
color: statusColor,
|
||||||
size: 28,
|
size: 26,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
|
|
@ -636,14 +636,15 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 9,
|
||||||
vertical: 6,
|
vertical: 5,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: statusColor.withValues(alpha: 0.14),
|
color: statusColor.withValues(alpha: 0.10),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(999),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_controller.getStatusLabel(product.status),
|
_controller.getStatusLabel(product.status),
|
||||||
|
|
@ -655,12 +656,8 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 20),
|
||||||
Container(height: 1, color: AppColors.grey200),
|
_buildDetailGrid(
|
||||||
const SizedBox(height: 16),
|
|
||||||
Wrap(
|
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: [
|
children: [
|
||||||
_buildDetailTile(
|
_buildDetailTile(
|
||||||
icon: Icons.category_outlined,
|
icon: Icons.category_outlined,
|
||||||
|
|
@ -694,25 +691,48 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 18),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Kapasitas stok',
|
||||||
|
style: AppTextStyles.labelSmall.copyWith(
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'$stockPercentage%',
|
||||||
|
style: AppTextStyles.labelSmall.copyWith(
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(999),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: maxStock == 0 ? 0 : product.stock / maxStock,
|
value: maxStock == 0 ? 0 : product.stock / maxStock,
|
||||||
minHeight: 8,
|
minHeight: 7,
|
||||||
backgroundColor: AppColors.grey200,
|
backgroundColor: AppColors.grey200,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(statusColor),
|
valueColor: AlwaysStoppedAnimation<Color>(statusColor),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 11,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: statusColor.withValues(alpha: 0.08),
|
color: statusColor.withValues(alpha: 0.07),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: statusColor.withValues(alpha: 0.18),
|
color: statusColor.withValues(alpha: 0.14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
@ -723,7 +743,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 18),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -732,9 +752,11 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
icon: const Icon(Icons.close_rounded),
|
icon: const Icon(Icons.close_rounded),
|
||||||
label: const Text('Tutup'),
|
label: const Text('Tutup'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
foregroundColor: AppColors.textSecondary,
|
||||||
|
side: const BorderSide(color: AppColors.grey300),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -751,9 +773,10 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.statusSuccess,
|
backgroundColor: AppColors.statusSuccess,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
elevation: 0,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -767,42 +790,72 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildDetailGrid({required List<Widget> children}) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
const spacing = 10.0;
|
||||||
|
final tileWidth = (constraints.maxWidth - spacing) / 2;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: spacing,
|
||||||
|
runSpacing: spacing,
|
||||||
|
children:
|
||||||
|
children.map((child) {
|
||||||
|
return SizedBox(width: tileWidth, child: child);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildDetailTile({
|
Widget _buildDetailTile({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String label,
|
required String label,
|
||||||
required String value,
|
required String value,
|
||||||
}) {
|
}) {
|
||||||
return SizedBox(
|
return Container(
|
||||||
width: (MediaQuery.of(context).size.width - 60) / 2,
|
constraints: const BoxConstraints(minHeight: 68),
|
||||||
child: Container(
|
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 10),
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.bgLight,
|
color: AppColors.bgWhite,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: AppColors.grey200),
|
border: Border.all(color: AppColors.grey200),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 18, color: AppColors.primaryBrown),
|
Container(
|
||||||
const SizedBox(width: 8),
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.bgLight,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 17, color: AppColors.primaryBrown),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 9),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: AppTextStyles.labelSmall.copyWith(
|
maxLines: 1,
|
||||||
color: AppColors.textTertiary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: AppTextStyles.labelSmall.copyWith(
|
style: AppTextStyles.labelSmall.copyWith(
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: AppTextStyles.labelMedium.copyWith(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -810,7 +863,6 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ class ReportController extends ChangeNotifier {
|
||||||
final List<Map<String, dynamic>> criticalItems = [];
|
final List<Map<String, dynamic>> criticalItems = [];
|
||||||
final List<Map<String, dynamic>> usageSummary = [];
|
final List<Map<String, dynamic>> usageSummary = [];
|
||||||
final List<double> demandTrend = [];
|
final List<double> demandTrend = [];
|
||||||
|
final Map<String, List<Map<String, dynamic>>> _recipeIngredients = {};
|
||||||
|
|
||||||
int totalProduk = 0;
|
int totalProduk = 0;
|
||||||
int totalBahan = 0;
|
int totalBahan = 0;
|
||||||
|
|
@ -40,6 +41,7 @@ class ReportController extends ChangeNotifier {
|
||||||
MLService.getReportPredictions(),
|
MLService.getReportPredictions(),
|
||||||
MLService.getProducts(),
|
MLService.getProducts(),
|
||||||
MLService.getDashboardSummary(),
|
MLService.getDashboardSummary(),
|
||||||
|
MLService.getRecipes(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final stockResponse = results[0] as Map<String, dynamic>;
|
final stockResponse = results[0] as Map<String, dynamic>;
|
||||||
|
|
@ -47,6 +49,7 @@ class ReportController extends ChangeNotifier {
|
||||||
final predictionResponse = results[2] as Map<String, dynamic>;
|
final predictionResponse = results[2] as Map<String, dynamic>;
|
||||||
final productsResponse = results[3];
|
final productsResponse = results[3];
|
||||||
final dashboardSummary = results[4] as Map<String, dynamic>;
|
final dashboardSummary = results[4] as Map<String, dynamic>;
|
||||||
|
final recipesResponse = results[5];
|
||||||
|
|
||||||
if (stockResponse['status'] != true) {
|
if (stockResponse['status'] != true) {
|
||||||
errorMessage = stockResponse['message']?.toString();
|
errorMessage = stockResponse['message']?.toString();
|
||||||
|
|
@ -74,6 +77,12 @@ class ReportController extends ChangeNotifier {
|
||||||
criticalItems.clear();
|
criticalItems.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recipesResponse is List) {
|
||||||
|
_applyRecipes(recipesResponse);
|
||||||
|
} else {
|
||||||
|
_recipeIngredients.clear();
|
||||||
|
}
|
||||||
|
|
||||||
if (dashboardSummary['status'] != true) {
|
if (dashboardSummary['status'] != true) {
|
||||||
errorMessage ??= dashboardSummary['message']?.toString();
|
errorMessage ??= dashboardSummary['message']?.toString();
|
||||||
usageSummary.clear();
|
usageSummary.clear();
|
||||||
|
|
@ -81,6 +90,10 @@ class ReportController extends ChangeNotifier {
|
||||||
_applyUsageSummary(dashboardSummary['penggunaan_bahan']);
|
_applyUsageSummary(dashboardSummary['penggunaan_bahan']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (usageSummary.isEmpty) {
|
||||||
|
_rebuildUsageSummaryFromPredictions();
|
||||||
|
}
|
||||||
|
|
||||||
_rebuildSummary();
|
_rebuildSummary();
|
||||||
_rebuildDemandTrend();
|
_rebuildDemandTrend();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -181,6 +194,37 @@ class ReportController extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _applyRecipes(List<dynamic> recipes) {
|
||||||
|
_recipeIngredients.clear();
|
||||||
|
|
||||||
|
for (final recipe in recipes) {
|
||||||
|
if (recipe is! Map) continue;
|
||||||
|
|
||||||
|
final recipeName = recipe['recipe_name']?.toString() ?? '';
|
||||||
|
if (recipeName.isEmpty) continue;
|
||||||
|
|
||||||
|
final ingredients = recipe['ingredients'];
|
||||||
|
if (ingredients is! List) continue;
|
||||||
|
|
||||||
|
_recipeIngredients[_nameKey(recipeName)] =
|
||||||
|
ingredients
|
||||||
|
.whereType<Map>()
|
||||||
|
.map(
|
||||||
|
(ingredient) => {
|
||||||
|
'label': ingredient['product_name']?.toString() ?? '-',
|
||||||
|
'value': _toDouble(ingredient['quantity_needed']),
|
||||||
|
'unit': _normalizeUnit(ingredient['unit']?.toString() ?? ''),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
(ingredient) =>
|
||||||
|
(ingredient['label'] as String).isNotEmpty &&
|
||||||
|
(ingredient['value'] as double) > 0,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _applyUsageSummary(dynamic data) {
|
void _applyUsageSummary(dynamic data) {
|
||||||
usageSummary.clear();
|
usageSummary.clear();
|
||||||
if (data is! List) return;
|
if (data is! List) return;
|
||||||
|
|
@ -201,6 +245,134 @@ class ReportController extends ChangeNotifier {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _rebuildUsageSummaryFromPredictions() {
|
||||||
|
final Map<String, Map<String, dynamic>> totals = {};
|
||||||
|
|
||||||
|
for (final item in predictionItems) {
|
||||||
|
final needs = item['needs']?.toString() ?? '';
|
||||||
|
final ingredients =
|
||||||
|
needs.isEmpty || needs == 'Belum tersedia'
|
||||||
|
? _estimateUsageFromRecipe(item)
|
||||||
|
: _parseEstimatedNeeds(needs);
|
||||||
|
|
||||||
|
for (final ingredient in ingredients) {
|
||||||
|
final label = ingredient['label'] as String;
|
||||||
|
final normalizedQuantity = _normalizeUsageQuantity(
|
||||||
|
value: ingredient['value'] as double,
|
||||||
|
unit: ingredient['unit'] as String,
|
||||||
|
);
|
||||||
|
final unit = normalizedQuantity['unit'] as String;
|
||||||
|
final value = normalizedQuantity['value'] as double;
|
||||||
|
if (label.isEmpty || value <= 0) continue;
|
||||||
|
|
||||||
|
final key = '${label.toLowerCase()}|$unit';
|
||||||
|
final current = totals[key];
|
||||||
|
if (current == null) {
|
||||||
|
totals[key] = {'label': label, 'value': value, 'unit': unit};
|
||||||
|
} else {
|
||||||
|
current['value'] = (current['value'] as double) + value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usageSummary
|
||||||
|
..clear()
|
||||||
|
..addAll(totals.values);
|
||||||
|
|
||||||
|
usageSummary.sort(
|
||||||
|
(a, b) => (b['value'] as double).compareTo(a['value'] as double),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (usageSummary.length > 5) {
|
||||||
|
usageSummary.removeRange(5, usageSummary.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _parseEstimatedNeeds(String text) {
|
||||||
|
final entries = <Map<String, dynamic>>[];
|
||||||
|
final normalized = text.trim();
|
||||||
|
if (normalized.isEmpty || normalized == 'Belum tersedia') return entries;
|
||||||
|
|
||||||
|
for (final part in normalized.split(',')) {
|
||||||
|
final separatorIndex = part.indexOf(':');
|
||||||
|
if (separatorIndex <= 0) continue;
|
||||||
|
|
||||||
|
final label = part.substring(0, separatorIndex).trim();
|
||||||
|
final quantityText = part.substring(separatorIndex + 1).trim();
|
||||||
|
final match = RegExp(
|
||||||
|
r'^([0-9]+(?:[.,][0-9]+)?)\s*([a-zA-Z]+)?',
|
||||||
|
).firstMatch(quantityText);
|
||||||
|
if (match == null) continue;
|
||||||
|
|
||||||
|
final value = _toDouble(match.group(1)?.replaceAll(',', '.'));
|
||||||
|
final unit = _normalizeUnit(match.group(2) ?? 'unit');
|
||||||
|
entries.add({'label': label, 'value': value, 'unit': unit});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _estimateUsageFromRecipe(
|
||||||
|
Map<String, dynamic> prediction,
|
||||||
|
) {
|
||||||
|
final productName = prediction['product']?.toString() ?? '';
|
||||||
|
final ingredients = _recipeIngredients[_nameKey(productName)];
|
||||||
|
if (ingredients == null || ingredients.isEmpty) return [];
|
||||||
|
|
||||||
|
final predictedQuantity = _toDouble(prediction['prediction']);
|
||||||
|
if (predictedQuantity <= 0) return [];
|
||||||
|
|
||||||
|
return ingredients.map((ingredient) {
|
||||||
|
return {
|
||||||
|
'label': ingredient['label'],
|
||||||
|
'value': (ingredient['value'] as double) * predictedQuantity,
|
||||||
|
'unit': ingredient['unit'],
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeUnit(String unit) {
|
||||||
|
final normalized = unit.trim().toLowerCase();
|
||||||
|
if (['g', 'gr', 'gram', 'grams'].contains(normalized)) return 'gr';
|
||||||
|
if (['kg', 'kilogram', 'kilograms'].contains(normalized)) return 'kg';
|
||||||
|
if (['ml', 'mili', 'mililiter', 'milliliter'].contains(normalized)) {
|
||||||
|
return 'ml';
|
||||||
|
}
|
||||||
|
if (['l', 'lt', 'ltr', 'liter', 'litre'].contains(normalized)) return 'l';
|
||||||
|
if (['pcs', 'piece', 'pieces'].contains(normalized)) return 'pcs';
|
||||||
|
if (['butir'].contains(normalized)) return 'butir';
|
||||||
|
return normalized.isEmpty ? 'unit' : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _normalizeUsageQuantity({
|
||||||
|
required double value,
|
||||||
|
required String unit,
|
||||||
|
}) {
|
||||||
|
switch (_normalizeUnit(unit)) {
|
||||||
|
case 'gr':
|
||||||
|
return {'value': value / 1000, 'unit': 'kg'};
|
||||||
|
case 'ml':
|
||||||
|
return {'value': value / 1000, 'unit': 'l'};
|
||||||
|
default:
|
||||||
|
return {'value': value, 'unit': _normalizeUnit(unit)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _nameKey(String value) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.replaceAll(
|
||||||
|
RegExp(
|
||||||
|
r'\s+\d+([,.]\d+)?\s*(kg|kilogram|g|gr|gram|ml|l|liter|ltr|butir|pcs)\b',
|
||||||
|
caseSensitive: false,
|
||||||
|
),
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
void _rebuildSummary() {
|
void _rebuildSummary() {
|
||||||
totalBahan = stockItems.length;
|
totalBahan = stockItems.length;
|
||||||
totalProduk = _productCount;
|
totalProduk = _productCount;
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
final DateFormat _fileDateFormat = DateFormat('yyyyMMdd_HHmmss');
|
final DateFormat _fileDateFormat = DateFormat('yyyyMMdd_HHmmss');
|
||||||
final ScrollController _stockTableScrollController = ScrollController();
|
final ScrollController _stockTableScrollController = ScrollController();
|
||||||
final ScrollController _stockHistoryScrollController = ScrollController();
|
final ScrollController _stockHistoryScrollController = ScrollController();
|
||||||
|
final ScrollController _predictionScrollController = ScrollController();
|
||||||
|
|
||||||
late final ReportController _controller;
|
late final ReportController _controller;
|
||||||
String? _lastExportPath;
|
String? _lastExportPath;
|
||||||
|
|
@ -72,10 +73,6 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
final width = MediaQuery.of(context).size.width;
|
final width = MediaQuery.of(context).size.width;
|
||||||
final cardWidth = (width - 48) / 2;
|
final cardWidth = (width - 48) / 2;
|
||||||
|
|
||||||
final usageBars = _buildUsageBars(_controller.usageSummary);
|
|
||||||
final usagePie = _buildUsagePie(usageBars);
|
|
||||||
final List<double> demandTrend = _controller.demandTrend;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.bgLight,
|
backgroundColor: AppColors.bgLight,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -96,6 +93,10 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final usageBars = _buildUsageBars(_controller.usageSummary);
|
||||||
|
final usagePie = _buildUsagePie(usageBars);
|
||||||
|
final List<double> demandTrend = _controller.demandTrend;
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => _controller.loadReports(),
|
onRefresh: () => _controller.loadReports(),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
|
|
@ -159,15 +160,27 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildSectionTitle('Laporan Stok Bahan'),
|
_buildSectionTitle(
|
||||||
|
'Laporan Stok Bahan',
|
||||||
|
icon: Icons.inventory_2_outlined,
|
||||||
|
subtitle: 'Pantau stok tersedia dan status tiap bahan.',
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildStockTable(_controller.stockItems),
|
_buildStockTable(_controller.stockItems),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildSectionTitle('Riwayat Stok Masuk'),
|
_buildSectionTitle(
|
||||||
|
'Riwayat Stok Masuk',
|
||||||
|
icon: Icons.playlist_add_check_rounded,
|
||||||
|
subtitle: 'Catatan bahan yang baru ditambahkan.',
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildStockHistory(_controller.stockHistory),
|
_buildStockHistory(_controller.stockHistory),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_buildSectionTitle('Laporan Prediksi Permintaan'),
|
_buildSectionTitle(
|
||||||
|
'Laporan Prediksi Permintaan',
|
||||||
|
icon: Icons.auto_graph_rounded,
|
||||||
|
subtitle: 'Ringkasan prediksi kebutuhan produk terbaru.',
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildPredictionList(_controller.predictionItems),
|
_buildPredictionList(_controller.predictionItems),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
@ -291,248 +304,371 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionTitle(String title) {
|
Widget _buildSectionTitle(String title, {IconData? icon, String? subtitle}) {
|
||||||
return Text(
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.bgWhite,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: AppColors.grey200),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 18, color: AppColors.primaryBrown),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
title,
|
title,
|
||||||
style: AppTextStyles.headlineSmall.copyWith(color: AppColors.textPrimary),
|
style: AppTextStyles.headlineSmall.copyWith(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStockTable(List<Map<String, dynamic>> items) {
|
Widget _buildStockTable(List<Map<String, dynamic>> items) {
|
||||||
return Container(
|
if (items.isEmpty) {
|
||||||
decoration: BoxDecoration(
|
return _buildReportEmptyState(
|
||||||
color: AppColors.bgWhite,
|
icon: Icons.inventory_2_outlined,
|
||||||
borderRadius: BorderRadius.circular(16),
|
message: 'Data stok bahan belum tersedia.',
|
||||||
boxShadow: [AppColors.shadowLight],
|
);
|
||||||
),
|
}
|
||||||
child:
|
|
||||||
items.isEmpty
|
final sortedItems = _sortedStockItems(items);
|
||||||
? Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
return SizedBox(
|
||||||
child: Text(
|
height: _reportListHeight(sortedItems.length, itemHeight: 92),
|
||||||
'Data stok bahan belum tersedia.',
|
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: SizedBox(
|
|
||||||
height: _sectionMaxHeight(),
|
|
||||||
child: Scrollbar(
|
child: Scrollbar(
|
||||||
thumbVisibility: true,
|
thumbVisibility: true,
|
||||||
controller: _stockTableScrollController,
|
controller: _stockTableScrollController,
|
||||||
child: SingleChildScrollView(
|
child: ListView.separated(
|
||||||
controller: _stockTableScrollController,
|
controller: _stockTableScrollController,
|
||||||
child: SingleChildScrollView(
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
scrollDirection: Axis.horizontal,
|
padding: EdgeInsets.zero,
|
||||||
child: DataTable(
|
itemCount: sortedItems.length,
|
||||||
columns: const [
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
DataColumn(label: Text('Nama Bahan')),
|
itemBuilder: (context, index) {
|
||||||
DataColumn(label: Text('Stok Tersedia')),
|
final item = sortedItems[index];
|
||||||
DataColumn(label: Text('Status')),
|
final stockValue = StockStatusUtils.parseStock(item['stock']);
|
||||||
],
|
final statusKey = StockStatusUtils.statusFromStock(stockValue);
|
||||||
rows:
|
final statusColor = StockStatusUtils.color(statusKey);
|
||||||
items.map((item) {
|
final statusLabel = StockStatusUtils.label(statusKey);
|
||||||
final stockValue = StockStatusUtils.parseStock(
|
|
||||||
item['stock'],
|
|
||||||
);
|
|
||||||
final statusKey =
|
|
||||||
StockStatusUtils.statusFromStock(stockValue);
|
|
||||||
final statusColor = StockStatusUtils.color(
|
|
||||||
statusKey,
|
|
||||||
);
|
|
||||||
final statusLabel = StockStatusUtils.label(
|
|
||||||
statusKey,
|
|
||||||
);
|
|
||||||
final unit = item['unit']?.toString() ?? 'kg';
|
final unit = item['unit']?.toString() ?? 'kg';
|
||||||
|
final minimumStock = StockStatusUtils.parseStock(item['min_stock']);
|
||||||
|
|
||||||
return DataRow(
|
return _buildReportItemCard(
|
||||||
cells: [
|
icon: Icons.inventory_2_outlined,
|
||||||
DataCell(Text(item['name'] as String)),
|
iconColor: statusColor,
|
||||||
DataCell(Text('${item['stock']} $unit')),
|
title: item['name']?.toString() ?? '-',
|
||||||
DataCell(
|
subtitle: 'Minimum ${_formatQuantity(minimumStock)} $unit',
|
||||||
Container(
|
trailing: _buildStatusBadge(statusLabel, statusColor),
|
||||||
padding: const EdgeInsets.symmetric(
|
footer: Row(
|
||||||
horizontal: 10,
|
children: [
|
||||||
vertical: 4,
|
_buildMetricPill(
|
||||||
|
label: 'Stok',
|
||||||
|
value: '${_formatQuantity(stockValue)} $unit',
|
||||||
|
color: AppColors.primaryBrown,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
const SizedBox(width: 8),
|
||||||
color: statusColor.withValues(
|
_buildMetricPill(
|
||||||
alpha: 0.15,
|
label: 'Status',
|
||||||
),
|
value: statusLabel,
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
statusLabel,
|
|
||||||
style: AppTextStyles.labelSmall
|
|
||||||
.copyWith(
|
|
||||||
color: statusColor,
|
color: statusColor,
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStockHistory(List<Map<String, dynamic>> items) {
|
Widget _buildStockHistory(List<Map<String, dynamic>> items) {
|
||||||
return Container(
|
if (items.isEmpty) {
|
||||||
padding: const EdgeInsets.all(12),
|
return _buildReportEmptyState(
|
||||||
decoration: BoxDecoration(
|
icon: Icons.playlist_add_check_rounded,
|
||||||
color: AppColors.bgWhite,
|
message: 'Belum ada riwayat stok masuk.',
|
||||||
borderRadius: BorderRadius.circular(16),
|
);
|
||||||
boxShadow: [AppColors.shadowLight],
|
}
|
||||||
),
|
|
||||||
child:
|
return SizedBox(
|
||||||
items.isEmpty
|
height: _reportListHeight(items.length, itemHeight: 86),
|
||||||
? Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Text(
|
|
||||||
'Belum ada riwayat stok masuk.',
|
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: SizedBox(
|
|
||||||
height: _sectionMaxHeight(),
|
|
||||||
child: Scrollbar(
|
child: Scrollbar(
|
||||||
thumbVisibility: true,
|
thumbVisibility: true,
|
||||||
controller: _stockHistoryScrollController,
|
controller: _stockHistoryScrollController,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
controller: _stockHistoryScrollController,
|
controller: _stockHistoryScrollController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 8),
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final entry = items[index];
|
final entry = items[index];
|
||||||
|
final amount = StockStatusUtils.parseStock(entry['amount']);
|
||||||
|
final unit = entry['unit']?.toString() ?? 'kg';
|
||||||
|
|
||||||
return ListTile(
|
return _buildReportItemCard(
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
icon: Icons.add_rounded,
|
||||||
horizontal: 8,
|
iconColor: AppColors.statusSuccess,
|
||||||
),
|
title: entry['name']?.toString() ?? '-',
|
||||||
leading: Container(
|
subtitle: _dateFormat.format(entry['date'] as DateTime),
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.primaryBrown.withValues(
|
|
||||||
alpha: 0.12,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.add_circle_outline,
|
|
||||||
color: AppColors.primaryBrown,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
entry['name'] as String,
|
|
||||||
style: AppTextStyles.labelLarge,
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
_dateFormat.format(entry['date'] as DateTime),
|
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
|
||||||
color: AppColors.textTertiary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: Text(
|
trailing: Text(
|
||||||
'+${entry['amount']} ${entry['unit'] ?? 'kg'}',
|
'+${_formatQuantity(amount)} $unit',
|
||||||
style: AppTextStyles.labelLarge.copyWith(
|
style: AppTextStyles.labelLarge.copyWith(
|
||||||
color: AppColors.statusSuccess,
|
color: AppColors.statusSuccess,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
footer: Row(
|
||||||
|
children: [
|
||||||
|
_buildMetricPill(
|
||||||
|
label: 'Jumlah',
|
||||||
|
value: '${_formatQuantity(amount)} $unit',
|
||||||
|
color: AppColors.statusSuccess,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPredictionList(List<Map<String, dynamic>> items) {
|
Widget _buildPredictionList(List<Map<String, dynamic>> items) {
|
||||||
final listHeight = items.length * 76.0;
|
if (items.isEmpty) {
|
||||||
final maxHeight = _sectionMaxHeight();
|
return _buildReportEmptyState(
|
||||||
final height = listHeight < maxHeight ? listHeight : maxHeight;
|
icon: Icons.auto_graph_rounded,
|
||||||
|
message: 'Belum ada data prediksi.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: _reportListHeight(items.length, itemHeight: 104),
|
||||||
|
child: Scrollbar(
|
||||||
|
thumbVisibility: true,
|
||||||
|
controller: _predictionScrollController,
|
||||||
|
child: ListView.separated(
|
||||||
|
controller: _predictionScrollController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
final prediction = StockStatusUtils.parseStock(item['prediction']);
|
||||||
|
|
||||||
|
return _buildReportItemCard(
|
||||||
|
icon: Icons.trending_up_rounded,
|
||||||
|
iconColor: AppColors.secondaryBlue,
|
||||||
|
title: item['product']?.toString() ?? '-',
|
||||||
|
subtitle: _dateFormat.format(item['date'] as DateTime),
|
||||||
|
trailing: Text(
|
||||||
|
'${_formatQuantity(prediction)} unit',
|
||||||
|
style: AppTextStyles.labelLarge.copyWith(
|
||||||
|
color: AppColors.primaryBrown,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
footer: Text(
|
||||||
|
item['needs']?.toString() ?? '-',
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.35,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _reportListHeight(int itemCount, {required double itemHeight}) {
|
||||||
|
final contentHeight = (itemCount * itemHeight) + ((itemCount - 1) * 10);
|
||||||
|
final maxHeight = _sectionMaxHeight();
|
||||||
|
if (contentHeight < itemHeight) return itemHeight;
|
||||||
|
return contentHeight < maxHeight ? contentHeight : maxHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReportEmptyState({
|
||||||
|
required IconData icon,
|
||||||
|
required String message,
|
||||||
|
}) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 22),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.bgWhite,
|
color: AppColors.bgWhite,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [AppColors.shadowLight],
|
border: Border.all(color: AppColors.grey200),
|
||||||
),
|
),
|
||||||
child:
|
child: Row(
|
||||||
items.isEmpty
|
children: [
|
||||||
? Padding(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.bgLight,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: AppColors.textTertiary, size: 22),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Belum ada data prediksi.',
|
message,
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.35,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: SizedBox(
|
|
||||||
height: height,
|
|
||||||
child: Scrollbar(
|
|
||||||
child: ListView.separated(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
itemCount: items.length,
|
|
||||||
separatorBuilder:
|
|
||||||
(context, index) =>
|
|
||||||
const Divider(height: 1, indent: 56),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = items[index];
|
|
||||||
return ListTile(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
),
|
),
|
||||||
leading: Container(
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReportItemCard({
|
||||||
|
required IconData icon,
|
||||||
|
required Color iconColor,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required Widget trailing,
|
||||||
|
required Widget footer,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.bgWhite,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.grey200.withValues(alpha: 0.8)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.secondaryBlue.withValues(
|
color: iconColor.withValues(alpha: 0.10),
|
||||||
alpha: 0.12,
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(10),
|
child: Icon(icon, color: iconColor, size: 21),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
const SizedBox(width: 12),
|
||||||
Icons.trending_up,
|
Expanded(
|
||||||
color: AppColors.secondaryBlue,
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
title: Text(
|
Text(
|
||||||
item['product'] as String,
|
title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: AppTextStyles.labelLarge,
|
style: AppTextStyles.labelLarge.copyWith(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
),
|
||||||
'${item['needs']} - ${_dateFormat.format(item['date'] as DateTime)}',
|
const SizedBox(height: 4),
|
||||||
maxLines: 2,
|
Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
color: AppColors.textTertiary,
|
color: AppColors.textTertiary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: Text(
|
],
|
||||||
'${_formatQuantity(item['prediction'])} unit',
|
),
|
||||||
style: AppTextStyles.labelLarge.copyWith(
|
),
|
||||||
color: AppColors.primaryBrown,
|
const SizedBox(width: 10),
|
||||||
fontWeight: FontWeight.w700,
|
trailing,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
footer,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusBadge(String label, Color color) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.10),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: AppTextStyles.labelSmall.copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
|
||||||
|
Widget _buildMetricPill({
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Flexible(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: RichText(
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
text: TextSpan(
|
||||||
|
style: AppTextStyles.labelSmall.copyWith(
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
letterSpacing: 0,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(text: '$label '),
|
||||||
|
TextSpan(
|
||||||
|
text: value,
|
||||||
|
style: AppTextStyles.labelSmall.copyWith(
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -553,7 +689,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
? _buildEmptyChartState(
|
? _buildEmptyChartState(
|
||||||
icon: Icons.inventory_2_outlined,
|
icon: Icons.inventory_2_outlined,
|
||||||
message:
|
message:
|
||||||
'Belum ada data penggunaan barang atau bahan dari API untuk membuat grafik.',
|
'Belum ada data penggunaan bahan. Jalankan prediksi dan simpan hasil produksi agar grafik terisi.',
|
||||||
)
|
)
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
height: 180,
|
height: 180,
|
||||||
|
|
@ -626,7 +762,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
? _buildEmptyChartState(
|
? _buildEmptyChartState(
|
||||||
icon: Icons.pie_chart_outline_rounded,
|
icon: Icons.pie_chart_outline_rounded,
|
||||||
message:
|
message:
|
||||||
'Belum ada data penggunaan barang atau bahan dari API untuk menghitung item paling sering digunakan.',
|
'Belum ada data bahan dari prediksi untuk menghitung bahan yang paling sering digunakan.',
|
||||||
)
|
)
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
|
|
@ -1658,6 +1794,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_stockTableScrollController.dispose();
|
_stockTableScrollController.dispose();
|
||||||
_stockHistoryScrollController.dispose();
|
_stockHistoryScrollController.dispose();
|
||||||
|
_predictionScrollController.dispose();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.1.44:5000';
|
static const String baseUrl = 'http://10.112.239.23:5000';
|
||||||
|
|
||||||
static const int timeoutSeconds = 30;
|
static const int timeoutSeconds = 30;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue