memperbarui fitur fitur 1
This commit is contained in:
parent
fdbe51d7f3
commit
16594b95e6
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> penggunaanBahan = [];
|
||||
final List<Map<String, dynamic>> lowStockItems = [];
|
||||
|
||||
void setSelectedIndex(int index) {
|
||||
selectedIndex = index;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> 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<String, dynamic>;
|
||||
final summary = results[1] as Map<String, dynamic>;
|
||||
final products = results[2] as List;
|
||||
final critical = results[3] as Map<String, dynamic>;
|
||||
|
||||
_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<String, dynamic> 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),
|
||||
),
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
class _DashboardScreenState extends State<DashboardScreen> 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<DashboardScreen> {
|
|||
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<DashboardScreen> {
|
|||
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<DashboardScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Grafik Penjualan',
|
||||
'Penggunaan Bahan',
|
||||
style: AppTextStyles.headlineSmall
|
||||
.copyWith(
|
||||
color: AppColors.textPrimary,
|
||||
|
|
@ -195,7 +218,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
),
|
||||
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<DashboardScreen> {
|
|||
],
|
||||
),
|
||||
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<DashboardScreen> {
|
|||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -469,9 +462,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||
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<DashboardScreen> {
|
|||
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<DashboardScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
String _formatNumber(num value) {
|
||||
if (value % 1 == 0) {
|
||||
return value.toInt().toString();
|
||||
}
|
||||
|
||||
return value.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
Widget _buildUsageRow(Map<String, dynamic> item) {
|
||||
final total = (item['total'] as num?)?.toDouble() ?? 0.0;
|
||||
final unit = item['unit']?.toString() ?? 'kg';
|
||||
final max = _controller.penggunaanBahan.fold<double>(
|
||||
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<Color>(AppColors.secondaryBlue),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
routeObserver.unsubscribe(this);
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, double> 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<String, double> get stockUsage {
|
||||
final required = requiredIngredients;
|
||||
final rounded = <String, double>{};
|
||||
final usage = <String, double>{};
|
||||
|
||||
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<Map<String, dynamic>> 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -706,10 +706,8 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
_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<PredictionScreen> {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
'${_controller.formatQuantity(stockKg)} kg',
|
||||
_controller.formatStockQuantity(
|
||||
ingredient,
|
||||
stockKg,
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize:
|
||||
12,
|
||||
|
|
@ -921,7 +922,7 @@ class _PredictionScreenState extends State<PredictionScreen> {
|
|||
),
|
||||
),
|
||||
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<PredictionScreen> {
|
|||
),
|
||||
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<PredictionScreen> {
|
|||
),
|
||||
),
|
||||
Text(
|
||||
'-${_controller.formatQuantity(amount)} $stockUnit',
|
||||
'-${_controller.formatStockQuantity(ingredient, amount)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ class _ProductListScreenState extends State<ProductListScreen> with RouteAware {
|
|||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children:
|
||||
['semua', 'tersedia', 'rendah', 'kritis']
|
||||
['semua', 'tersedia', 'sedang', 'kritis']
|
||||
.map(
|
||||
(filter) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import 'dart:async';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:finalproject/services/ml_service.dart';
|
||||
import 'package:flutter/material.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<ReportScreen> {
|
||||
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<ReportScreen> {
|
|||
_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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -430,7 +469,9 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
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<ReportScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
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<void> _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<void> _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<void> _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<Map<String, dynamic>> _buildUsageBars(
|
||||
List<Map<String, dynamic>> usageSummary,
|
||||
) {
|
||||
|
|
@ -799,6 +1000,8 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_stockTableScrollController.dispose();
|
||||
_stockHistoryScrollController.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<String, dynamic> ? 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
425
ml_model/app.py
425
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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
199
pubspec.lock
199
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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
Loading…
Reference in New Issue