memperbarui beberapa fitur 3

This commit is contained in:
rhanarmt 2026-05-15 23:17:01 +07:00
parent b0ed5950c2
commit 5f2f97d693
4 changed files with 662 additions and 301 deletions

View File

@ -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 {
), ),
], ],
), ),
),
); );
} }

View File

@ -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;

View File

@ -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();
} }

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.1.44:5000'; static const String baseUrl = 'http://10.112.239.23:5000';
static const int timeoutSeconds = 30; static const int timeoutSeconds = 30;