diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0b0669f..e1e5046 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "com.example.finalproject" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/lib/models/product_model.dart b/lib/models/product_model.dart index 824b605..55433f5 100644 --- a/lib/models/product_model.dart +++ b/lib/models/product_model.dart @@ -5,7 +5,7 @@ class Product { final int price; final int stock; final String unit; - final String status; // 'tersedia', 'rendah', 'kritis' + final String status; // 'tersedia', 'sedang', 'kritis' Product({ required this.id, diff --git a/lib/screens/dashboard/dashboard_controller.dart b/lib/screens/dashboard/dashboard_controller.dart index 9287543..8cc689e 100644 --- a/lib/screens/dashboard/dashboard_controller.dart +++ b/lib/screens/dashboard/dashboard_controller.dart @@ -1,32 +1,175 @@ -import 'package:finalproject/theme/colors.dart'; +import 'package:finalproject/services/ml_service.dart'; +import 'package:finalproject/utils/stock_status.dart'; import 'package:flutter/material.dart'; class DashboardController extends ChangeNotifier { int selectedIndex = 0; - final List> lowStockItems = [ - { - 'name': 'Tepung Terigu', - 'stock': '5 kg', - 'status': 'Kritis', - 'statusColor': AppColors.statusError, - }, - { - 'name': 'Gula Pasir', - 'stock': '8 kg', - 'status': 'Rendah', - 'statusColor': AppColors.statusWarning, - }, - { - 'name': 'Mentega', - 'stock': '3 kg', - 'status': 'Kritis', - 'statusColor': AppColors.statusError, - }, - ]; + bool isLoading = true; + bool isBahanDigunakanLoading = true; + String? errorMessage; + String? bahanDigunakanError; + + double totalBahanDigunakanHariIni = 0; + String bahanDigunakanSatuan = 'kg'; + String bahanDigunakanKeterangan = 'total penggunaan hari ini'; + int totalProduk = 0; + + final List> penggunaanBahan = []; + final List> lowStockItems = []; void setSelectedIndex(int index) { selectedIndex = index; notifyListeners(); } + + Future loadDashboard({bool showLoading = true}) async { + if (showLoading) { + isLoading = true; + isBahanDigunakanLoading = true; + notifyListeners(); + } + + try { + errorMessage = null; + bahanDigunakanError = null; + + final results = await Future.wait([ + MLService.getBahanDigunakanHariIni(), + MLService.getDashboardSummary(), + MLService.getProducts(), + MLService.getReportCritical(), + ]); + + final bahanDigunakan = results[0] as Map; + final summary = results[1] as Map; + final products = results[2] as List; + final critical = results[3] as Map; + + _applyBahanDigunakanHariIni(bahanDigunakan); + + if (summary['status'] == true) { + _applyPenggunaanBahan(summary['penggunaan_bahan']); + } else { + errorMessage = summary['message']?.toString(); + _applyDummyPenggunaan(); + } + + totalProduk = products.length; + + if (critical['status'] == true) { + _applyLowStockItems(critical['data']); + } else { + _applyDummyLowStock(); + } + } catch (e) { + errorMessage = 'Gagal memuat dashboard: $e'; + bahanDigunakanError = 'Gagal memuat bahan digunakan hari ini'; + _resetBahanDigunakanHariIni(); + _applyDummyPenggunaan(); + _applyDummyLowStock(); + } finally { + isLoading = false; + isBahanDigunakanLoading = false; + notifyListeners(); + } + } + + void _applyBahanDigunakanHariIni(Map data) { + if (data['status'] == false) { + bahanDigunakanError = data['message']?.toString(); + _resetBahanDigunakanHariIni(); + return; + } + + totalBahanDigunakanHariIni = (data['total'] as num?)?.toDouble() ?? 0; + bahanDigunakanSatuan = data['satuan']?.toString() ?? 'kg'; + bahanDigunakanKeterangan = + data['keterangan']?.toString() ?? 'total penggunaan hari ini'; + } + + void _resetBahanDigunakanHariIni() { + totalBahanDigunakanHariIni = 0; + bahanDigunakanSatuan = 'kg'; + bahanDigunakanKeterangan = 'total penggunaan hari ini'; + } + + void _applyPenggunaanBahan(dynamic data) { + penggunaanBahan.clear(); + if (data is! List || data.isEmpty) { + _applyDummyPenggunaan(); + return; + } + for (final item in data) { + penggunaanBahan.add({ + 'name': item['nama_bahan']?.toString() ?? '-', + 'total': (item['total_digunakan'] as num?)?.toDouble() ?? 0, + 'unit': item['satuan']?.toString() ?? 'kg', + }); + } + } + + void _applyLowStockItems(dynamic data) { + lowStockItems.clear(); + if (data is! List || data.isEmpty) { + _applyDummyLowStock(); + return; + } + + for (final item in data) { + final stockValue = StockStatusUtils.parseStock(item['stok']); + final statusKey = StockStatusUtils.statusFromStock(stockValue); + final unit = item['unit'] ?? 'kg'; + final stockLabel = item['stok']?.toString() ?? '0'; + lowStockItems.add({ + 'name': item['nama_bahan']?.toString() ?? '-', + 'stock': '$stockLabel $unit', + 'status': StockStatusUtils.label(statusKey), + 'statusColor': StockStatusUtils.color(statusKey), + }); + } + } + + void _applyDummyPenggunaan() { + penggunaanBahan + ..clear() + ..addAll([ + {'name': 'Tepung Terigu', 'total': 120.0, 'unit': 'kg'}, + {'name': 'Gula', 'total': 80.0, 'unit': 'kg'}, + {'name': 'Mentega', 'total': 45.0, 'unit': 'kg'}, + {'name': 'Telur', 'total': 30.0, 'unit': 'kg'}, + {'name': 'Coklat Bubuk', 'total': 20.0, 'unit': 'kg'}, + ]); + } + + void _applyDummyLowStock() { + lowStockItems + ..clear() + ..addAll([ + { + 'name': 'Tepung Terigu', + 'stock': '5 kg', + 'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(5)), + 'statusColor': StockStatusUtils.color( + StockStatusUtils.statusFromStock(5), + ), + }, + { + 'name': 'Gula Pasir', + 'stock': '8 kg', + 'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(8)), + 'statusColor': StockStatusUtils.color( + StockStatusUtils.statusFromStock(8), + ), + }, + { + 'name': 'Mentega', + 'stock': '3 kg', + 'status': StockStatusUtils.label(StockStatusUtils.statusFromStock(3)), + 'statusColor': StockStatusUtils.color( + StockStatusUtils.statusFromStock(3), + ), + }, + ]); + } } diff --git a/lib/screens/dashboard/dashboard_page.dart b/lib/screens/dashboard/dashboard_page.dart index a215d02..355210f 100644 --- a/lib/screens/dashboard/dashboard_page.dart +++ b/lib/screens/dashboard/dashboard_page.dart @@ -1,5 +1,6 @@ import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/text_styles.dart'; +import 'package:finalproject/utils/route_observer.dart'; import 'package:flutter/material.dart'; import 'dashboard_controller.dart'; @@ -11,24 +12,41 @@ class DashboardScreen extends StatefulWidget { State createState() => _DashboardScreenState(); } -class _DashboardScreenState extends State { +class _DashboardScreenState extends State with RouteAware { late final DashboardController _controller; @override void initState() { super.initState(); _controller = DashboardController(); + _controller.loadDashboard(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route != null) { + routeObserver.subscribe(this, route); + } + } + + @override + void didPopNext() { + _controller.loadDashboard(showLoading: false); } @override Widget build(BuildContext context) { + final topPadding = MediaQuery.of(context).padding.top; + final headerHeight = 240 + topPadding; return AnimatedBuilder( animation: _controller, builder: (context, _) { return Scaffold( backgroundColor: AppColors.bgLight, appBar: PreferredSize( - preferredSize: const Size.fromHeight(240), + preferredSize: Size.fromHeight(headerHeight), child: Container( decoration: BoxDecoration( color: AppColors.primaryBrown, @@ -57,7 +75,9 @@ class _DashboardScreenState extends State { width: 40, height: 40, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.3), + color: Colors.white.withValues( + alpha: 0.3, + ), borderRadius: BorderRadius.circular(10), ), child: IconButton( @@ -136,18 +156,21 @@ class _DashboardScreenState extends State { children: [ Expanded( child: _buildStatCard( - title: 'Total Penjualan', - value: 'Rp 67 Jt', - change: '+12.5%', - icon: Icons.trending_up, + title: 'Bahan Digunakan Hari Ini', + value: + '${_formatNumber(_controller.totalBahanDigunakanHariIni)} ${_controller.bahanDigunakanSatuan}', + change: _controller.bahanDigunakanKeterangan, + icon: Icons.inventory_2_outlined, iconBgColor: AppColors.statusSuccess, + isLoading: _controller.isBahanDigunakanLoading, + hasError: _controller.bahanDigunakanError != null, ), ), const SizedBox(width: 12), Expanded( child: _buildStatCard( title: 'Produk', - value: '24', + value: _controller.totalProduk.toString(), change: 'Aktif', icon: Icons.shopping_bag, iconBgColor: AppColors.secondaryBlue, @@ -187,7 +210,7 @@ class _DashboardScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Grafik Penjualan', + 'Penggunaan Bahan', style: AppTextStyles.headlineSmall .copyWith( color: AppColors.textPrimary, @@ -195,7 +218,7 @@ class _DashboardScreenState extends State { ), const SizedBox(height: 2), Text( - '6 Bulan Terakhir', + 'Top 5 bahan paling sering digunakan', style: AppTextStyles.bodySmall.copyWith( color: AppColors.textTertiary, ), @@ -203,57 +226,27 @@ class _DashboardScreenState extends State { ], ), Icon( - Icons.trending_up, - color: AppColors.statusSuccess, + Icons.bar_chart_rounded, + color: AppColors.secondaryBlue, size: 20, ), ], ), - const SizedBox(height: 20), - SizedBox( - height: 200, - child: Column( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...[ - '80000000', - '60000000', - '40000000', - '20000000', - '0', - ].map((label) { - return Text( - label, - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.grey300, - ), - ); - }).toList(), - ], + const SizedBox(height: 16), + if (_controller.isLoading) + const Center(child: CircularProgressIndicator()) + else + Column( + children: + _controller.penggunaanBahan.map((item) { + return Padding( + padding: const EdgeInsets.only( + bottom: 12, + ), + child: _buildUsageRow(item), + ); + }).toList(), ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: - [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'Mei', - 'Jun', - ].map((month) { - return Text( - month, - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textTertiary, - ), - ); - }).toList(), - ), ], ), ), @@ -336,7 +329,7 @@ class _DashboardScreenState extends State { ], ), ); - }).toList(), + }), ], ), ), @@ -469,9 +462,12 @@ class _DashboardScreenState extends State { required String change, required IconData icon, required Color iconBgColor, + bool isLoading = false, + bool hasError = false, }) { return Container( - padding: const EdgeInsets.all(14), + constraints: const BoxConstraints(minHeight: 120), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.bgWhite, borderRadius: BorderRadius.circular(14), @@ -490,38 +486,63 @@ class _DashboardScreenState extends State { children: [ Text( title, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: AppTextStyles.bodySmall.copyWith( color: AppColors.textTertiary, fontWeight: FontWeight.w500, + fontSize: 11, + height: 1.2, ), ), - const SizedBox(height: 6), - Text( - value, - style: AppTextStyles.titleLarge.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, + const SizedBox(height: 4), + if (isLoading) + const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2.4), + ) + else + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + maxLines: 1, + style: AppTextStyles.titleLarge.copyWith( + color: + hasError + ? AppColors.textSecondary + : AppColors.textPrimary, + fontWeight: FontWeight.w700, + fontSize: 18, + height: 1.1, + ), + ), ), - ), - const SizedBox(height: 6), + const SizedBox(height: 4), Text( change, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: AppTextStyles.bodySmall.copyWith( color: - change.contains('+') - ? AppColors.statusSuccess + hasError + ? AppColors.statusError : AppColors.textTertiary, fontWeight: FontWeight.w600, + fontSize: 11, + height: 1.2, ), ), ], ), ), Container( - width: 44, - height: 44, + width: 40, + height: 40, decoration: BoxDecoration( - color: iconBgColor.withOpacity(0.15), + color: iconBgColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), child: Center(child: Icon(icon, color: iconBgColor, size: 22)), @@ -533,8 +554,66 @@ class _DashboardScreenState extends State { ); } + String _formatNumber(num value) { + if (value % 1 == 0) { + return value.toInt().toString(); + } + + return value.toStringAsFixed(1); + } + + Widget _buildUsageRow(Map item) { + final total = (item['total'] as num?)?.toDouble() ?? 0.0; + final unit = item['unit']?.toString() ?? 'kg'; + final max = _controller.penggunaanBahan.fold( + 0, + (value, element) => + (((element['total'] as num?)?.toDouble() ?? 0.0) > value) + ? (element['total'] as num?)!.toDouble() + : value, + ); + final progress = max == 0 ? 0.0 : total / max; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + item['name']?.toString() ?? '-', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + '${total.toStringAsFixed(0)} $unit', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: progress, + minHeight: 8, + backgroundColor: AppColors.grey200, + valueColor: AlwaysStoppedAnimation(AppColors.secondaryBlue), + ), + ), + ], + ); + } + @override void dispose() { + routeObserver.unsubscribe(this); _controller.dispose(); super.dispose(); } diff --git a/lib/screens/prediction/prediction_controller.dart b/lib/screens/prediction/prediction_controller.dart index abccdc7..d6d560b 100644 --- a/lib/screens/prediction/prediction_controller.dart +++ b/lib/screens/prediction/prediction_controller.dart @@ -179,7 +179,7 @@ class PredictionController extends ChangeNotifier { amount: neededAmount, fromUnit: getIngredientUnit(ingredient), ); - final available = currentStock[ingredient] ?? 0; + final available = getCurrentStock(ingredient); if (available < requiredInStockUnit) { insufficient[ingredient] = requiredInStockUnit - available; } @@ -200,15 +200,28 @@ class PredictionController extends ChangeNotifier { } String getStockUnit(String ingredient) { - final unit = productUnits[ingredient]; - if (unit != null && unit.isNotEmpty) return unit; + final key = _matchingProductName(ingredient); + final unit = key == null ? productUnits[ingredient] : productUnits[key]; + if (unit != null && unit.isNotEmpty) return _normalizeUnit(unit); return 'kg'; } + double getCurrentStock(String ingredient) { + final key = _matchingProductName(ingredient); + return key == null ? currentStock[ingredient] ?? 0 : currentStock[key] ?? 0; + } + + int? getProductId(String ingredient) { + final key = _matchingProductName(ingredient); + return key == null ? productIds[ingredient] : productIds[key]; + } + double toGram({required double amount, required String unit}) { - final normalized = unit.toLowerCase(); + final normalized = _normalizeUnit(unit); if (normalized == 'gr') return amount; if (normalized == 'kg') return amount * 1000; + if (normalized == 'ml') return amount; + if (normalized == 'l') return amount * 1000; if (normalized == 'butir') return amount * eggGramPerButir; return amount; } @@ -228,14 +241,22 @@ class PredictionController extends ChangeNotifier { amount: requiredIngredients[ingredient] ?? 0, fromUnit: getIngredientUnit(ingredient), ); - final available = currentStock[ingredient] ?? 0; + final available = getCurrentStock(ingredient); return available >= required ? const Color(0xFF10B981) : const Color(0xFFDC2626); } String cleanIngredientName(String ingredient) { - return ingredient.replaceAll(RegExp(r' \d+(kg|gr)'), '').trim(); + return ingredient + .replaceAll( + RegExp( + r'\s+\d+([,.]\d+)?\s*(kg|kilogram|g|gr|gram|ml|l|liter|ltr|butir|pcs)\b', + caseSensitive: false, + ), + '', + ) + .trim(); } String formatQuantity(double value) { @@ -248,13 +269,38 @@ class PredictionController extends ChangeNotifier { .replaceAll(RegExp(r'\.$'), ''); } + String formatStockQuantity(String ingredient, double value) { + final unit = getStockUnit(ingredient); + if (unit == 'kg' && value > 0 && value < 1) { + return '${formatQuantity(value * 1000)} gr'; + } + if (unit == 'l' && value > 0 && value < 1) { + return '${formatQuantity(value * 1000)} ml'; + } + return '${formatQuantity(value)} $unit'; + } + + 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 (['butir', 'pcs', 'piece', 'pieces'].contains(normalized)) { + return 'butir'; + } + return normalized; + } + double _convertToStockUnit({ required String ingredient, required double amount, required String fromUnit, }) { - final stockUnit = getStockUnit(ingredient).toLowerCase(); - final unit = fromUnit.toLowerCase(); + final stockUnit = _normalizeUnit(getStockUnit(ingredient)); + final unit = _normalizeUnit(fromUnit); if (unit == stockUnit) return amount; @@ -267,6 +313,14 @@ class PredictionController extends ChangeNotifier { if (unit == 'butir' && stockUnit == 'gr') { return amount * eggGramPerButir; } + if (unit == 'ml' && stockUnit == 'kg') return amount / 1000; + if (unit == 'l' && stockUnit == 'kg') return amount; + if (unit == 'kg' && stockUnit == 'ml') return amount * 1000; + if (unit == 'kg' && stockUnit == 'l') return amount; + if (unit == 'ml' && stockUnit == 'gr') return amount; + if (unit == 'gr' && stockUnit == 'ml') return amount; + if (unit == 'l' && stockUnit == 'ml') return amount * 1000; + if (unit == 'ml' && stockUnit == 'l') return amount / 1000; if (unit == 'kg' && stockUnit == 'butir') { return (amount * 1000) / eggGramPerButir; } @@ -277,23 +331,52 @@ class PredictionController extends ChangeNotifier { return amount; } - double _roundRequiredGramToKg(double grams) { - if (grams <= 0) return 0; - return MLService.gramsToKgRounded(grams); + String _ingredientKey(String value) { + return cleanIngredientName( + value, + ).toLowerCase().replaceAll(RegExp(r'\s+'), ' '); } - Map get roundedUsage { + String? _matchingProductName(String ingredient) { + if (currentStock.containsKey(ingredient)) return ingredient; + + final target = _ingredientKey(ingredient); + for (final name in currentStock.keys) { + if (_ingredientKey(name) == target) return name; + } + + return null; + } + + Map get stockUsage { final required = requiredIngredients; - final rounded = {}; + final usage = {}; required.forEach((ingredient, neededAmount) { if (!isIngredientSelected(ingredient)) return; final unit = getIngredientUnit(ingredient); - final requiredGram = toGram(amount: neededAmount, unit: unit); - rounded[ingredient] = _roundRequiredGramToKg(requiredGram); + usage[ingredient] = _convertToStockUnit( + ingredient: ingredient, + amount: neededAmount, + fromUnit: unit, + ); }); - return rounded; + return usage; + } + + String buildEstimatedNeedsText() { + final usage = stockUsage; + if (usage.isEmpty) return 'Belum tersedia'; + + return usage.entries + .where((entry) => entry.value > 0) + .map((entry) { + final name = cleanIngredientName(entry.key); + final amount = formatStockQuantity(entry.key, entry.value); + return '$name: $amount'; + }) + .join(', '); } Future> submitProduction() async { @@ -323,20 +406,20 @@ class PredictionController extends ChangeNotifier { }; } - final rounded = roundedUsage; - if (rounded.isEmpty) { + final usage = stockUsage; + if (usage.isEmpty) { return {'status': 'error', 'message': 'Tidak ada bahan yang dipilih'}; } final items = - rounded.entries.where((entry) => entry.value > 0).map((entry) { + usage.entries.where((entry) => entry.value > 0).map((entry) { final ingredient = entry.key; - final quantityKg = entry.value; + final quantity = entry.value; return { - 'product_id': productIds[ingredient], + 'product_id': getProductId(ingredient), 'product_name': ingredient, - 'quantity': quantityKg, - 'unit': 'kg', + 'quantity': quantity, + 'unit': getStockUnit(ingredient), }; }).toList(); @@ -347,14 +430,22 @@ class PredictionController extends ChangeNotifier { ); if (result['status'] == 'success') { - for (final entry in rounded.entries) { + // Simpan hasil prediksi ke database agar laporan total prediksi ter-update. + await MLService.savePrediction( + productName: selectedRecipe ?? 'Produk', + category: 'Produk', + unitPrice: 0, + predictionDate: DateTime.now().toIso8601String().split('T').first, + predictedQuantity: productionQuantity, + estimatedNeeds: buildEstimatedNeedsText(), + ); + + for (final entry in usage.entries) { final ingredient = entry.key; final quantity = entry.value; - final available = currentStock[ingredient] ?? 0; - currentStock[ingredient] = (available - quantity).clamp( - 0, - double.infinity, - ); + final key = _matchingProductName(ingredient) ?? ingredient; + final available = currentStock[key] ?? 0; + currentStock[key] = (available - quantity).clamp(0, double.infinity); } await refreshStock(); } diff --git a/lib/screens/prediction/prediction_page.dart b/lib/screens/prediction/prediction_page.dart index f6993b1..2614738 100644 --- a/lib/screens/prediction/prediction_page.dart +++ b/lib/screens/prediction/prediction_page.dart @@ -706,10 +706,8 @@ class _PredictionScreenState extends State { _controller .productionQuantity : 0.0; - final stockKg = - _controller - .currentStock[ingredient] ?? - 0.0; + final stockKg = _controller + .getCurrentStock(ingredient); final requiredGram = isSelected ? _controller.toGram( @@ -845,7 +843,10 @@ class _PredictionScreenState extends State { ), ), Text( - '${_controller.formatQuantity(stockKg)} kg', + _controller.formatStockQuantity( + ingredient, + stockKg, + ), style: const TextStyle( fontSize: 12, @@ -921,7 +922,7 @@ class _PredictionScreenState extends State { ), ), const SizedBox(height: 16), - if (_controller.roundedUsage.isNotEmpty) ...[ + if (_controller.stockUsage.isNotEmpty) ...[ const Text( 'Ringkasan Pengurangan Stok', style: TextStyle( @@ -946,13 +947,11 @@ class _PredictionScreenState extends State { ), child: Column( children: - _controller.roundedUsage.entries.map(( + _controller.stockUsage.entries.map(( entry, ) { final ingredient = entry.key; final amount = entry.value; - final stockUnit = _controller - .getStockUnit(ingredient); return Padding( padding: const EdgeInsets.symmetric( @@ -980,7 +979,7 @@ class _PredictionScreenState extends State { ), ), Text( - '-${_controller.formatQuantity(amount)} $stockUnit', + '-${_controller.formatStockQuantity(ingredient, amount)}', style: const TextStyle( fontSize: 12, fontWeight: diff --git a/lib/screens/products/product_list_controller.dart b/lib/screens/products/product_list_controller.dart index af8ad86..d69d1c1 100644 --- a/lib/screens/products/product_list_controller.dart +++ b/lib/screens/products/product_list_controller.dart @@ -1,5 +1,6 @@ import 'package:finalproject/models/product_model.dart'; import 'package:finalproject/services/ml_service.dart'; +import 'package:finalproject/utils/stock_status.dart'; import 'package:flutter/material.dart'; class ProductListController extends ChangeNotifier { @@ -16,12 +17,6 @@ class ProductListController extends ChangeNotifier { try { final fetchedProducts = await MLService.getProducts(); - String getStatus(int stock) { - if (stock == 0) return 'kritis'; - if (stock <= 5) return 'rendah'; - return 'tersedia'; - } - products = fetchedProducts.map((p) { final stock = p['current_stock'] ?? 0; @@ -36,7 +31,7 @@ class ProductListController extends ChangeNotifier { price: p['price'] ?? 0, stock: stock, unit: unit, - status: getStatus(stock), + status: StockStatusUtils.statusFromStock(stock), ); }).toList(); } finally { @@ -84,16 +79,7 @@ class ProductListController extends ChangeNotifier { } Color getStatusColor(String status) { - switch (status) { - case 'tersedia': - return const Color(0xFF10B981); - case 'rendah': - return const Color(0xFFFB923C); - case 'kritis': - return const Color(0xFFDC2626); - default: - return const Color(0xFF9CA3AF); - } + return StockStatusUtils.color(status); } IconData getCategoryIcon(String category) { @@ -122,16 +108,7 @@ class ProductListController extends ChangeNotifier { String formatPrice(int price) => 'Rp ${(price ~/ 1000)}K'; String getStatusLabel(String status) { - switch (status) { - case 'tersedia': - return '✅ Tersedia'; - case 'rendah': - return '⚠️ Rendah'; - case 'kritis': - return '🔴 Kritis'; - default: - return 'Unknown'; - } + return StockStatusUtils.label(status, withIcon: true); } String capitalize(String text) => diff --git a/lib/screens/products/product_list_page.dart b/lib/screens/products/product_list_page.dart index 8285366..f6b5026 100644 --- a/lib/screens/products/product_list_page.dart +++ b/lib/screens/products/product_list_page.dart @@ -186,7 +186,7 @@ class _ProductListScreenState extends State with RouteAware { scrollDirection: Axis.horizontal, child: Row( children: - ['semua', 'tersedia', 'rendah', 'kritis'] + ['semua', 'tersedia', 'sedang', 'kritis'] .map( (filter) => Padding( padding: const EdgeInsets.only( diff --git a/lib/screens/reports/report_controller.dart b/lib/screens/reports/report_controller.dart index fefa2d1..4579238 100644 --- a/lib/screens/reports/report_controller.dart +++ b/lib/screens/reports/report_controller.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:async'; import 'package:finalproject/services/ml_service.dart'; import 'package:flutter/material.dart'; diff --git a/lib/screens/reports/report_page.dart b/lib/screens/reports/report_page.dart index e3a1a79..ca109fc 100644 --- a/lib/screens/reports/report_page.dart +++ b/lib/screens/reports/report_page.dart @@ -1,8 +1,14 @@ +import 'dart:io'; + import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/text_styles.dart'; -import 'package:flutter/material.dart'; +import 'package:finalproject/utils/stock_status.dart'; import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; import 'report_controller.dart'; @@ -15,8 +21,12 @@ class ReportScreen extends StatefulWidget { class _ReportScreenState extends State { final DateFormat _dateFormat = DateFormat('dd/MM/yyyy'); + final DateFormat _fileDateFormat = DateFormat('yyyyMMdd_HHmmss'); + final ScrollController _stockTableScrollController = ScrollController(); + final ScrollController _stockHistoryScrollController = ScrollController(); late final ReportController _controller; + String? _lastExportPath; @override void initState() { @@ -26,15 +36,11 @@ class _ReportScreenState extends State { _controller.startAutoRefresh(); } - Color _statusColor(String status) { - switch (status) { - case 'Kritis': - return AppColors.statusError; - case 'Rendah': - return AppColors.statusWarning; - default: - return AppColors.statusSuccess; - } + double _sectionMaxHeight() { + final height = MediaQuery.of(context).size.height * 0.35; + if (height < 220) return 220; + if (height > 360) return 360; + return height; } @override @@ -82,10 +88,10 @@ class _ReportScreenState extends State { width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: AppColors.statusError.withOpacity(0.1), + color: AppColors.statusError.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), border: Border.all( - color: AppColors.statusError.withOpacity(0.3), + color: AppColors.statusError.withValues(alpha: 0.3), ), ), child: Text( @@ -152,7 +158,7 @@ class _ReportScreenState extends State { const SizedBox(height: 12), _buildCriticalItems(_controller.criticalItems), const SizedBox(height: 24), - _buildExportButton(), + _buildExportActions(), const SizedBox(height: 24), ], ), @@ -233,7 +239,7 @@ class _ReportScreenState extends State { width: 42, height: 42, decoration: BoxDecoration( - color: color.withOpacity(0.15), + color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: color, size: 22), @@ -278,59 +284,81 @@ class _ReportScreenState extends State { borderRadius: BorderRadius.circular(16), boxShadow: [AppColors.shadowLight], ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: - items.isEmpty - ? Padding( - padding: const EdgeInsets.all(16), - child: Text( - 'Data stok bahan belum tersedia.', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, + 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'; + + 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(), + ), ), ), - ) - : DataTable( - columns: const [ - DataColumn(label: Text('Nama Bahan')), - DataColumn(label: Text('Stok Tersedia')), - DataColumn(label: Text('Status')), - ], - rows: - items.map((item) { - final statusColor = _statusColor( - item['status'] as String, - ); - final unit = item['unit']?.toString() ?? 'kg'; - 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.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - item['status'] as String, - style: AppTextStyles.labelSmall.copyWith( - color: statusColor, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ], - ); - }).toList(), ), - ), + ), ); } @@ -342,23 +370,31 @@ class _ReportScreenState extends State { borderRadius: BorderRadius.circular(16), boxShadow: [AppColors.shadowLight], ), - child: Column( - children: - items.isEmpty - ? [ - Padding( - padding: const EdgeInsets.all(12), - child: Text( - 'Belum ada riwayat stok masuk.', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), + child: + items.isEmpty + ? Padding( + padding: const EdgeInsets.all(12), + child: Text( + 'Belum ada riwayat stok masuk.', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, ), - ] - : items - .map( - (entry) => ListTile( + ), + ) + : 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]; + + return ListTile( contentPadding: const EdgeInsets.symmetric( horizontal: 8, ), @@ -366,7 +402,9 @@ class _ReportScreenState extends State { width: 40, height: 40, decoration: BoxDecoration( - color: AppColors.primaryBrown.withOpacity(0.12), + color: AppColors.primaryBrown.withValues( + alpha: 0.12, + ), borderRadius: BorderRadius.circular(10), ), child: const Icon( @@ -391,10 +429,11 @@ class _ReportScreenState extends State { fontWeight: FontWeight.w700, ), ), - ), - ) - .toList(), - ), + ); + }, + ), + ), + ), ); } @@ -430,7 +469,9 @@ class _ReportScreenState extends State { width: 40, height: 40, decoration: BoxDecoration( - color: AppColors.secondaryBlue.withOpacity(0.12), + color: AppColors.secondaryBlue.withValues( + alpha: 0.12, + ), borderRadius: BorderRadius.circular(10), ), child: const Icon( @@ -443,7 +484,7 @@ class _ReportScreenState extends State { style: AppTextStyles.labelLarge, ), subtitle: Text( - '${item['needs']} • ${_dateFormat.format(item['date'] as DateTime)}', + '${item['needs']} - ${_dateFormat.format(item['date'] as DateTime)}', style: AppTextStyles.bodySmall.copyWith( color: AppColors.textTertiary, ), @@ -489,10 +530,15 @@ class _ReportScreenState extends State { bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, + interval: 1, getTitlesWidget: (value, meta) { + if (value % 1 != 0) { + return const SizedBox.shrink(); + } if (value < 0 || value >= usageBars.length) { return const SizedBox.shrink(); } + return Padding( padding: const EdgeInsets.only(top: 6), child: Text( @@ -546,7 +592,15 @@ class _ReportScreenState extends State { bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, + interval: 1, getTitlesWidget: (value, meta) { + if (value % 1 != 0) { + return const SizedBox.shrink(); + } + if (value < 0 || value >= demandTrend.length) { + return const SizedBox.shrink(); + } + return Padding( padding: const EdgeInsets.only(top: 6), child: Text( @@ -577,7 +631,7 @@ class _ReportScreenState extends State { dotData: FlDotData(show: false), belowBarData: BarAreaData( show: true, - color: AppColors.secondaryBlue.withOpacity(0.15), + color: AppColors.secondaryBlue.withValues(alpha: 0.15), ), ), ], @@ -596,12 +650,14 @@ class _ReportScreenState extends State { centerSpaceRadius: 40, sections: usagePie.map((entry) { + final value = entry['value'] as double; + return PieChartSectionData( - value: entry['value'] as double, + value: value, color: entry['color'] as Color, radius: 50, - showTitle: true, - title: '${entry['value']}%', + showTitle: value >= 5, + title: '${value.toStringAsFixed(1)}%', titleStyle: AppTextStyles.labelSmall.copyWith( color: Colors.white, fontWeight: FontWeight.w700, @@ -675,7 +731,7 @@ class _ReportScreenState extends State { width: 40, height: 40, decoration: BoxDecoration( - color: AppColors.statusError.withOpacity(0.12), + color: AppColors.statusError.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), child: const Icon( @@ -720,31 +776,176 @@ class _ReportScreenState extends State { ); } - Widget _buildExportButton() { - return SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Export laporan masih menggunakan data dummy.'), + Widget _buildExportActions() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton.icon( + onPressed: _exportReport, + icon: const Icon(Icons.download_rounded), + label: const Text('Export Laporan'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBrown, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - ); - }, - icon: const Icon(Icons.download_rounded), - label: const Text('Export Laporan'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryBrown, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), ), ), - ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _lastExportPath == null ? null : _openLastExport, + icon: const Icon(Icons.open_in_new), + label: const Text('Buka File'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _lastExportPath == null ? null : _shareLastExport, + icon: const Icon(Icons.share_outlined), + label: const Text('Bagikan'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ], ); } + Future _exportReport() async { + if (_controller.stockItems.isEmpty && + _controller.stockHistory.isEmpty && + _controller.predictionItems.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Data laporan masih kosong.')), + ); + return; + } + + try { + final directory = await getApplicationDocumentsDirectory(); + final timestamp = _fileDateFormat.format(DateTime.now()); + final filePath = + '${directory.path}${Platform.pathSeparator}' + 'laporan_$timestamp.csv'; + final file = File(filePath); + + await file.writeAsString(_buildCsvContent()); + + if (!mounted) return; + setState(() => _lastExportPath = filePath); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Laporan tersimpan: $filePath'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Gagal export laporan: $e'))); + } + } + + Future _openLastExport() async { + final path = _lastExportPath; + if (path == null) return; + + final result = await OpenFilex.open(path); + if (!mounted || result.type == ResultType.done) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal membuka file: ${result.message}')), + ); + } + + Future _shareLastExport() async { + final path = _lastExportPath; + if (path == null) return; + + await Share.shareXFiles( + [XFile(path)], + text: 'Laporan & Analitik', + subject: 'Export Laporan', + ); + } + + String _buildCsvContent() { + final buffer = StringBuffer(); + buffer.writeln('Laporan & Analitik'); + buffer.writeln('Tanggal,${_dateFormat.format(DateTime.now())}'); + buffer.writeln(''); + + buffer.writeln('Ringkasan'); + buffer.writeln('Total Produk,${_controller.totalProduk}'); + buffer.writeln('Total Bahan,${_controller.totalBahan}'); + buffer.writeln('Stok Kritis,${_controller.totalKritis}'); + buffer.writeln('Total Prediksi,${_controller.totalPrediksi}'); + buffer.writeln(''); + + buffer.writeln('Laporan Stok Bahan'); + buffer.writeln('Nama Bahan,Stok,Unit,Status'); + for (final item in _controller.stockItems) { + final stockValue = StockStatusUtils.parseStock(item['stock']); + final statusKey = StockStatusUtils.statusFromStock(stockValue); + final statusLabel = StockStatusUtils.label(statusKey); + buffer.writeln( + '${_escapeCsv(item['name'])},${item['stock']},' + '${_escapeCsv(item['unit'])},$statusLabel', + ); + } + buffer.writeln(''); + + buffer.writeln('Riwayat Stok Masuk'); + buffer.writeln('Tanggal,Nama Bahan,Jumlah,Unit'); + for (final entry in _controller.stockHistory) { + final date = _dateFormat.format(entry['date'] as DateTime); + buffer.writeln( + '$date,${_escapeCsv(entry['name'])},${entry['amount']},' + '${_escapeCsv(entry['unit'])}', + ); + } + buffer.writeln(''); + + buffer.writeln('Laporan Prediksi Permintaan'); + buffer.writeln('Tanggal,Produk,Prediksi,Estimasi Kebutuhan'); + for (final item in _controller.predictionItems) { + final date = _dateFormat.format(item['date'] as DateTime); + buffer.writeln( + '$date,${_escapeCsv(item['product'])},${item['prediction']},' + '${_escapeCsv(item['needs'])}', + ); + } + buffer.writeln(''); + + return buffer.toString(); + } + + String _escapeCsv(dynamic value) { + final text = value?.toString() ?? ''; + if (text.contains(',') || text.contains('"') || text.contains('\n')) { + final escaped = text.replaceAll('"', '""'); + return '"$escaped"'; + } + return text; + } + List> _buildUsageBars( List> usageSummary, ) { @@ -799,6 +1000,8 @@ class _ReportScreenState extends State { @override void dispose() { + _stockTableScrollController.dispose(); + _stockHistoryScrollController.dispose(); _controller.dispose(); super.dispose(); } diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index 7f1b62b..f90efe4 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.91:5000'; + static const String baseUrl = 'http://192.168.18.30:5000'; static const int timeoutSeconds = 30; @@ -198,6 +198,63 @@ class MLService { } } + // ======================================================================== + // DASHBOARD ENDPOINTS + // ======================================================================== + + /// Ringkasan dashboard: penggunaan bahan + static Future> getDashboardSummary() async { + try { + final response = await http + .get(Uri.parse('$baseUrl/api/dashboard/summary')) + .timeout(Duration(seconds: timeoutSeconds)); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } + + return { + 'status': false, + 'message': 'Server error: ${response.statusCode}', + 'penggunaan_bahan': [], + }; + } catch (e) { + return { + 'status': false, + 'message': 'Connection error: $e', + 'penggunaan_bahan': [], + }; + } + } + + /// Total bahan keluar yang digunakan hari ini. + static Future> getBahanDigunakanHariIni() async { + const fallback = { + 'total': 0, + 'satuan': 'kg', + 'keterangan': 'total penggunaan hari ini', + }; + + try { + final response = await http + .get(Uri.parse('$baseUrl/api/dashboard/bahan-digunakan-hari-ini')) + .timeout(Duration(seconds: timeoutSeconds)); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data is Map ? data : fallback; + } + + return { + ...fallback, + 'status': false, + 'message': 'Server error: ${response.statusCode}', + }; + } catch (e) { + return {...fallback, 'status': false, 'message': 'Connection error: $e'}; + } + } + // ======================================================================== // REPORT ENDPOINTS - LAPORAN // ======================================================================== @@ -505,6 +562,7 @@ class MLService { required int predictedQuantity, double? rawValue, int? estimatedTotalPrice, + String? estimatedNeeds, double? accuracyR2, double? errorMae, }) async { @@ -518,6 +576,7 @@ class MLService { if (rawValue != null) 'raw_value': rawValue, if (estimatedTotalPrice != null) 'estimated_total_price': estimatedTotalPrice, + if (estimatedNeeds != null) 'estimated_needs': estimatedNeeds, if (accuracyR2 != null) 'accuracy_r2': accuracyR2, if (errorMae != null) 'error_mae': errorMae, }; diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index 338cd02..e12d1ff 100644 --- a/lib/theme/colors.dart +++ b/lib/theme/colors.dart @@ -14,7 +14,7 @@ class AppColors { // Status Colors static const Color statusSuccess = Color(0xFF4CAF50); // Tersedia (green) - static const Color statusWarning = Color(0xFFFF9800); // Rendah (orange) + static const Color statusWarning = Color(0xFFFF9800); // Sedang (orange) static const Color statusError = Color(0xFFF44336); // Kritis (red) // Neutral Colors diff --git a/lib/utils/stock_status.dart b/lib/utils/stock_status.dart new file mode 100644 index 0000000..d349d90 --- /dev/null +++ b/lib/utils/stock_status.dart @@ -0,0 +1,61 @@ +import 'package:finalproject/theme/colors.dart'; +import 'package:flutter/material.dart'; + +class StockStatusUtils { + StockStatusUtils._(); + + static const double criticalStockLimitKg = 5; + static const double warningStockLimitKg = 20; + + static const String statusTersedia = 'tersedia'; + static const String statusSedang = 'sedang'; + static const String statusKritis = 'kritis'; + + static double parseStock(dynamic value) { + if (value is num) return value.toDouble(); + return double.tryParse(value?.toString() ?? '') ?? 0.0; + } + + static String statusFromStock(num stockKg) { + final stock = stockKg.toDouble(); + if (stock < criticalStockLimitKg) return statusKritis; + if (stock < warningStockLimitKg) return statusSedang; + return statusTersedia; + } + + static String normalizeStatus(String status) { + final normalized = status.trim().toLowerCase(); + if (normalized == 'rendah') return statusSedang; + if (normalized == 'habis') return statusKritis; + if (normalized == statusTersedia || + normalized == statusSedang || + normalized == statusKritis) { + return normalized; + } + return statusKritis; + } + + static String label(String status, {bool withIcon = false}) { + switch (normalizeStatus(status)) { + case statusTersedia: + return withIcon ? '✅ Tersedia' : 'Tersedia'; + case statusSedang: + return withIcon ? '⚠️ Sedang' : 'Sedang'; + case statusKritis: + default: + return withIcon ? '🔴 Kritis' : 'Kritis'; + } + } + + static Color color(String status) { + switch (normalizeStatus(status)) { + case statusTersedia: + return AppColors.statusSuccess; + case statusSedang: + return AppColors.statusWarning; + case statusKritis: + default: + return AppColors.statusError; + } + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..17f9da9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,10 @@ import FlutterMacOS import Foundation +import path_provider_foundation +import share_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) } diff --git a/ml_model/app.py b/ml_model/app.py index 36000c4..5567f14 100644 --- a/ml_model/app.py +++ b/ml_model/app.py @@ -97,6 +97,61 @@ def build_report_response(success: bool, message: str, data: list | None = None, 'data': data or [] }), status_code +def ensure_prediction_needs_column(connection, table_name: str = 'predictions') -> str | None: + needs_col = get_existing_column( + connection, + table_name, + ['estimated_needs', 'estimasi_kebutuhan_bahan'] + ) + if needs_col: + return needs_col + + if not table_exists(connection, table_name): + return None + + cursor = connection.cursor() + try: + cursor.execute( + f"ALTER TABLE {escape_table_name(table_name)} ADD COLUMN estimated_needs TEXT" + ) + connection.commit() + return 'estimated_needs' + except Error as e: + logger.warning(f"Could not add estimated_needs column: {e}") + return None + finally: + cursor.close() + +def ensure_stock_usage_tables(connection): + cursor = connection.cursor() + try: + cursor.execute(""" + CREATE TABLE IF NOT EXISTS stock_usage_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + recipe_name VARCHAR(255), + production_quantity INT, + product_id INT, + product_name VARCHAR(255) NOT NULL, + quantity_used FLOAT NOT NULL, + unit VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS stok_keluar ( + id INT AUTO_INCREMENT PRIMARY KEY, + bahan_id INT, + jumlah_keluar FLOAT NOT NULL, + satuan VARCHAR(50) DEFAULT 'kg', + tanggal_keluar DATE NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + connection.commit() + finally: + cursor.close() + def compute_stock_status(stok: float, stok_minimum: float) -> str: if stok > stok_minimum: return 'Aman' @@ -105,7 +160,8 @@ def compute_stock_status(stok: float, stok_minimum: float) -> str: return 'Kritis' def fetch_stock_report(connection): - table_name = get_existing_table(connection, ['bahan', 'products']) + # Prioritaskan tabel products agar stok kritis mengikuti data produk aktif. + table_name = get_existing_table(connection, ['products', 'bahan']) if not table_name: return None, 'Tabel bahan atau products tidak ditemukan' @@ -149,6 +205,173 @@ def fetch_stock_report(connection): return data, None + +def fetch_penggunaan_bahan(connection): + table_name = get_existing_table(connection, ['stok_keluar', 'stock_usage_history']) + if not table_name: + return [] + + cursor = connection.cursor(dictionary=True) + + if table_name == 'stock_usage_history': + cursor.execute( + f""" + SELECT product_name AS nama_bahan, + COALESCE(SUM(quantity_used), 0) AS total_digunakan, + COALESCE(unit, 'kg') AS satuan + FROM {escape_table_name(table_name)} + GROUP BY product_name, unit + ORDER BY total_digunakan DESC + LIMIT 5 + """ + ) + else: + name_col = get_existing_column(connection, table_name, ['nama_bahan']) + qty_col = get_existing_column(connection, table_name, ['jumlah_keluar', 'quantity_used', 'jumlah']) + unit_col = get_existing_column(connection, table_name, ['satuan', 'unit']) + bahan_id_col = get_existing_column(connection, table_name, ['bahan_id', 'product_id']) + + if name_col and qty_col: + unit_select = unit_col if unit_col else "'kg'" + cursor.execute( + f""" + SELECT {name_col} AS nama_bahan, + COALESCE(SUM({qty_col}), 0) AS total_digunakan, + {unit_select} AS satuan + FROM {escape_table_name(table_name)} + GROUP BY {name_col}, {unit_select} + ORDER BY total_digunakan DESC + LIMIT 5 + """ + ) + elif bahan_id_col and qty_col: + bahan_table = get_existing_table(connection, ['bahan', 'products']) + if not bahan_table: + cursor.close() + return [] + + bahan_name_col = get_existing_column(connection, bahan_table, ['nama_bahan', 'product_name', 'name']) + bahan_unit_col = get_existing_column(connection, bahan_table, ['satuan', 'unit']) + if not bahan_name_col: + cursor.close() + return [] + + unit_select = f"b.{bahan_unit_col}" if bahan_unit_col else "'kg'" + cursor.execute( + f""" + SELECT b.{bahan_name_col} AS nama_bahan, + COALESCE(SUM(k.{qty_col}), 0) AS total_digunakan, + {unit_select} AS satuan + FROM {escape_table_name(table_name)} k + JOIN {escape_table_name(bahan_table)} b ON b.id = k.{bahan_id_col} + GROUP BY b.{bahan_name_col}, {unit_select} + ORDER BY total_digunakan DESC + LIMIT 5 + """ + ) + else: + cursor.close() + return [] + + rows = cursor.fetchall() + cursor.close() + + data = [] + for row in rows: + data.append({ + 'nama_bahan': row.get('nama_bahan'), + 'total_digunakan': float(row.get('total_digunakan') or 0), + 'satuan': row.get('satuan') or 'kg' + }) + + return data + + +def getBahanDigunakanHariIni(connection): + """Total bahan keluar hari ini dari stok_keluar, fallback ke stock_usage_history.""" + table_name = get_existing_table(connection, ['stok_keluar']) + totalBahanDigunakanHariIni = 0 + + if table_name: + qty_col = get_existing_column(connection, table_name, ['jumlah_keluar']) + date_col = get_existing_column(connection, table_name, ['tanggal_keluar']) + created_col = get_existing_column(connection, table_name, ['created_at']) + + if qty_col: + table_sql = escape_table_name(table_name) + cursor = connection.cursor() + + if date_col and created_col: + cursor.execute( + f""" + SELECT COALESCE(SUM({qty_col}), 0) + FROM {table_sql} + WHERE ( + {date_col} >= CURDATE() + AND {date_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY) + ) + OR ( + {date_col} IS NULL + AND {created_col} >= CURDATE() + AND {created_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY) + ) + """ + ) + elif date_col: + cursor.execute( + f""" + SELECT COALESCE(SUM({qty_col}), 0) + FROM {table_sql} + WHERE {date_col} >= CURDATE() + AND {date_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY) + """ + ) + elif created_col: + cursor.execute( + f""" + SELECT COALESCE(SUM({qty_col}), 0) + FROM {table_sql} + WHERE {created_col} >= CURDATE() + AND {created_col} < DATE_ADD(CURDATE(), INTERVAL 1 DAY) + """ + ) + else: + cursor.close() + cursor = None + + if cursor: + totalBahanDigunakanHariIni = cursor.fetchone()[0] or 0 + cursor.close() + + if float(totalBahanDigunakanHariIni or 0) == 0 and table_exists(connection, 'stock_usage_history'): + cursor = connection.cursor(dictionary=True) + cursor.execute( + """ + SELECT quantity_used, unit + FROM stock_usage_history + WHERE created_at >= CURDATE() + AND created_at < DATE_ADD(CURDATE(), INTERVAL 1 DAY) + """ + ) + rows = cursor.fetchall() + cursor.close() + totalBahanDigunakanHariIni = sum( + convert_stock_quantity( + float(row.get('quantity_used') or 0), + row.get('unit') or 'kg', + 'kg' + ) + for row in rows + ) + + total = float(totalBahanDigunakanHariIni) + + return { + 'total': int(total) if total.is_integer() else total, + 'satuan': 'kg', + 'keterangan': 'total penggunaan hari ini' + } + def grams_to_kg_rounded(grams: float) -> float: """ Convert grams to kilograms with rounding rules: @@ -164,6 +387,49 @@ def grams_to_kg_rounded(grams: float) -> float: return 1.0 return math.ceil(grams / 500.0) * 0.5 +def normalize_stock_unit(unit: str | None) -> str: + normalized = (unit or '').strip().lower() + if normalized in ['g', 'gr', 'gram', 'grams']: + return 'gr' + if normalized in ['kg', 'kilogram', 'kilograms']: + return 'kg' + if normalized in ['ml', 'mili', 'mililiter', 'milliliter']: + return 'ml' + if normalized in ['l', 'lt', 'ltr', 'liter', 'litre']: + return 'l' + if normalized in ['butir', 'pcs', 'piece', 'pieces']: + return 'butir' + return normalized + +def convert_stock_quantity(quantity: float, from_unit: str, to_unit: str) -> float: + unit = normalize_stock_unit(from_unit) + stock_unit = normalize_stock_unit(to_unit) + + if unit == stock_unit or not unit or not stock_unit: + return quantity + if unit == 'gr' and stock_unit == 'kg': + return quantity / 1000 + if unit == 'kg' and stock_unit == 'gr': + return quantity * 1000 + if unit == 'ml' and stock_unit == 'kg': + return quantity / 1000 + if unit == 'l' and stock_unit == 'kg': + return quantity + if unit == 'kg' and stock_unit == 'ml': + return quantity * 1000 + if unit == 'kg' and stock_unit == 'l': + return quantity + if unit == 'ml' and stock_unit == 'gr': + return quantity + if unit == 'gr' and stock_unit == 'ml': + return quantity + if unit == 'l' and stock_unit == 'ml': + return quantity * 1000 + if unit == 'ml' and stock_unit == 'l': + return quantity / 1000 + + return quantity + # ============================================================================ # LOAD MODELS AT STARTUP # ============================================================================ @@ -575,9 +841,22 @@ def consume_stock(): if not connection: return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500 + ensure_stock_usage_tables(connection) + name_column = get_product_name_column(connection) unit_column_exists = has_product_unit_column(connection) history_enabled = table_exists(connection, 'stock_usage_history') + stock_out_enabled = table_exists(connection, 'stok_keluar') + stock_out_product_col = None + stock_out_qty_col = None + stock_out_unit_col = None + stock_out_date_col = None + if stock_out_enabled: + stock_out_product_col = get_existing_column(connection, 'stok_keluar', ['bahan_id', 'product_id']) + stock_out_qty_col = get_existing_column(connection, 'stok_keluar', ['jumlah_keluar']) + stock_out_unit_col = get_existing_column(connection, 'stok_keluar', ['satuan', 'unit']) + stock_out_date_col = get_existing_column(connection, 'stok_keluar', ['tanggal_keluar']) + stock_out_enabled = bool(stock_out_product_col and stock_out_qty_col) cursor = connection.cursor(dictionary=True) started_transaction = True @@ -623,26 +902,16 @@ def consume_stock(): continue product_unit = (product.get('unit') or '').strip().lower() - effective_quantity = quantity - effective_unit = unit or product_unit + effective_unit = normalize_stock_unit(product_unit or unit) + effective_quantity = convert_stock_quantity( + float(quantity), + unit or effective_unit, + effective_unit + ) - if unit in ['g', 'gram', 'grams']: - try: - effective_quantity = grams_to_kg_rounded(float(quantity)) - effective_unit = 'kg' - logger.info( - f"[stock/consume] Rounded grams to kg: {quantity}g -> {effective_quantity}kg (product_id={product.get('id')})" - ) - except ValueError: - errors.append({ - 'item': item, - 'message': 'Quantity gram harus lebih dari 0' - }) - continue - else: - logger.info( - f"[stock/consume] Using quantity without gram rounding: {quantity} {effective_unit} (product_id={product.get('id')})" - ) + logger.info( + f"[stock/consume] Using quantity: {quantity} {unit or effective_unit} -> {effective_quantity} {effective_unit} (product_id={product.get('id')})" + ) if product['current_stock'] < effective_quantity: errors.append({ @@ -695,6 +964,24 @@ def consume_stock(): ) ) + if stock_out_enabled: + columns = [stock_out_product_col, stock_out_qty_col] + values = [product['id'], quantity] + + if stock_out_unit_col: + columns.append(stock_out_unit_col) + values.append(entry['unit']) + if stock_out_date_col: + columns.append(stock_out_date_col) + values.append(datetime.now().date()) + + column_sql = ', '.join(columns) + placeholders = ', '.join(['%s'] * len(columns)) + cursor.execute( + f"INSERT INTO stok_keluar ({column_sql}) VALUES ({placeholders})", + tuple(values) + ) + results.append({ 'product_id': product['id'], 'product_name': product['name'], @@ -970,13 +1257,21 @@ def save_prediction(): if not connection: return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500 - cursor = connection.cursor() - cursor.execute(""" - INSERT INTO predictions - (product_name, category, unit_price, prediction_date, predicted_quantity, - raw_value, estimated_total_price, accuracy_r2, error_mae) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - """, ( + table_name = 'predictions' + needs_col = ensure_prediction_needs_column(connection, table_name) + + columns = [ + 'product_name', + 'category', + 'unit_price', + 'prediction_date', + 'predicted_quantity', + 'raw_value', + 'estimated_total_price', + 'accuracy_r2', + 'error_mae' + ] + values = [ data['product_name'], data['category'], data['unit_price'], @@ -986,7 +1281,19 @@ def save_prediction(): data.get('estimated_total_price'), data.get('accuracy_r2'), data.get('error_mae') - )) + ] + + if needs_col: + columns.append(needs_col) + values.append(data.get('estimated_needs') or data.get('estimasi_kebutuhan_bahan')) + + cursor = connection.cursor() + placeholders = ', '.join(['%s'] * len(columns)) + column_sql = ', '.join(columns) + cursor.execute( + f"INSERT INTO predictions ({column_sql}) VALUES ({placeholders})", + tuple(values) + ) connection.commit() prediction_id = cursor.lastrowid @@ -1205,6 +1512,68 @@ def laporan_prediksi(): return build_report_response(False, f'Gagal mengambil data: {str(e)}', [], 500) +# ============================================================================ +# DASHBOARD ENDPOINTS +# ============================================================================ + +@app.route('/api/dashboard/bahan-digunakan-hari-ini', methods=['GET']) +def bahanDigunakanHariIni(): + """Total bahan keluar hari ini dari stok_keluar.""" + connection = None + try: + connection = get_db_connection() + if not connection: + return jsonify({ + 'status': False, + 'message': 'Database connection failed', + 'total': 0, + 'satuan': 'kg', + 'keterangan': 'total penggunaan hari ini' + }), 500 + + data = getBahanDigunakanHariIni(connection) + return jsonify(data), 200 + except Exception as e: + logger.error(f"Bahan digunakan hari ini error: {str(e)}") + return jsonify({ + 'status': False, + 'message': f'Gagal mengambil data: {str(e)}', + 'total': 0, + 'satuan': 'kg', + 'keterangan': 'total penggunaan hari ini' + }), 500 + finally: + if connection: + connection.close() + +@app.route('/api/dashboard/summary', methods=['GET']) +def dashboard_summary(): + """Ringkasan dashboard untuk grafik penggunaan bahan.""" + try: + connection = get_db_connection() + if not connection: + return jsonify({ + 'status': False, + 'message': 'Database connection failed', + 'penggunaan_bahan': [] + }), 500 + + penggunaan = fetch_penggunaan_bahan(connection) + connection.close() + + return jsonify({ + 'status': True, + 'penggunaan_bahan': penggunaan + }), 200 + except Exception as e: + logger.error(f"Dashboard summary error: {str(e)}") + return jsonify({ + 'status': False, + 'message': f'Gagal mengambil data: {str(e)}', + 'penggunaan_bahan': [] + }), 500 + + # ============================================================================ # RECIPES ENDPOINTS # ============================================================================ diff --git a/ml_model/database_setup.py b/ml_model/database_setup.py index 0006792..42f97e3 100644 --- a/ml_model/database_setup.py +++ b/ml_model/database_setup.py @@ -93,6 +93,7 @@ def create_tables(): predicted_quantity DECIMAL(10, 2), raw_value DECIMAL(10, 2), estimated_total_price DECIMAL(10, 2), + estimated_needs TEXT, accuracy_r2 DECIMAL(5, 4), error_mae DECIMAL(5, 4), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/ml_model/setup_database.sql b/ml_model/setup_database.sql index 4574471..36b7117 100644 --- a/ml_model/setup_database.sql +++ b/ml_model/setup_database.sql @@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS predictions ( predicted_quantity INT NOT NULL, raw_value FLOAT, estimated_total_price INT, + estimated_needs TEXT, accuracy_r2 FLOAT, error_mae FLOAT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP diff --git a/pubspec.lock b/pubspec.lock index 5401d6b..6e4cd9c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + url: "https://pub.dev" + source: hosted + version: "0.3.5" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -65,6 +81,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" fl_chart: dependency: "direct main" description: @@ -91,6 +131,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" http: dependency: "direct main" description: @@ -171,6 +216,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" + url: "https://pub.dev" + source: hosted + version: "4.7.0" path: dependency: transitive description: @@ -179,6 +240,86 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" + url: "https://pub.dev" + source: hosted + version: "2.2.19" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da + url: "https://pub.dev" + source: hosted + version: "10.1.4" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b + url: "https://pub.dev" + source: hosted + version: "5.0.2" sky_engine: dependency: transitive description: flutter @@ -240,6 +381,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -264,6 +445,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" sdks: dart: ">=3.7.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index becb186..95006c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,13 @@ dependencies: # Charts fl_chart: ^0.66.2 + # File export + path_provider: ^2.1.4 + + # Open file + share + open_filex: ^4.5.0 + share_plus: ^10.0.2 + dev_dependencies: flutter_test: sdk: flutter diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..c3384ec 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,12 @@ #include "generated_plugin_registrant.h" +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..01d3836 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,8 @@ # list(APPEND FLUTTER_PLUGIN_LIST + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST