diff --git a/lib/screens/products/product_list_page.dart b/lib/screens/products/product_list_page.dart index 0a32563..4b089f7 100644 --- a/lib/screens/products/product_list_page.dart +++ b/lib/screens/products/product_list_page.dart @@ -571,17 +571,17 @@ class _ProductListScreenState extends State with RouteAware { backgroundColor: Colors.transparent, builder: (context) { return Container( - margin: const EdgeInsets.all(12), + margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), padding: EdgeInsets.only( - left: 18, - right: 18, - top: 12, - bottom: 18 + MediaQuery.of(context).padding.bottom, + left: 20, + right: 20, + top: 10, + bottom: 16 + MediaQuery.of(context).padding.bottom, ), decoration: BoxDecoration( color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(18), - boxShadow: [AppColors.shadowMedium], + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [AppColors.shadowLight], ), child: Column( mainAxisSize: MainAxisSize.min, @@ -592,26 +592,26 @@ class _ProductListScreenState extends State with RouteAware { width: 40, height: 4, decoration: BoxDecoration( - color: AppColors.grey200, + color: AppColors.grey300, borderRadius: BorderRadius.circular(4), ), ), ), - const SizedBox(height: 18), + const SizedBox(height: 20), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 56, - height: 56, + width: 52, + height: 52, decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(14), + borderRadius: BorderRadius.circular(16), ), child: Icon( _controller.getCategoryIcon(product.category), color: statusColor, - size: 28, + size: 26, ), ), const SizedBox(width: 14), @@ -636,14 +636,15 @@ class _ProductListScreenState extends State with RouteAware { ], ), ), + const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, + horizontal: 9, + vertical: 5, ), decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(8), + color: statusColor.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), ), child: Text( _controller.getStatusLabel(product.status), @@ -655,12 +656,8 @@ class _ProductListScreenState extends State with RouteAware { ), ], ), - const SizedBox(height: 18), - Container(height: 1, color: AppColors.grey200), - const SizedBox(height: 16), - Wrap( - spacing: 12, - runSpacing: 12, + const SizedBox(height: 20), + _buildDetailGrid( children: [ _buildDetailTile( icon: Icons.category_outlined, @@ -694,25 +691,48 @@ class _ProductListScreenState extends State 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( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(999), child: LinearProgressIndicator( value: maxStock == 0 ? 0 : product.stock / maxStock, - minHeight: 8, + minHeight: 7, backgroundColor: AppColors.grey200, valueColor: AlwaysStoppedAnimation(statusColor), ), ), - const SizedBox(height: 14), + const SizedBox(height: 12), Container( width: double.infinity, - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 11, + ), decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), + color: statusColor.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(14), border: Border.all( - color: statusColor.withValues(alpha: 0.18), + color: statusColor.withValues(alpha: 0.14), ), ), child: Text( @@ -723,7 +743,7 @@ class _ProductListScreenState extends State with RouteAware { ), ), ), - const SizedBox(height: 16), + const SizedBox(height: 18), Row( children: [ Expanded( @@ -732,9 +752,11 @@ class _ProductListScreenState extends State with RouteAware { icon: const Icon(Icons.close_rounded), label: const Text('Tutup'), 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( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), ), ), @@ -751,9 +773,10 @@ class _ProductListScreenState extends State with RouteAware { style: ElevatedButton.styleFrom( backgroundColor: AppColors.statusSuccess, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), ), ), ), @@ -767,49 +790,78 @@ class _ProductListScreenState extends State with RouteAware { ); } + Widget _buildDetailGrid({required List 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({ required IconData icon, required String label, required String value, }) { - return SizedBox( - width: (MediaQuery.of(context).size.width - 60) / 2, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.bgLight, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.grey200), - ), - child: Row( - children: [ - Icon(icon, size: 18, color: AppColors.primaryBrown), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textTertiary, - ), - ), - const SizedBox(height: 2), - Text( - value, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - ], - ), + return Container( + constraints: const BoxConstraints(minHeight: 68), + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 10), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.grey200), + ), + child: Row( + children: [ + Container( + 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( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + 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, + fontWeight: FontWeight.w700, + letterSpacing: 0, + ), + ), + ], + ), + ), + ], ), ); } diff --git a/lib/screens/reports/report_controller.dart b/lib/screens/reports/report_controller.dart index c691538..20445b7 100644 --- a/lib/screens/reports/report_controller.dart +++ b/lib/screens/reports/report_controller.dart @@ -14,6 +14,7 @@ class ReportController extends ChangeNotifier { final List> criticalItems = []; final List> usageSummary = []; final List demandTrend = []; + final Map>> _recipeIngredients = {}; int totalProduk = 0; int totalBahan = 0; @@ -40,6 +41,7 @@ class ReportController extends ChangeNotifier { MLService.getReportPredictions(), MLService.getProducts(), MLService.getDashboardSummary(), + MLService.getRecipes(), ]); final stockResponse = results[0] as Map; @@ -47,6 +49,7 @@ class ReportController extends ChangeNotifier { final predictionResponse = results[2] as Map; final productsResponse = results[3]; final dashboardSummary = results[4] as Map; + final recipesResponse = results[5]; if (stockResponse['status'] != true) { errorMessage = stockResponse['message']?.toString(); @@ -74,6 +77,12 @@ class ReportController extends ChangeNotifier { criticalItems.clear(); } + if (recipesResponse is List) { + _applyRecipes(recipesResponse); + } else { + _recipeIngredients.clear(); + } + if (dashboardSummary['status'] != true) { errorMessage ??= dashboardSummary['message']?.toString(); usageSummary.clear(); @@ -81,6 +90,10 @@ class ReportController extends ChangeNotifier { _applyUsageSummary(dashboardSummary['penggunaan_bahan']); } + if (usageSummary.isEmpty) { + _rebuildUsageSummaryFromPredictions(); + } + _rebuildSummary(); _rebuildDemandTrend(); } catch (e) { @@ -181,6 +194,37 @@ class ReportController extends ChangeNotifier { ); } + void _applyRecipes(List 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( + (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) { usageSummary.clear(); if (data is! List) return; @@ -201,6 +245,134 @@ class ReportController extends ChangeNotifier { ); } + void _rebuildUsageSummaryFromPredictions() { + final Map> 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> _parseEstimatedNeeds(String text) { + final entries = >[]; + 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> _estimateUsageFromRecipe( + Map 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 _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() { totalBahan = stockItems.length; totalProduk = _productCount; diff --git a/lib/screens/reports/report_page.dart b/lib/screens/reports/report_page.dart index c9892f9..7bdad3e 100644 --- a/lib/screens/reports/report_page.dart +++ b/lib/screens/reports/report_page.dart @@ -48,6 +48,7 @@ class _ReportScreenState extends State { final DateFormat _fileDateFormat = DateFormat('yyyyMMdd_HHmmss'); final ScrollController _stockTableScrollController = ScrollController(); final ScrollController _stockHistoryScrollController = ScrollController(); + final ScrollController _predictionScrollController = ScrollController(); late final ReportController _controller; String? _lastExportPath; @@ -72,10 +73,6 @@ class _ReportScreenState extends State { final width = MediaQuery.of(context).size.width; final cardWidth = (width - 48) / 2; - final usageBars = _buildUsageBars(_controller.usageSummary); - final usagePie = _buildUsagePie(usageBars); - final List demandTrend = _controller.demandTrend; - return Scaffold( backgroundColor: AppColors.bgLight, appBar: AppBar( @@ -96,6 +93,10 @@ class _ReportScreenState extends State { return const Center(child: CircularProgressIndicator()); } + final usageBars = _buildUsageBars(_controller.usageSummary); + final usagePie = _buildUsagePie(usageBars); + final List demandTrend = _controller.demandTrend; + return RefreshIndicator( onRefresh: () => _controller.loadReports(), child: SingleChildScrollView( @@ -159,15 +160,27 @@ class _ReportScreenState extends State { ], ), 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), _buildStockTable(_controller.stockItems), 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), _buildStockHistory(_controller.stockHistory), 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), _buildPredictionList(_controller.predictionItems), const SizedBox(height: 24), @@ -291,251 +304,374 @@ class _ReportScreenState extends State { ); } - Widget _buildSectionTitle(String title) { - return Text( - title, - style: AppTextStyles.headlineSmall.copyWith(color: AppColors.textPrimary), + Widget _buildSectionTitle(String title, {IconData? icon, String? subtitle}) { + 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, + 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> items) { - return Container( - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: - items.isEmpty - ? Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Data stok bahan belum tersedia.', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ) - : SizedBox( - height: _sectionMaxHeight(), - child: Scrollbar( - thumbVisibility: true, - controller: _stockTableScrollController, - child: SingleChildScrollView( - controller: _stockTableScrollController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - columns: const [ - DataColumn(label: Text('Nama Bahan')), - DataColumn(label: Text('Stok Tersedia')), - DataColumn(label: Text('Status')), - ], - rows: - items.map((item) { - 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'; + if (items.isEmpty) { + return _buildReportEmptyState( + icon: Icons.inventory_2_outlined, + message: 'Data stok bahan belum tersedia.', + ); + } - return DataRow( - cells: [ - DataCell(Text(item['name'] as String)), - DataCell(Text('${item['stock']} $unit')), - DataCell( - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: statusColor.withValues( - alpha: 0.15, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - statusLabel, - style: AppTextStyles.labelSmall - .copyWith( - color: statusColor, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ], - ); - }).toList(), - ), - ), + final sortedItems = _sortedStockItems(items); + + return SizedBox( + height: _reportListHeight(sortedItems.length, itemHeight: 92), + child: Scrollbar( + thumbVisibility: true, + controller: _stockTableScrollController, + child: ListView.separated( + controller: _stockTableScrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: sortedItems.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final item = sortedItems[index]; + 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 minimumStock = StockStatusUtils.parseStock(item['min_stock']); + + return _buildReportItemCard( + icon: Icons.inventory_2_outlined, + iconColor: statusColor, + title: item['name']?.toString() ?? '-', + subtitle: 'Minimum ${_formatQuantity(minimumStock)} $unit', + trailing: _buildStatusBadge(statusLabel, statusColor), + footer: Row( + children: [ + _buildMetricPill( + label: 'Stok', + value: '${_formatQuantity(stockValue)} $unit', + color: AppColors.primaryBrown, ), - ), + const SizedBox(width: 8), + _buildMetricPill( + label: 'Status', + value: statusLabel, + color: statusColor, + ), + ], ), + ); + }, + ), + ), ); } Widget _buildStockHistory(List> items) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: - items.isEmpty - ? 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( - thumbVisibility: true, - controller: _stockHistoryScrollController, - child: ListView.separated( - controller: _stockHistoryScrollController, - padding: EdgeInsets.zero, - itemCount: items.length, - separatorBuilder: (_, __) => const Divider(height: 8), - itemBuilder: (context, index) { - final entry = items[index]; + if (items.isEmpty) { + return _buildReportEmptyState( + icon: Icons.playlist_add_check_rounded, + message: 'Belum ada riwayat stok masuk.', + ); + } - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - ), - leading: Container( - 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( - '+${entry['amount']} ${entry['unit'] ?? 'kg'}', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.statusSuccess, - fontWeight: FontWeight.w700, - ), - ), - ); - }, - ), + return SizedBox( + height: _reportListHeight(items.length, itemHeight: 86), + child: Scrollbar( + thumbVisibility: true, + controller: _stockHistoryScrollController, + child: ListView.separated( + controller: _stockHistoryScrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final entry = items[index]; + final amount = StockStatusUtils.parseStock(entry['amount']); + final unit = entry['unit']?.toString() ?? 'kg'; + + return _buildReportItemCard( + icon: Icons.add_rounded, + iconColor: AppColors.statusSuccess, + title: entry['name']?.toString() ?? '-', + subtitle: _dateFormat.format(entry['date'] as DateTime), + trailing: Text( + '+${_formatQuantity(amount)} $unit', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.statusSuccess, + fontWeight: FontWeight.w800, ), ), + footer: Row( + children: [ + _buildMetricPill( + label: 'Jumlah', + value: '${_formatQuantity(amount)} $unit', + color: AppColors.statusSuccess, + ), + ], + ), + ); + }, + ), + ), ); } Widget _buildPredictionList(List> items) { - final listHeight = items.length * 76.0; - final maxHeight = _sectionMaxHeight(); - final height = listHeight < maxHeight ? listHeight : maxHeight; + if (items.isEmpty) { + return _buildReportEmptyState( + 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( - padding: const EdgeInsets.all(12), + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 22), decoration: BoxDecoration( color: AppColors.bgWhite, borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], + border: Border.all(color: AppColors.grey200), ), - child: - items.isEmpty - ? Padding( - padding: const EdgeInsets.all(12), - child: Text( - 'Belum ada data prediksi.', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), + child: Row( + children: [ + Container( + 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( + message, + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + height: 1.35, + ), + ), + ), + ], + ), + ); + } + + 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, + height: 40, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), ), - ) - : 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( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.secondaryBlue.withValues( - alpha: 0.12, - ), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.trending_up, - color: AppColors.secondaryBlue, - ), - ), - title: Text( - item['product'] as String, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTextStyles.labelLarge, - ), - subtitle: Text( - '${item['needs']} - ${_dateFormat.format(item['date'] as DateTime)}', - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, - ), - ), - trailing: Text( - '${_formatQuantity(item['prediction'])} unit', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.primaryBrown, - fontWeight: FontWeight.w700, - ), - ), - ); - }, - ), + child: Icon(icon, color: iconColor, size: 21), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + ), + ], ), ), + const SizedBox(width: 10), + 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 { ? _buildEmptyChartState( icon: Icons.inventory_2_outlined, 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( height: 180, @@ -626,7 +762,7 @@ class _ReportScreenState extends State { ? _buildEmptyChartState( icon: Icons.pie_chart_outline_rounded, 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( height: 200, @@ -1658,6 +1794,7 @@ class _ReportScreenState extends State { void dispose() { _stockTableScrollController.dispose(); _stockHistoryScrollController.dispose(); + _predictionScrollController.dispose(); _controller.dispose(); super.dispose(); } diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index 61b80d4..a94b5ef 100644 --- a/lib/services/ml_service.dart +++ b/lib/services/ml_service.dart @@ -5,7 +5,7 @@ class MLService { // API URL - Change based on environment // Untuk emulator Android: 10.0.2.2 // 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;