From eae95fa4eb25e7ee2db39fbbddb27d32bc1c88d9 Mon Sep 17 00:00:00 2001 From: rhanarmt Date: Thu, 23 Apr 2026 14:18:00 +0700 Subject: [PATCH] Alur Logika Stock It ke produk --- lib/main.dart | 14 +- lib/models/product_model.dart | 6 + lib/pages/prediction_page.dart | 135 -- .../dashboard/dashboard_controller.dart | 32 + lib/screens/dashboard/dashboard_page.dart | 539 ++++++++ lib/screens/dashboard_screen.dart | 540 -------- lib/screens/login/login_controller.dart | 42 + lib/screens/login/login_page.dart | 300 ++++ lib/screens/login_screen.dart | 325 ----- .../prediction/prediction_controller.dart | 136 ++ lib/screens/prediction/prediction_page.dart | 956 +++++++++++++ lib/screens/prediction_screen.dart | 909 ------------- lib/screens/product_list_screen.dart | 637 --------- .../products/product_list_controller.dart | 139 ++ lib/screens/products/product_list_page.dart | 552 ++++++++ lib/screens/report_screen.dart | 468 ------- lib/screens/reports/report_controller.dart | 22 + lib/screens/reports/report_page.dart | 473 +++++++ lib/screens/settings/settings_controller.dart | 16 + lib/screens/settings/settings_page.dart | 420 ++++++ lib/screens/settings_screen.dart | 412 ------ .../transaction/transaction_controller.dart | 258 ++++ lib/screens/transaction/transaction_page.dart | 1016 ++++++++++++++ lib/screens/transaction_screen.dart | 1205 ----------------- lib/services/ml_service.dart | 6 +- ml_model/app.py | 71 +- ml_model/setup_database.sql | 20 +- 27 files changed, 4990 insertions(+), 4659 deletions(-) delete mode 100644 lib/pages/prediction_page.dart create mode 100644 lib/screens/dashboard/dashboard_controller.dart create mode 100644 lib/screens/dashboard/dashboard_page.dart delete mode 100644 lib/screens/dashboard_screen.dart create mode 100644 lib/screens/login/login_controller.dart create mode 100644 lib/screens/login/login_page.dart delete mode 100644 lib/screens/login_screen.dart create mode 100644 lib/screens/prediction/prediction_controller.dart create mode 100644 lib/screens/prediction/prediction_page.dart delete mode 100644 lib/screens/prediction_screen.dart delete mode 100644 lib/screens/product_list_screen.dart create mode 100644 lib/screens/products/product_list_controller.dart create mode 100644 lib/screens/products/product_list_page.dart delete mode 100644 lib/screens/report_screen.dart create mode 100644 lib/screens/reports/report_controller.dart create mode 100644 lib/screens/reports/report_page.dart create mode 100644 lib/screens/settings/settings_controller.dart create mode 100644 lib/screens/settings/settings_page.dart delete mode 100644 lib/screens/settings_screen.dart create mode 100644 lib/screens/transaction/transaction_controller.dart create mode 100644 lib/screens/transaction/transaction_page.dart delete mode 100644 lib/screens/transaction_screen.dart diff --git a/lib/main.dart b/lib/main.dart index f908060..bc469f4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:finalproject/theme/app_theme.dart'; import 'package:finalproject/screens/splash_screen.dart'; -import 'package:finalproject/screens/login_screen.dart'; -import 'package:finalproject/screens/dashboard_screen.dart'; -import 'package:finalproject/screens/prediction_screen.dart'; -import 'package:finalproject/screens/transaction_screen.dart'; -import 'package:finalproject/screens/product_list_screen.dart'; -import 'package:finalproject/screens/report_screen.dart'; -import 'package:finalproject/screens/settings_screen.dart'; +import 'package:finalproject/screens/login/login_page.dart'; +import 'package:finalproject/screens/dashboard/dashboard_page.dart'; +import 'package:finalproject/screens/prediction/prediction_page.dart'; +import 'package:finalproject/screens/transaction/transaction_page.dart'; +import 'package:finalproject/screens/products/product_list_page.dart'; +import 'package:finalproject/screens/reports/report_page.dart'; +import 'package:finalproject/screens/settings/settings_page.dart'; void main() { runApp(const MyApp()); diff --git a/lib/models/product_model.dart b/lib/models/product_model.dart index 5cf2a6d..824b605 100644 --- a/lib/models/product_model.dart +++ b/lib/models/product_model.dart @@ -4,6 +4,7 @@ class Product { final String category; final int price; final int stock; + final String unit; final String status; // 'tersedia', 'rendah', 'kritis' Product({ @@ -12,6 +13,7 @@ class Product { required this.category, required this.price, required this.stock, + this.unit = 'kg', this.status = 'tersedia', }); @@ -22,6 +24,7 @@ class Product { category: json['category'] as String, price: json['price'] as int, stock: json['stock'] as int, + unit: (json['unit'] as String?) ?? 'kg', status: json['status'] as String? ?? 'tersedia', ); } @@ -33,6 +36,7 @@ class Product { 'category': category, 'price': price, 'stock': stock, + 'unit': unit, 'status': status, }; } @@ -43,6 +47,7 @@ class Product { String? category, int? price, int? stock, + String? unit, String? status, }) { return Product( @@ -51,6 +56,7 @@ class Product { category: category ?? this.category, price: price ?? this.price, stock: stock ?? this.stock, + unit: unit ?? this.unit, status: status ?? this.status, ); } diff --git a/lib/pages/prediction_page.dart b/lib/pages/prediction_page.dart deleted file mode 100644 index 7ebb61e..0000000 --- a/lib/pages/prediction_page.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:flutter/material.dart'; -import '../services/ml_service.dart'; - -class PredictionPage extends StatefulWidget { - const PredictionPage({Key? key}) : super(key: key); - - @override - State createState() => _PredictionPageState(); -} - -class _PredictionPageState extends State { - bool _isLoading = false; - String? _errorMessage; - Map? _predictionResult; - - final _tahunController = TextEditingController(text: '2024'); - final _bulanController = TextEditingController(text: '4'); - final _hariController = TextEditingController(text: '4'); - final _hariDalamMingguController = TextEditingController(text: '3'); - - @override - void initState() { - super.initState(); - _checkAPIHealth(); - } - - Future _checkAPIHealth() async { - final isHealthy = await MLService.healthCheck(); - if (!isHealthy) { - setState(() { - _errorMessage = 'API tidak tersedia. Pastikan server Python sudah berjalan.'; - }); - } - } - - Future _predict() async { - setState(() { - _isLoading = true; - _errorMessage = null; - _predictionResult = null; - }); - - try { - final result = await MLService.prediksiStok( - tahun: int.parse(_tahunController.text), - bulan: int.parse(_bulanController.text), - hari: int.parse(_hariController.text), - hariDalamMinggu: int.parse(_hariDalamMingguController.text), - hariMinggu: int.parse(_hariDalamMingguController.text), - hargaSatuanUpdate: 50000, - totalHargaUpdate: 250000, - produkEncoded: 2, - namaProdukEncoded: 2, - kategoriProdukEncoded: 1, - ); - - setState(() { - if (result['status'] == 'success') { - _predictionResult = result; - _errorMessage = null; - } else { - _errorMessage = result['message'] ?? 'Prediksi gagal'; - _predictionResult = null; - } - }); - } catch (e) { - setState(() { - _errorMessage = 'Error: $e'; - _predictionResult = null; - }); - } finally { - setState(() => _isLoading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Prediksi Permintaan Stok')), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - if (_errorMessage != null) - Container( - padding: const EdgeInsets.all(12.0), - margin: const EdgeInsets.only(bottom: 16.0), - decoration: BoxDecoration( - color: Colors.red[100], - border: Border.all(color: Colors.red), - borderRadius: BorderRadius.circular(8.0), - ), - child: Text(_errorMessage!, style: const TextStyle(color: Colors.red)), - ), - TextField(controller: _tahunController, decoration: const InputDecoration(labelText: 'Tahun')), - TextField(controller: _bulanController, decoration: const InputDecoration(labelText: 'Bulan')), - TextField(controller: _hariController, decoration: const InputDecoration(labelText: 'Hari')), - TextField(controller: _hariDalamMingguController, decoration: const InputDecoration(labelText: 'Hari Minggu')), - const SizedBox(height: 20.0), - ElevatedButton(onPressed: _isLoading ? null : _predict, style: ElevatedButton.styleFrom(minimumSize: const Size.fromHeight(50)), child: _isLoading ? const CircularProgressIndicator() : const Text('PREDIKSI')), - const SizedBox(height: 20.0), - if (_predictionResult != null) - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('HASIL PREDIKSI', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 12.0), - Text('Jumlah Unit: ${_predictionResult!['prediksi']['jumlah_unit']}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.green)), - Text('Nilai Raw: ${_predictionResult!['prediksi']['nilai_raw']}'), - const Divider(), - Text('R² Score: ${_predictionResult!['model_accuracy']['r2_score']}'), - Text('MAE: ${_predictionResult!['model_accuracy']['mae']}'), - Text('RMSE: ${_predictionResult!['model_accuracy']['rmse']}'), - ], - ), - ), - ), - ], - ), - ), - ); - } - - @override - void dispose() { - _tahunController.dispose(); - _bulanController.dispose(); - _hariController.dispose(); - _hariDalamMingguController.dispose(); - super.dispose(); - } -} diff --git a/lib/screens/dashboard/dashboard_controller.dart b/lib/screens/dashboard/dashboard_controller.dart new file mode 100644 index 0000000..9287543 --- /dev/null +++ b/lib/screens/dashboard/dashboard_controller.dart @@ -0,0 +1,32 @@ +import 'package:finalproject/theme/colors.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, + }, + ]; + + void setSelectedIndex(int index) { + selectedIndex = index; + notifyListeners(); + } +} diff --git a/lib/screens/dashboard/dashboard_page.dart b/lib/screens/dashboard/dashboard_page.dart new file mode 100644 index 0000000..cc34a62 --- /dev/null +++ b/lib/screens/dashboard/dashboard_page.dart @@ -0,0 +1,539 @@ +import 'package:finalproject/theme/colors.dart'; +import 'package:finalproject/theme/text_styles.dart'; +import 'package:flutter/material.dart'; + +import 'dashboard_controller.dart'; + +class DashboardScreen extends StatefulWidget { + const DashboardScreen({super.key}); + + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + late final DashboardController _controller; + + @override + void initState() { + super.initState(); + _controller = DashboardController(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: AppColors.bgLight, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(240), + child: Container( + decoration: BoxDecoration( + color: AppColors.primaryBrown, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + ), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Stack( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(10), + ), + child: IconButton( + icon: const Icon( + Icons.notifications_none, + color: Colors.white, + size: 20, + ), + onPressed: () {}, + padding: EdgeInsets.zero, + ), + ), + Positioned( + right: 4, + top: 4, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: AppColors.statusError, + borderRadius: BorderRadius.circular(11), + border: Border.all( + color: AppColors.primaryBrown, + width: 2, + ), + ), + child: const Center( + child: Text( + '3', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 2), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Selamat Datang', + style: TextStyle( + color: Colors.white70, + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + SizedBox(height: 2), + Text( + 'Admin Sulastri', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + Expanded( + child: _buildStatCard( + title: 'Total Penjualan', + value: 'Rp 67 Jt', + change: '+12.5%', + icon: Icons.trending_up, + iconBgColor: AppColors.statusSuccess, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildStatCard( + title: 'Produk', + value: '24', + change: 'Aktif', + icon: Icons.shopping_bag, + iconBgColor: AppColors.secondaryBlue, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppColors.shadowLight], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Grafik Penjualan', + style: AppTextStyles.headlineSmall + .copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + '6 Bulan Terakhir', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + ), + ], + ), + Icon( + Icons.trending_up, + color: AppColors.statusSuccess, + 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: 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(), + ), + ], + ), + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppColors.shadowLight], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: AppColors.statusWarning, + size: 22, + ), + const SizedBox(width: 10), + Text( + 'Stok Menipis', + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 16), + ..._controller.lowStockItems.map((item) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + item['name'], + style: AppTextStyles.labelLarge + .copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Stok: ${item['stock']}', + style: AppTextStyles.bodySmall + .copyWith( + color: AppColors.textTertiary, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: item['statusColor'], + borderRadius: BorderRadius.circular(6), + ), + child: Text( + item['status'], + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pushNamed('/prediction'); + }, + icon: const Icon(Icons.trending_up, size: 20), + label: const Text( + 'Lihat Prediksi', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.secondaryBlue, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 2, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.check_circle, size: 20), + label: const Text( + 'Rekomendasi Stok', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.statusSuccess, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + elevation: 2, + ), + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _controller.selectedIndex, + onTap: (index) { + _controller.setSelectedIndex(index); + switch (index) { + case 0: + break; + case 1: + Navigator.of(context).pushNamed('/products'); + break; + case 2: + Navigator.of(context).pushNamed('/transaction'); + break; + case 3: + Navigator.of(context).pushNamed('/prediction'); + break; + case 4: + Navigator.of(context).pushNamed('/reports'); + break; + case 5: + Navigator.of(context).pushNamed('/settings'); + break; + } + }, + type: BottomNavigationBarType.fixed, + backgroundColor: Colors.white, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + label: 'Dashboard', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_bag_outlined), + label: 'Produk', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_cart_outlined), + label: 'Stock In', + ), + BottomNavigationBarItem( + icon: Icon(Icons.trending_up_outlined), + label: 'Prediksi', + ), + BottomNavigationBarItem( + icon: Icon(Icons.description_outlined), + label: 'Laporan', + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings_outlined), + label: 'Pengaturan', + ), + ], + ), + ); + }, + ); + } + + Widget _buildStatCard({ + required String title, + required String value, + required String change, + required IconData icon, + required Color iconBgColor, + }) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(14), + boxShadow: [AppColors.shadowLight], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textTertiary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 6), + Text( + value, + style: AppTextStyles.titleLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + change, + style: AppTextStyles.bodySmall.copyWith( + color: + change.contains('+') + ? AppColors.statusSuccess + : AppColors.textTertiary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: iconBgColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Center(child: Icon(icon, color: iconBgColor, size: 22)), + ), + ], + ), + ], + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart deleted file mode 100644 index 90315ef..0000000 --- a/lib/screens/dashboard_screen.dart +++ /dev/null @@ -1,540 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:finalproject/theme/colors.dart'; -import 'package:finalproject/theme/text_styles.dart'; - -class DashboardScreen extends StatefulWidget { - const DashboardScreen({Key? key}) : super(key: key); - - @override - State createState() => _DashboardScreenState(); -} - -class _DashboardScreenState extends State { - int _selectedIndex = 0; - - // Sample data for low stock items - 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, - }, - ]; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.bgLight, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(240), - child: Container( - decoration: BoxDecoration( - color: AppColors.primaryBrown, - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(24), - bottomRight: Radius.circular(24), - ), - ), - child: SafeArea( - child: Column( - children: [ - // Top section: Menu + Greeting + Notification - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Top row: Menu + Notification - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.3), - borderRadius: BorderRadius.circular(10), - ), - child: IconButton( - icon: const Icon(Icons.menu, - color: Colors.white, size: 20), - onPressed: () {}, - padding: EdgeInsets.zero, - ), - ), - Stack( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.3), - borderRadius: BorderRadius.circular(10), - ), - child: IconButton( - icon: const Icon(Icons.notifications_none, - color: Colors.white, size: 20), - onPressed: () {}, - padding: EdgeInsets.zero, - ), - ), - Positioned( - right: 4, - top: 4, - child: Container( - width: 22, - height: 22, - decoration: BoxDecoration( - color: AppColors.statusError, - borderRadius: BorderRadius.circular(11), - border: - Border.all( - color: AppColors.primaryBrown, - width: 2, - ), - ), - child: Center( - child: Text( - '3', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ), - ], - ), - // Greeting + Title - const SizedBox(height: 6), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text( - 'Selamat Datang', - style: TextStyle( - color: Colors.white70, - fontSize: 13, - fontWeight: FontWeight.w400, - ), - ), - SizedBox(height: 2), - Text( - 'Admin Sulastri', - style: TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - ), - ), - ], - ), - ], - ), - ), - // Stats Cards section (overlap ke bawah) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Expanded( - child: _buildStatCard( - title: 'Total Penjualan', - value: 'Rp 67 Jt', - change: '+12.5%', - icon: Icons.trending_up, - iconBgColor: AppColors.statusSuccess, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _buildStatCard( - title: 'Produk', - value: '24', - change: 'Aktif', - icon: Icons.shopping_bag, - iconBgColor: AppColors.secondaryBlue, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - body: SingleChildScrollView( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - - // Chart Section - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Grafik Penjualan', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - '6 Bulan Terakhir', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, - ), - ), - ], - ), - Icon(Icons.trending_up, - color: AppColors.statusSuccess, size: 20), - ], - ), - const SizedBox(height: 20), - // Area chart placeholder dengan axes - SizedBox( - height: 200, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Y-axis labels - ...['80000000', '60000000', '40000000', '20000000', - '0'] - .map((label) { - return Text( - label, - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.grey300, - ), - ); - }).toList(), - ], - ), - ), - const SizedBox(height: 12), - // X-axis labels - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: - ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun'] - .map((month) { - return Text( - month, - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textTertiary, - ), - ); - }).toList(), - ), - ], - ), - ), - const SizedBox(height: 24), - - // Low Stock Section - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.warning_amber_rounded, - color: AppColors.statusWarning, size: 22), - const SizedBox(width: 10), - Text( - 'Stok Menipis', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - ], - ), - const SizedBox(height: 16), - ...lowStockItems.map((item) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - item['name'], - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - 'Stok: ${item['stock']}', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: item['statusColor'], - borderRadius: BorderRadius.circular(6), - ), - child: Text( - item['status'], - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ], - ), - ); - }).toList(), - ], - ), - ), - const SizedBox(height: 24), - - // Action Buttons - BIGGER & BOLDER - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () { - Navigator.of(context).pushNamed('/prediction'); - }, - icon: const Icon(Icons.trending_up, size: 20), - label: const Text( - 'Lihat Prediksi', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.secondaryBlue, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 2, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.check_circle, size: 20), - label: const Text( - 'Rekomendasi Stok', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.statusSuccess, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - elevation: 2, - ), - ), - ), - ], - ), - const SizedBox(height: 24), - ], - ), - ), - ], - ), - ), - bottomNavigationBar: BottomNavigationBar( - currentIndex: _selectedIndex, - onTap: (index) { - setState(() => _selectedIndex = index); - switch (index) { - case 0: - break; - case 1: - Navigator.of(context).pushNamed('/products'); - break; - case 2: - Navigator.of(context).pushNamed('/transaction'); - break; - case 3: - Navigator.of(context).pushNamed('/prediction'); - break; - case 4: - Navigator.of(context).pushNamed('/reports'); - break; - case 5: - Navigator.of(context).pushNamed('/settings'); - break; - } - }, - type: BottomNavigationBarType.fixed, - backgroundColor: Colors.white, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_outlined), - label: 'Dashboard', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_bag_outlined), - label: 'Produk', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_cart_outlined), - label: 'Transaksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.trending_up_outlined), - label: 'Prediksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.description_outlined), - label: 'Laporan', - ), - BottomNavigationBarItem( - icon: Icon(Icons.settings_outlined), - label: 'Pengaturan', - ), - ], - ), - ); - } - - Widget _buildStatCard({ - required String title, - required String value, - required String change, - required IconData icon, - required Color iconBgColor, - }) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(14), - boxShadow: [AppColors.shadowLight], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 6), - Text( - value, - style: AppTextStyles.titleLarge.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 6), - Text( - change, - style: AppTextStyles.bodySmall.copyWith( - color: - change.contains('+') - ? AppColors.statusSuccess - : AppColors.textTertiary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: iconBgColor.withOpacity(0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: Icon( - icon, - color: iconBgColor, - size: 22, - ), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/screens/login/login_controller.dart b/lib/screens/login/login_controller.dart new file mode 100644 index 0000000..a3d99a7 --- /dev/null +++ b/lib/screens/login/login_controller.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class LoginController extends ChangeNotifier { + final usernameController = TextEditingController(text: 'admin@sulastri.com'); + final passwordController = TextEditingController(text: 'password'); + + bool isPasswordVisible = false; + bool isLoading = false; + + void togglePasswordVisibility() { + isPasswordVisible = !isPasswordVisible; + notifyListeners(); + } + + String? validateLoginForm() { + if (usernameController.text.isEmpty || passwordController.text.isEmpty) { + return 'Email dan password tidak boleh kosong'; + } + return null; + } + + Future handleLogin() async { + final error = validateLoginForm(); + if (error != null) return false; + + isLoading = true; + notifyListeners(); + + await Future.delayed(const Duration(seconds: 1)); + + isLoading = false; + notifyListeners(); + return true; + } + + @override + void dispose() { + usernameController.dispose(); + passwordController.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/login/login_page.dart b/lib/screens/login/login_page.dart new file mode 100644 index 0000000..83cbb9f --- /dev/null +++ b/lib/screens/login/login_page.dart @@ -0,0 +1,300 @@ +import 'package:finalproject/theme/colors.dart'; +import 'package:finalproject/theme/text_styles.dart'; +import 'package:flutter/material.dart'; + +import 'login_controller.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + late final LoginController _controller; + + @override + void initState() { + super.initState(); + _controller = LoginController(); + } + + Future _onLoginPressed() async { + final validationError = _controller.validateLoginForm(); + if (validationError != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(validationError), + backgroundColor: AppColors.statusError, + ), + ); + return; + } + + final success = await _controller.handleLogin(); + if (!mounted) return; + + if (success) { + Navigator.of(context).pushReplacementNamed('/dashboard'); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: AppColors.bgLight, + body: SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Center( + child: Column( + children: [ + const SizedBox(height: 40), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppColors.shadowLight], + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 32.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: AppColors.primaryBrown, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.home_rounded, + color: Colors.white, + size: 40, + ), + ), + const SizedBox(height: 24), + Text( + 'Selamat Datang', + style: AppTextStyles.displaySmall.copyWith( + color: AppColors.primaryBrown, + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Masuk ke akun Anda', + style: AppTextStyles.bodyMedium.copyWith( + color: AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Email atau Username', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + ), + const SizedBox(height: 8), + TextField( + controller: _controller.usernameController, + enabled: !_controller.isLoading, + decoration: InputDecoration( + hintText: 'Masukkan email atau username', + hintStyle: AppTextStyles.bodyMedium.copyWith( + color: AppColors.grey300, + ), + prefixIcon: Icon( + Icons.email_outlined, + color: AppColors.textSecondary, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: AppColors.grey300, + ), + ), + filled: true, + fillColor: AppColors.bgLight, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + const SizedBox(height: 24), + Align( + alignment: Alignment.centerLeft, + child: Text( + 'Password', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + ), + const SizedBox(height: 8), + TextField( + controller: _controller.passwordController, + enabled: !_controller.isLoading, + obscureText: !_controller.isPasswordVisible, + decoration: InputDecoration( + hintText: 'Masukkan password', + hintStyle: AppTextStyles.bodyMedium.copyWith( + color: AppColors.grey300, + ), + prefixIcon: Icon( + Icons.lock_outline, + color: AppColors.textSecondary, + ), + suffixIcon: IconButton( + icon: Icon( + _controller.isPasswordVisible + ? Icons.visibility + : Icons.visibility_off, + color: AppColors.textSecondary, + ), + onPressed: + _controller.isLoading + ? null + : _controller + .togglePasswordVisibility, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: AppColors.grey300, + ), + ), + filled: true, + fillColor: AppColors.bgLight, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: + _controller.isLoading ? null : () {}, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + ), + child: Text( + 'Lupa Password?', + style: AppTextStyles.labelMedium.copyWith( + color: AppColors.primaryBrown, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: + _controller.isLoading + ? null + : _onLoginPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBrown, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: + _controller.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Text( + 'Masuk', + style: AppTextStyles.labelLarge + .copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 24), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.bgLight, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.grey300), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Demo: Klik "Masuk" untuk melanjutkan', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.statusError, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 48), + Text( + '© 2025 Toko Bahan Kue Sulastri', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart deleted file mode 100644 index 1c6d4ff..0000000 --- a/lib/screens/login_screen.dart +++ /dev/null @@ -1,325 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:finalproject/theme/colors.dart'; -import 'package:finalproject/theme/text_styles.dart'; - -class LoginScreen extends StatefulWidget { - const LoginScreen({Key? key}) : super(key: key); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - late TextEditingController _usernameController; - late TextEditingController _passwordController; - bool _isPasswordVisible = false; - bool _isLoading = false; - - @override - void initState() { - super.initState(); - _usernameController = TextEditingController(text: 'admin@sulastri.com'); - _passwordController = TextEditingController(text: 'password'); - } - - @override - void dispose() { - _usernameController.dispose(); - _passwordController.dispose(); - super.dispose(); - } - - Future _handleLogin() async { - // Validation - if (_usernameController.text.isEmpty || _passwordController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Email dan password tidak boleh kosong'), - backgroundColor: AppColors.statusError, - ), - ); - return; - } - - setState(() => _isLoading = true); - - // Simulate login process - await Future.delayed(const Duration(seconds: 1)); - - setState(() => _isLoading = false); - - if (mounted) { - Navigator.of(context).pushReplacementNamed('/dashboard'); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.bgLight, - body: SafeArea( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: Center( - child: Column( - children: [ - const SizedBox(height: 40), - - // Card Container - Container( - width: double.infinity, - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 32.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Logo Icon - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - color: AppColors.primaryBrown, - borderRadius: BorderRadius.circular(16), - ), - child: const Icon( - Icons.home_rounded, - color: Colors.white, - size: 40, - ), - ), - const SizedBox(height: 24), - - // Greeting Title - Text( - 'Selamat Datang', - style: AppTextStyles.displaySmall.copyWith( - color: AppColors.primaryBrown, - fontWeight: FontWeight.w700, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - - // Subtitle - Text( - 'Masuk ke akun Anda', - style: AppTextStyles.bodyMedium.copyWith( - color: AppColors.textSecondary, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - - // Email/Username Label - Align( - alignment: Alignment.centerLeft, - child: Text( - 'Email atau Username', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - ), - const SizedBox(height: 8), - - // Email/Username Field - TextField( - controller: _usernameController, - enabled: !_isLoading, - decoration: InputDecoration( - hintText: 'Masukkan email atau username', - hintStyle: - AppTextStyles.bodyMedium.copyWith( - color: AppColors.grey300, - ), - prefixIcon: Icon( - Icons.email_outlined, - color: AppColors.textSecondary, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: - BorderSide(color: AppColors.grey300), - ), - filled: true, - fillColor: AppColors.bgLight, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - ), - const SizedBox(height: 24), - - // Password Label - Align( - alignment: Alignment.centerLeft, - child: Text( - 'Password', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - ), - const SizedBox(height: 8), - - // Password Field - TextField( - controller: _passwordController, - enabled: !_isLoading, - obscureText: !_isPasswordVisible, - decoration: InputDecoration( - hintText: 'Masukkan password', - hintStyle: - AppTextStyles.bodyMedium.copyWith( - color: AppColors.grey300, - ), - prefixIcon: Icon( - Icons.lock_outline, - color: AppColors.textSecondary, - ), - suffixIcon: IconButton( - icon: Icon( - _isPasswordVisible - ? Icons.visibility - : Icons.visibility_off, - color: AppColors.textSecondary, - ), - onPressed: _isLoading - ? null - : () { - setState(() { - _isPasswordVisible = - !_isPasswordVisible; - }); - }, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: - BorderSide(color: AppColors.grey300), - ), - filled: true, - fillColor: AppColors.bgLight, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - ), - ), - const SizedBox(height: 16), - - // Forgot Password Link - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: _isLoading ? null : () {}, - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - ), - child: Text( - 'Lupa Password?', - style: AppTextStyles.labelMedium.copyWith( - color: AppColors.primaryBrown, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - const SizedBox(height: 32), - - // Login Button - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: _isLoading ? null : _handleLogin, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryBrown, - foregroundColor: Colors.white, - padding: - const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: _isLoading - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: - AlwaysStoppedAnimation( - Colors.white, - ), - ), - ) - : Text( - 'Masuk', - style: - AppTextStyles.labelLarge.copyWith( - color: Colors.white, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - const SizedBox(height: 24), - - // Demo Info Box - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.bgLight, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.grey300, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Demo: Klik "Masuk" untuk melanjutkan', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.statusError, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ), - ), - - const SizedBox(height: 48), - - // Footer Copyright - Text( - '© 2025 Toko Bahan Kue Sulastri', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, - ), - ), - const SizedBox(height: 16), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/screens/prediction/prediction_controller.dart b/lib/screens/prediction/prediction_controller.dart new file mode 100644 index 0000000..2393c44 --- /dev/null +++ b/lib/screens/prediction/prediction_controller.dart @@ -0,0 +1,136 @@ +import 'package:finalproject/services/ml_service.dart'; +import 'package:flutter/material.dart'; + +class PredictionController extends ChangeNotifier { + String? selectedRecipe; + int productionQuantity = 0; + bool isCalculated = false; + bool isLoading = true; + + List> recipes = []; + Map> recipeIngredients = {}; + + final Map currentStock = { + 'Tepung Terigu 1kg': 45000, + 'Telur 1kg': 12, + 'Gula Pasir 1kg': 28000, + 'Susu Bubuk': 8000, + 'Cokelat Bubuk 250gr': 22000, + 'Mentega 500gr': 15000, + 'Keju Parut 250gr': 3000, + 'Baking Powder': 60000, + }; + + Future loadRecipes() async { + isLoading = true; + notifyListeners(); + + try { + final fetchedRecipes = await MLService.getRecipes(); + recipes = fetchedRecipes; + recipeIngredients = {}; + + for (final recipe in recipes) { + final recipeName = recipe['recipe_name'] as String?; + if (recipeName == null) continue; + + recipeIngredients[recipeName] = {}; + + if (recipe['ingredients'] != null) { + for (final ingredient in recipe['ingredients']) { + recipeIngredients[recipeName]![ingredient['product_name']] = { + 'quantity': ingredient['quantity_needed'], + 'unit': ingredient['unit'], + }; + } + } + } + + return null; + } catch (e) { + return 'Gagal memuat resep: $e'; + } finally { + isLoading = false; + notifyListeners(); + } + } + + void setSelectedRecipe(String? value) { + selectedRecipe = value; + isCalculated = false; + notifyListeners(); + } + + void setProductionQuantity(String value) { + productionQuantity = int.tryParse(value) ?? 0; + isCalculated = false; + notifyListeners(); + } + + bool get canCalculate => selectedRecipe != null && productionQuantity > 0; + + void calculate() { + isCalculated = true; + notifyListeners(); + } + + void reset() { + selectedRecipe = null; + productionQuantity = 0; + isCalculated = false; + notifyListeners(); + } + + Map get requiredIngredients { + if (selectedRecipe == null || productionQuantity == 0) { + return {}; + } + + final ingredients = recipeIngredients[selectedRecipe] ?? {}; + final required = {}; + + ingredients.forEach((productName, details) { + final quantity = (details['quantity'] as num).toInt(); + required[productName] = quantity * productionQuantity; + }); + + return required; + } + + Map get insufficientStock { + final required = requiredIngredients; + final insufficient = {}; + + required.forEach((ingredient, neededAmount) { + final available = currentStock[ingredient] ?? 0; + if (available < neededAmount) { + insufficient[ingredient] = neededAmount - available; + } + }); + + return insufficient; + } + + bool get isStockSufficient => insufficientStock.isEmpty; + + String getIngredientUnit(String ingredient) { + if (selectedRecipe == null) return 'gr'; + final recipeIngs = recipeIngredients[selectedRecipe]; + if (recipeIngs == null) return 'gr'; + final ingData = recipeIngs[ingredient]; + if (ingData == null) return 'gr'; + return ingData['unit'] as String? ?? 'gr'; + } + + Color getStatusColor(String ingredient) { + final required = requiredIngredients[ingredient] ?? 0; + final available = currentStock[ingredient] ?? 0; + return available >= required + ? const Color(0xFF10B981) + : const Color(0xFFDC2626); + } + + String cleanIngredientName(String ingredient) { + return ingredient.replaceAll(RegExp(r' \d+(kg|gr)'), '').trim(); + } +} diff --git a/lib/screens/prediction/prediction_page.dart b/lib/screens/prediction/prediction_page.dart new file mode 100644 index 0000000..0dbce16 --- /dev/null +++ b/lib/screens/prediction/prediction_page.dart @@ -0,0 +1,956 @@ +import 'package:flutter/material.dart'; + +import 'prediction_controller.dart'; + +class PredictionScreen extends StatefulWidget { + const PredictionScreen({super.key}); + + @override + State createState() => _PredictionScreenState(); +} + +class _PredictionScreenState extends State { + late final PredictionController _controller; + + @override + void initState() { + super.initState(); + _controller = PredictionController(); + _loadRecipes(); + } + + Future _loadRecipes() async { + final error = await _controller.loadRecipes(); + if (!mounted || error == null) return; + + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error))); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: PreferredSize( + preferredSize: const Size.fromHeight(200), + child: Container( + decoration: const BoxDecoration( + color: Color(0xFFA89080), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.arrow_back, + color: Colors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Prediksi Kebutuhan Bahan', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 2), + Text( + 'Kalkulasi bahan berdasarkan rencana produksi', + style: TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.15), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Colors.white.withOpacity(0.2), + width: 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(6), + ), + child: const Icon( + Icons.calculate, + color: Colors.white, + size: 18, + ), + ), + const SizedBox(width: 10), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sistem Kalkulasi Otomatis', + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 4), + Text( + 'Pilih produk dan jumlah untuk lihat kebutuhan bahan', + style: TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w400, + height: 1.3, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + body: + _controller.isLoading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Memuat resep...'), + ], + ), + ) + : _controller.recipes.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Color(0xFFDC2626), + ), + const SizedBox(height: 16), + const Text('Gagal memuat resep'), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _loadRecipes, + child: const Text('Coba Lagi'), + ), + ], + ), + ) + : SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Rencana Produksi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 16), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Text( + 'Pilih Produk', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + border: Border.all( + color: const Color(0xFFE5E7EB), + width: 1, + ), + borderRadius: BorderRadius.circular( + 10, + ), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _controller.selectedRecipe, + hint: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + ), + child: Text( + '-- Pilih Produk --', + style: TextStyle( + color: Color(0xFF9CA3AF), + fontSize: 14, + ), + ), + ), + isExpanded: true, + icon: const Padding( + padding: EdgeInsets.only( + right: 12, + ), + child: Icon( + Icons.expand_more, + color: Color(0xFF9CA3AF), + ), + ), + items: + _controller.recipes + .map( + ( + recipe, + ) => DropdownMenuItem< + String + >( + value: + recipe['recipe_name'] + as String, + child: Padding( + padding: + const EdgeInsets.symmetric( + horizontal: + 12, + ), + child: Text( + recipe['recipe_name'] + as String, + ), + ), + ), + ) + .toList(), + onChanged: + _controller.setSelectedRecipe, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + const Text( + 'Jumlah Produksi', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 8), + TextField( + keyboardType: TextInputType.number, + onChanged: + _controller.setProductionQuantity, + decoration: InputDecoration( + hintText: '0', + hintStyle: const TextStyle( + color: Color(0xFF9CA3AF), + ), + suffixText: 'pcs', + suffixStyle: const TextStyle( + color: Color(0xFF9CA3AF), + fontSize: 12, + ), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(10), + borderSide: const BorderSide( + color: Color(0xFFE5E7EB), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(10), + borderSide: const BorderSide( + color: Color(0xFFE5E7EB), + ), + ), + contentPadding: + const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: + _controller.canCalculate + ? _controller.calculate + : null, + icon: const Icon( + Icons.calculate, + size: 18, + ), + label: const Text( + 'Hitung Kebutuhan', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color( + 0xFFA89080, + ), + foregroundColor: Colors.white, + padding: + const EdgeInsets.symmetric( + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(10), + ), + ), + ), + ), + const SizedBox(width: 10), + OutlinedButton( + onPressed: _controller.reset, + style: OutlinedButton.styleFrom( + side: const BorderSide( + color: Color(0xFFE5E7EB), + width: 1, + ), + padding: const EdgeInsets.symmetric( + horizontal: 20, + ), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(10), + ), + ), + child: const Text( + 'Reset', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + if (_controller.isCalculated) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: + _controller.isStockSufficient + ? const Color( + 0xFF10B981, + ).withOpacity(0.1) + : const Color( + 0xFFDC2626, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + _controller.isStockSufficient + ? const Color( + 0xFF10B981, + ).withOpacity(0.3) + : const Color( + 0xFFDC2626, + ).withOpacity(0.3), + width: 1, + ), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: + _controller.isStockSufficient + ? const Color( + 0xFF10B981, + ).withOpacity(0.2) + : const Color( + 0xFFDC2626, + ).withOpacity(0.2), + borderRadius: BorderRadius.circular( + 12, + ), + ), + child: Icon( + _controller.isStockSufficient + ? Icons.check_circle + : Icons.warning_amber, + color: + _controller.isStockSufficient + ? const Color(0xFF10B981) + : const Color(0xFFDC2626), + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + _controller.isStockSufficient + ? 'Stok Cukup' + : 'Stok Belum Cukup', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: + _controller + .isStockSufficient + ? const Color( + 0xFF10B981, + ) + : const Color( + 0xFFDC2626, + ), + ), + ), + const SizedBox(height: 2), + Text( + _controller.isStockSufficient + ? 'Semua bahan tersedia untuk produksi' + : 'Ada bahan yang perlu ditambah', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + const Text( + 'Kebutuhan Bahan', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: + _controller.requiredIngredients.entries.toList().asMap().entries.map(( + entry, + ) { + final isLast = + entry.key == + _controller + .requiredIngredients + .length - + 1; + final ingredient = entry.value.key; + final neededAmount = + entry.value.value; + final availableAmount = + _controller + .currentStock[ingredient] ?? + 0; + final unit = _controller + .getIngredientUnit(ingredient); + final isSufficient = + availableAmount >= neededAmount; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all( + 14, + ), + child: Column( + children: [ + Row( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + Text( + _controller + .cleanIngredientName( + ingredient, + ), + style: const TextStyle( + fontSize: 13, + fontWeight: + FontWeight + .w600, + color: Color( + 0xFF1F2937, + ), + ), + ), + const SizedBox( + height: 6, + ), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + const Text( + 'Kebutuhan', + style: TextStyle( + fontSize: + 11, + color: Color( + 0xFF9CA3AF, + ), + ), + ), + Text( + '$neededAmount $unit', + style: const TextStyle( + fontSize: + 12, + fontWeight: + FontWeight.w700, + color: Color( + 0xFF1F2937, + ), + ), + ), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + const Text( + 'Stok Saat Ini', + style: TextStyle( + fontSize: + 11, + color: Color( + 0xFF9CA3AF, + ), + ), + ), + Text( + '$availableAmount $unit', + style: const TextStyle( + fontSize: + 12, + fontWeight: + FontWeight.w700, + color: Color( + 0xFF1F2937, + ), + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + const SizedBox( + width: 10, + ), + Container( + padding: + const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: _controller + .getStatusColor( + ingredient, + ) + .withOpacity( + 0.15, + ), + borderRadius: + BorderRadius.circular( + 8, + ), + border: Border.all( + color: _controller + .getStatusColor( + ingredient, + ) + .withOpacity( + 0.3, + ), + width: 1, + ), + ), + child: Text( + isSufficient + ? 'Aman' + : 'Kurang', + style: TextStyle( + fontSize: 11, + fontWeight: + FontWeight + .w700, + color: _controller + .getStatusColor( + ingredient, + ), + ), + ), + ), + ], + ), + ], + ), + ), + if (!isLast) + Container( + height: 1, + color: const Color( + 0xFFF3F4F6, + ), + ), + ], + ); + }).toList(), + ), + ), + const SizedBox(height: 16), + if (!_controller.isStockSufficient) ...[ + const Text( + 'Rekomendasi Penambahan Stok', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color( + 0xFFDC2626, + ).withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color( + 0xFFDC2626, + ).withOpacity(0.2), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: + _controller.insufficientStock.entries.map(( + entry, + ) { + final ingredient = entry.key; + final deficitAmount = entry.value; + final unit = _controller + .getIngredientUnit(ingredient); + + return Padding( + padding: + const EdgeInsets.symmetric( + vertical: 8, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, + children: [ + Expanded( + child: Text( + _controller + .cleanIngredientName( + ingredient, + ), + style: const TextStyle( + fontSize: 13, + fontWeight: + FontWeight.w600, + color: Color( + 0xFF1F2937, + ), + ), + ), + ), + Container( + padding: + const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color( + 0xFFDC2626, + ).withOpacity(0.1), + borderRadius: + BorderRadius.circular( + 8, + ), + ), + child: Text( + '+$deficitAmount $unit', + style: const TextStyle( + fontSize: 12, + fontWeight: + FontWeight.w700, + color: Color( + 0xFFDC2626, + ), + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ] else + Container( + width: double.infinity, + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: const Column( + children: [ + _EmptyCalcIcon(), + SizedBox(height: 16), + Text( + 'Belum Ada Kalkulasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + SizedBox(height: 8), + Text( + 'Pilih produk dan masukkan jumlah produksi untuk melihat kebutuhan bahan', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + color: Color(0xFF9CA3AF), + height: 1.5, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: 3, + type: BottomNavigationBarType.fixed, + onTap: (index) { + switch (index) { + case 0: + Navigator.popUntil(context, (route) => route.isFirst); + break; + case 1: + Navigator.pushNamed(context, '/products'); + break; + case 2: + Navigator.pushNamed(context, '/transaction'); + break; + case 4: + Navigator.pushNamed(context, '/reports'); + break; + case 5: + Navigator.pushNamed(context, '/settings'); + break; + } + }, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + label: 'Dashboard', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_bag_outlined), + label: 'Produk', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_cart_outlined), + label: 'Stock In', + ), + BottomNavigationBarItem( + icon: Icon(Icons.trending_up_outlined), + label: 'Prediksi', + ), + BottomNavigationBarItem( + icon: Icon(Icons.description_outlined), + label: 'Laporan', + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings_outlined), + label: 'Pengaturan', + ), + ], + ), + ); + }, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +class _EmptyCalcIcon extends StatelessWidget { + const _EmptyCalcIcon(); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 60, + height: 60, + child: DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF9CA3AF).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.calculate, color: Color(0xFF9CA3AF), size: 32), + ), + ); + } +} diff --git a/lib/screens/prediction_screen.dart b/lib/screens/prediction_screen.dart deleted file mode 100644 index 6b58156..0000000 --- a/lib/screens/prediction_screen.dart +++ /dev/null @@ -1,909 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:finalproject/theme/colors.dart'; -import 'package:finalproject/theme/text_styles.dart'; -import 'package:finalproject/services/ml_service.dart'; - -class PredictionScreen extends StatefulWidget { - const PredictionScreen({Key? key}) : super(key: key); - - @override - State createState() => _PredictionScreenState(); -} - -class _PredictionScreenState extends State { - String? _selectedRecipe; - int _productionQuantity = 0; - bool _isCalculated = false; - bool _isLoading = true; - - // Data dari API - List> recipes = []; - Map> recipeIngredients = {}; - - // Stok saat ini (sama dengan di product list) - final Map currentStock = { - 'Tepung Terigu 1kg': 45000, // gram - 'Telur 1kg': 12, // butir - 'Gula Pasir 1kg': 28000, // gram - 'Susu Bubuk': 8000, // gram - 'Cokelat Bubuk 250gr': 22000, // gram - 'Mentega 500gr': 15000, // gram - 'Keju Parut 250gr': 3000, // gram - 'Baking Powder': 60000, // gram - }; - - @override - void initState() { - super.initState(); - _loadRecipes(); - } - - Future _loadRecipes() async { - try { - final fetchedRecipes = await MLService.getRecipes(); - - setState(() { - recipes = fetchedRecipes; - - // Build recipeIngredients map - for (var recipe in recipes) { - recipeIngredients[recipe['recipe_name']] = {}; - - if (recipe['ingredients'] != null) { - for (var ingredient in recipe['ingredients']) { - recipeIngredients[recipe['recipe_name']]![ingredient['product_name']] = { - 'quantity': ingredient['quantity_needed'], - 'unit': ingredient['unit'], - }; - } - } - } - - _isLoading = false; - }); - } catch (e) { - print('Error loading recipes: $e'); - setState(() => _isLoading = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Gagal memuat resep: $e')), - ); - } - } - - Map get requiredIngredients { - if (_selectedRecipe == null || _productionQuantity == 0) { - return {}; - } - - final ingredients = recipeIngredients[_selectedRecipe] ?? {}; - final required = {}; - - ingredients.forEach((productName, details) { - final quantity = (details['quantity'] as num).toInt(); - required[productName] = quantity * _productionQuantity; - }); - - return required; - } - - Map get insufficientStock { - final required = requiredIngredients; - final insufficient = {}; - - required.forEach((ingredient, neededAmount) { - final available = currentStock[ingredient] ?? 0; - if (available < neededAmount) { - insufficient[ingredient] = neededAmount - available; - } - }); - - return insufficient; - } - - bool get isStockSufficient => insufficientStock.isEmpty; - - String _getIngredientUnit(String ingredient) { - if (_selectedRecipe == null) return 'gr'; - final recipeIngs = recipeIngredients[_selectedRecipe]; - if (recipeIngs == null) return 'gr'; - final ingData = recipeIngs[ingredient]; - if (ingData == null) return 'gr'; - return ingData['unit'] as String? ?? 'gr'; - } - - Color _getStatusColor(String ingredient) { - final required = requiredIngredients[ingredient] ?? 0; - final available = currentStock[ingredient] ?? 0; - return available >= required ? Color(0xFF10B981) : Color(0xFFDC2626); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Color(0xFFF5F5F5), - // Brown Header - appBar: PreferredSize( - preferredSize: const Size.fromHeight(200), - child: Container( - decoration: BoxDecoration( - color: Color(0xFFA89080), - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(24), - bottomRight: Radius.circular(24), - ), - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Back button + Title - Row( - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.3), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon(Icons.arrow_back, - color: Colors.white, size: 20), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text( - 'Prediksi Kebutuhan Bahan', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w700, - ), - ), - SizedBox(height: 2), - Text( - 'Kalkulasi bahan berdasarkan rencana produksi', - style: TextStyle( - color: Colors.white70, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - // Info Box - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: Colors.white.withOpacity(0.2), - width: 1, - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(6), - ), - child: const Icon(Icons.calculate, - color: Colors.white, size: 18), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text( - 'Sistem Kalkulasi Otomatis', - style: TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - SizedBox(height: 4), - Text( - 'Pilih produk dan jumlah untuk lihat kebutuhan bahan', - style: TextStyle( - color: Colors.white70, - fontSize: 11, - fontWeight: FontWeight.w400, - height: 1.3, - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - body: _isLoading - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text('Memuat resep...'), - ], - ), - ) - : recipes.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 48, color: Color(0xFFDC2626)), - const SizedBox(height: 16), - const Text('Gagal memuat resep'), - const SizedBox(height: 8), - ElevatedButton( - onPressed: _loadRecipes, - child: const Text('Coba Lagi'), - ), - ], - ), - ) - : SingleChildScrollView( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Input Form Section - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Rencana Produksi', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(height: 16), - - // Product Selection - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Pilih Produk', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - border: Border.all( - color: Color(0xFFE5E7EB), - width: 1, - ), - borderRadius: BorderRadius.circular(10), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedRecipe, - hint: const Padding( - padding: EdgeInsets.symmetric(horizontal: 12), - child: Text( - '-- Pilih Produk --', - style: TextStyle( - color: Color(0xFF9CA3AF), - fontSize: 14, - ), - ), - ), - isExpanded: true, - icon: const Padding( - padding: EdgeInsets.only(right: 12), - child: Icon(Icons.expand_more, - color: Color(0xFF9CA3AF)), - ), - items: recipes - .map((recipe) => - DropdownMenuItem( - value: recipe['recipe_name'] as String, - child: Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 12), - child: Text(recipe['recipe_name'] as String), - ), - )) - .toList(), - onChanged: (value) { - setState(() { - _selectedRecipe = value; - _isCalculated = false; - }); - }, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - // Quantity Input - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Jumlah Produksi', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(height: 8), - TextField( - keyboardType: TextInputType.number, - onChanged: (value) { - setState(() { - _productionQuantity = - int.tryParse(value) ?? 0; - _isCalculated = false; - }); - }, - decoration: InputDecoration( - hintText: '0', - hintStyle: - const TextStyle(color: Color(0xFF9CA3AF)), - suffixText: 'pcs', - suffixStyle: const TextStyle( - color: Color(0xFF9CA3AF), - fontSize: 12, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide( - color: Color(0xFFE5E7EB), - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide( - color: Color(0xFFE5E7EB), - ), - ), - contentPadding: - const EdgeInsets.symmetric( - horizontal: 12, vertical: 12), - ), - ), - ], - ), - const SizedBox(height: 20), - - // Calculate Button - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: (_selectedRecipe != null && - _productionQuantity > 0) - ? () { - setState(() => _isCalculated = true); - } - : null, - icon: const Icon(Icons.calculate, size: 18), - label: const Text( - 'Hitung Kebutuhan', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Color(0xFFA89080), - foregroundColor: Colors.white, - padding: - const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - ), - ), - const SizedBox(width: 10), - OutlinedButton( - onPressed: () { - setState(() { - _selectedRecipe = null; - _productionQuantity = 0; - _isCalculated = false; - }); - }, - style: OutlinedButton.styleFrom( - side: const BorderSide( - color: Color(0xFFE5E7EB), - width: 1, - ), - padding: - const EdgeInsets.symmetric(horizontal: 20), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: const Text( - 'Reset', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 24), - - // Results Section - if (_isCalculated) ...[ - // Status Card - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: isStockSufficient - ? Color(0xFF10B981).withOpacity(0.1) - : Color(0xFFDC2626).withOpacity(0.1), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isStockSufficient - ? Color(0xFF10B981).withOpacity(0.3) - : Color(0xFFDC2626).withOpacity(0.3), - width: 1, - ), - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: isStockSufficient - ? Color(0xFF10B981).withOpacity(0.2) - : Color(0xFFDC2626).withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - isStockSufficient - ? Icons.check_circle - : Icons.warning_amber, - color: isStockSufficient - ? Color(0xFF10B981) - : Color(0xFFDC2626), - size: 24, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isStockSufficient - ? 'Stok Cukup' - : 'Stok Belum Cukup', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: isStockSufficient - ? Color(0xFF10B981) - : Color(0xFFDC2626), - ), - ), - const SizedBox(height: 2), - Text( - isStockSufficient - ? 'Semua bahan tersedia untuk produksi' - : 'Ada bahan yang perlu ditambah', - style: const TextStyle( - fontSize: 12, - color: Color(0xFF6B7280), - ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Ingredients Table - Text( - 'Kebutuhan Bahan', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(height: 12), - Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - children: requiredIngredients.entries - .toList() - .asMap() - .entries - .map((entry) { - final isLast = - entry.key == requiredIngredients.length - 1; - final ingredient = entry.value.key; - final neededAmount = entry.value.value; - final availableAmount = - currentStock[ingredient] ?? 0; - final unit = _getIngredientUnit(ingredient); - final isSufficient = - availableAmount >= neededAmount; - - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(14), - child: Column( - children: [ - Row( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - ingredient - .replaceAll( - RegExp(r' \d+(kg|gr)'), - '') - .trim(), - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment - .start, - children: [ - Text( - 'Kebutuhan', - style: TextStyle( - fontSize: 11, - color: Color( - 0xFF9CA3AF), - ), - ), - Text( - '$neededAmount $unit', - style: - const TextStyle( - fontSize: 12, - fontWeight: - FontWeight.w700, - color: Color( - 0xFF1F2937), - ), - ), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment - .start, - children: [ - Text( - 'Stok Saat Ini', - style: TextStyle( - fontSize: 11, - color: Color( - 0xFF9CA3AF), - ), - ), - Text( - '$availableAmount $unit', - style: - const TextStyle( - fontSize: 12, - fontWeight: - FontWeight.w700, - color: Color( - 0xFF1F2937), - ), - ), - ], - ), - ), - ], - ), - ], - ), - ), - const SizedBox(width: 10), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: _getStatusColor(ingredient) - .withOpacity(0.15), - borderRadius: - BorderRadius.circular(8), - border: Border.all( - color: _getStatusColor(ingredient) - .withOpacity(0.3), - width: 1, - ), - ), - child: Text( - isSufficient ? 'Aman' : 'Kurang', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: _getStatusColor( - ingredient), - ), - ), - ), - ], - ), - ], - ), - ), - if (!isLast) - Container( - height: 1, - color: Color(0xFFF3F4F6), - ), - ], - ); - }).toList(), - ), - ), - const SizedBox(height: 16), - - // Recommendation Card - if (!isStockSufficient) ...[ - Text( - 'Rekomendasi Penambahan Stok', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Color(0xFFDC2626).withOpacity(0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Color(0xFFDC2626).withOpacity(0.2), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: insufficientStock.entries - .map((entry) { - final ingredient = entry.key; - final deficitAmount = entry.value; - final unit = _getIngredientUnit(ingredient); - - return Padding( - padding: - const EdgeInsets.symmetric(vertical: 8), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - ingredient - .replaceAll( - RegExp(r' \d+(kg|gr)'), '') - .trim(), - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Color(0xFFDC2626) - .withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '+$deficitAmount $unit', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: Color(0xFFDC2626), - ), - ), - ), - ], - ), - ); - }).toList(), - ), - ), - ], - ] else - Container( - width: double.infinity, - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Color(0xFF9CA3AF).withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon(Icons.calculate, - color: Color(0xFF9CA3AF), size: 32), - ), - const SizedBox(height: 16), - const Text( - 'Belum Ada Kalkulasi', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(height: 8), - const Text( - 'Pilih produk dan masukkan jumlah produksi untuk melihat kebutuhan bahan', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - color: Color(0xFF9CA3AF), - height: 1.5, - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - bottomNavigationBar: BottomNavigationBar( - currentIndex: 3, - type: BottomNavigationBarType.fixed, - onTap: (index) { - switch (index) { - case 0: - Navigator.popUntil(context, (route) => route.isFirst); - break; - case 1: - Navigator.pushNamed(context, '/products'); - break; - case 2: - Navigator.pushNamed(context, '/transaction'); - break; - case 4: - Navigator.pushNamed(context, '/reports'); - break; - } - }, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_outlined), - label: 'Dashboard', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_bag_outlined), - label: 'Produk', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_cart_outlined), - label: 'Transaksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.trending_up_outlined), - label: 'Prediksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.description_outlined), - label: 'Laporan', - ), - ], - ), - ); - } -} diff --git a/lib/screens/product_list_screen.dart b/lib/screens/product_list_screen.dart deleted file mode 100644 index 3b5853e..0000000 --- a/lib/screens/product_list_screen.dart +++ /dev/null @@ -1,637 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:finalproject/models/product_model.dart'; -import 'package:finalproject/theme/colors.dart'; -import 'package:finalproject/theme/text_styles.dart'; -import 'package:finalproject/services/ml_service.dart'; - -class ProductListScreen extends StatefulWidget { - const ProductListScreen({Key? key}) : super(key: key); - - @override - State createState() => _ProductListScreenState(); -} - -class _ProductListScreenState extends State { - String _selectedFilter = 'semua'; - String _searchQuery = ''; - bool _isLoading = true; - - late List products = []; - - @override - void initState() { - super.initState(); - _loadProducts(); - } - - Future _loadProducts() async { - try { - final fetchedProducts = await MLService.getProducts(); - - // Determine stock status based on quantity - String _getStatus(int stock) { - if (stock == 0) return 'kritis'; - if (stock <= 5) return 'rendah'; - return 'tersedia'; - } - - final productList = fetchedProducts.map((p) { - int stock = p['current_stock'] ?? 0; - return Product( - id: p['id'] ?? 0, - name: p['name'] ?? '', - category: p['category'] ?? '', - price: p['price'] ?? 0, - stock: stock, - status: _getStatus(stock), - ); - }).toList(); - - setState(() { - products = productList; - _isLoading = false; - }); - } catch (e) { - print('Error loading products: $e'); - setState(() => _isLoading = false); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Gagal memuat data produk'), - backgroundColor: AppColors.statusError, - ), - ); - } - } - } - - List get filteredProducts { - List result = products; - - // Filter by status - if (_selectedFilter != 'semua') { - result = result.where((p) => p.status == _selectedFilter).toList(); - } - - // Filter by search - if (_searchQuery.isNotEmpty) { - result = result - .where((p) => - p.name.toLowerCase().contains(_searchQuery.toLowerCase()) || - p.category.toLowerCase().contains(_searchQuery.toLowerCase())) - .toList(); - } - - return result; - } - - 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); - } - } - - IconData _getCategoryIcon(String category) { - switch (category) { - case 'Tepung': - return Icons.grain; - case 'Telur': - return Icons.circle; - case 'Gula': - return Icons.blur_circular; - case 'Susu': - return Icons.local_drink; - case 'Cokelat': - return Icons.square_rounded; - case 'Mentega': - return Icons.spa; - case 'Keju': - return Icons.lunch_dining; - case 'Bahan Tambahan': - return Icons.miscellaneous_services; - default: - return Icons.shopping_bag; - } - } - - String _formatPrice(int price) { - return 'Rp ${(price ~/ 1000).toString()}K'; - } - - int _getMaxStock() { - return products.fold(0, (max, p) => p.stock > max ? p.stock : max); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.bgLight, - // Brown Header with Search - appBar: PreferredSize( - preferredSize: const Size.fromHeight(160), - child: Container( - decoration: BoxDecoration( - color: AppColors.primaryBrown, - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(24), - bottomRight: Radius.circular(24), - ), - ), - child: SafeArea( - child: Column( - children: [ - // Top: Back button & Title - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.3), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.arrow_back, - color: Colors.white, - size: 20, - ), - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Data Produk', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - '${products.length} Produk', - style: const TextStyle( - color: Colors.white70, - fontSize: 12, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ], - ), - ), - // Bottom: Search Bar (dalam brown area) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: AppColors.bgWhite, - width: 1, - ), - borderRadius: BorderRadius.circular(12), - color: Colors.white, - ), - child: TextField( - onChanged: (value) { - setState(() => _searchQuery = value); - }, - decoration: InputDecoration( - hintText: 'Cari produk...', - hintStyle: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - prefixIcon: Icon( - Icons.search, - color: AppColors.textSecondary, - size: 20, - ), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - body: RefreshIndicator( - onRefresh: _loadProducts, - color: AppColors.primaryBrown, - child: _isLoading - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - Color(0xFF8B6E58), - ), - ), - SizedBox(height: 16), - Text('Memuat data produk...'), - ], - ), - ) - : SingleChildScrollView( - child: Column( - children: [ - // Filter Chips Section (moved to body) - Container( - color: AppColors.bgWhite, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - 'semua', - 'tersedia', - 'rendah', - 'kritis' - ] - .map((filter) => Padding( - padding: const EdgeInsets.only(right: 8), - child: FilterChip( - label: Text( - _capitalize(filter), - style: AppTextStyles.labelSmall.copyWith( - color: _selectedFilter == filter - ? Colors.white - : AppColors.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - backgroundColor: _selectedFilter == filter - ? AppColors.primaryBrown - : AppColors.bgLight, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide( - color: _selectedFilter == filter - ? AppColors.primaryBrown - : AppColors.grey200, - width: 1, - ), - ), - onSelected: (selected) { - setState(() => _selectedFilter = filter); - }, - ), - )) - .toList(), - ), - ), - ), - - // Products List - Padding( - padding: const EdgeInsets.all(16), - child: filteredProducts.isNotEmpty - ? Column( - children: filteredProducts - .map((product) => _buildProductCard(product)) - .toList() - .expand((card) => - [card, const SizedBox(height: 12)]) - .toList(), - ) - : Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 60), - child: Column( - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: AppColors.grey200, - borderRadius: BorderRadius.circular(20), - ), - child: Icon( - Icons.shopping_bag_outlined, - size: 40, - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 16), - Text( - 'Produk Tidak Ditemukan', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - Text( - 'Coba ubah filter atau cari dengan kata kunci lain', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - bottomNavigationBar: BottomNavigationBar( - currentIndex: 1, - type: BottomNavigationBarType.fixed, - onTap: (index) { - switch (index) { - case 0: - Navigator.popUntil(context, (route) => route.isFirst); - break; - case 2: - Navigator.pushNamed(context, '/transaction'); - break; - case 3: - Navigator.pushNamed(context, '/prediction'); - break; - case 4: - Navigator.pushNamed(context, '/reports'); - break; - } - }, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_outlined), - label: 'Dashboard', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_bag_outlined), - label: 'Produk', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_cart_outlined), - label: 'Transaksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.trending_up_outlined), - label: 'Prediksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.description_outlined), - label: 'Laporan', - ), - ], - ), - ); - } - - Widget _buildProductCard(Product product) { - final maxStock = _getMaxStock(); - final stockPercentage = (product.stock / maxStock * 100).toInt(); - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: Column( - children: [ - // Top Row: Icon & Info - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Product Icon (Category-based) - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: _getStatusColor(product.status).withOpacity(0.12), - borderRadius: BorderRadius.circular(14), - ), - child: Icon( - _getCategoryIcon(product.category), - color: _getStatusColor(product.status), - size: 28, - ), - ), - const SizedBox(width: 14), - - // Product Info (Middle) - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - product.name, - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 6), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppColors.primaryBrown.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - product.category, - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.primaryBrown, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 8), - Text( - _formatPrice(product.price), - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ], - ), - ), - - // Status Badge (Right) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: _getStatusColor(product.status).withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _getStatusColor(product.status).withOpacity(0.3), - width: 1, - ), - ), - child: Text( - _getStatusLabel(product.status), - style: AppTextStyles.labelSmall.copyWith( - color: _getStatusColor(product.status), - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - - const SizedBox(height: 12), - - // Divider - Container( - height: 1, - color: AppColors.grey200, - ), - - const SizedBox(height: 12), - - // Bottom Row: Stock Info - Row( - children: [ - // Stock Display - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Stok Tersedia', - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - '${product.stock} kg', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - - const SizedBox(width: 16), - - // Stock Progress Bar - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Kapasitas', - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - Text( - '$stockPercentage%', - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - const SizedBox(height: 6), - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: product.stock / maxStock, - minHeight: 6, - backgroundColor: AppColors.grey200, - valueColor: AlwaysStoppedAnimation( - _getStatusColor(product.status), - ), - ), - ), - ], - ), - ), - - const SizedBox(width: 12), - - // Action Button - GestureDetector( - onTap: () { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${product.name} - Detail'), - duration: const Duration(seconds: 1), - ), - ); - }, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.primaryBrown.withOpacity(0.1), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Icons.arrow_forward_ios, - color: AppColors.primaryBrown, - size: 16, - ), - ), - ), - ], - ), - ], - ), - ); - } - - String _getStatusLabel(String status) { - switch (status) { - case 'tersedia': - return '✅ Tersedia'; - case 'rendah': - return '⚠️ Rendah'; - case 'kritis': - return '🔴 Kritis'; - default: - return 'Unknown'; - } - } - - String _capitalize(String text) { - return "${text[0].toUpperCase()}${text.substring(1)}"; - } -} - diff --git a/lib/screens/products/product_list_controller.dart b/lib/screens/products/product_list_controller.dart new file mode 100644 index 0000000..af8ad86 --- /dev/null +++ b/lib/screens/products/product_list_controller.dart @@ -0,0 +1,139 @@ +import 'package:finalproject/models/product_model.dart'; +import 'package:finalproject/services/ml_service.dart'; +import 'package:flutter/material.dart'; + +class ProductListController extends ChangeNotifier { + String selectedFilter = 'semua'; + String searchQuery = ''; + bool isLoading = true; + + List products = []; + + Future loadProducts() async { + isLoading = true; + notifyListeners(); + + 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; + final category = p['category'] ?? ''; + final unit = + p['unit'] ?? + (category.toString().toLowerCase() == 'barang' ? 'pcs' : 'kg'); + return Product( + id: p['id'] ?? 0, + name: p['name'] ?? '', + category: category, + price: p['price'] ?? 0, + stock: stock, + unit: unit, + status: getStatus(stock), + ); + }).toList(); + } finally { + isLoading = false; + notifyListeners(); + } + } + + List get filteredProducts { + var result = products; + + if (selectedFilter != 'semua') { + result = result.where((p) => p.status == selectedFilter).toList(); + } + + if (searchQuery.isNotEmpty) { + result = + result + .where( + (p) => + p.name.toLowerCase().contains(searchQuery.toLowerCase()) || + p.category.toLowerCase().contains( + searchQuery.toLowerCase(), + ), + ) + .toList(); + } + + return result; + } + + void setSearchQuery(String value) { + searchQuery = value; + notifyListeners(); + } + + void setFilter(String value) { + selectedFilter = value; + notifyListeners(); + } + + int get maxStock { + if (products.isEmpty) return 1; + return products.fold(0, (max, p) => p.stock > max ? p.stock : max); + } + + 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); + } + } + + IconData getCategoryIcon(String category) { + switch (category) { + case 'Tepung': + return Icons.grain; + case 'Telur': + return Icons.circle; + case 'Gula': + return Icons.blur_circular; + case 'Susu': + return Icons.local_drink; + case 'Cokelat': + return Icons.square_rounded; + case 'Mentega': + return Icons.spa; + case 'Keju': + return Icons.lunch_dining; + case 'Bahan Tambahan': + return Icons.miscellaneous_services; + default: + return Icons.shopping_bag; + } + } + + 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'; + } + } + + String capitalize(String text) => + '${text[0].toUpperCase()}${text.substring(1)}'; +} diff --git a/lib/screens/products/product_list_page.dart b/lib/screens/products/product_list_page.dart new file mode 100644 index 0000000..23a432b --- /dev/null +++ b/lib/screens/products/product_list_page.dart @@ -0,0 +1,552 @@ +import 'package:finalproject/models/product_model.dart'; +import 'package:finalproject/theme/colors.dart'; +import 'package:finalproject/theme/text_styles.dart'; +import 'package:flutter/material.dart'; + +import 'product_list_controller.dart'; + +class ProductListScreen extends StatefulWidget { + const ProductListScreen({super.key}); + + @override + State createState() => _ProductListScreenState(); +} + +class _ProductListScreenState extends State { + late final ProductListController _controller; + + @override + void initState() { + super.initState(); + _controller = ProductListController(); + _controller.loadProducts().catchError((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Gagal memuat data produk'), + backgroundColor: AppColors.statusError, + ), + ); + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: AppColors.bgLight, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(160), + child: Container( + decoration: BoxDecoration( + color: AppColors.primaryBrown, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + ), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.3), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.arrow_back, + color: Colors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Data Produk', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + '${_controller.products.length} Produk', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: AppColors.bgWhite, + width: 1, + ), + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), + child: TextField( + onChanged: _controller.setSearchQuery, + decoration: InputDecoration( + hintText: 'Cari produk...', + hintStyle: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + prefixIcon: Icon( + Icons.search, + color: AppColors.textSecondary, + size: 20, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + body: RefreshIndicator( + onRefresh: _controller.loadProducts, + color: AppColors.primaryBrown, + child: + _controller.isLoading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Color(0xFF8B6E58), + ), + ), + SizedBox(height: 16), + Text('Memuat data produk...'), + ], + ), + ) + : SingleChildScrollView( + child: Column( + children: [ + Container( + color: AppColors.bgWhite, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: + ['semua', 'tersedia', 'rendah', 'kritis'] + .map( + (filter) => Padding( + padding: const EdgeInsets.only( + right: 8, + ), + child: FilterChip( + label: Text( + _controller.capitalize(filter), + style: AppTextStyles.labelSmall + .copyWith( + color: + _controller.selectedFilter == + filter + ? Colors.white + : AppColors + .textSecondary, + fontWeight: + FontWeight.w600, + ), + ), + backgroundColor: + _controller.selectedFilter == + filter + ? AppColors.primaryBrown + : AppColors.bgLight, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(8), + side: BorderSide( + color: + _controller.selectedFilter == + filter + ? AppColors + .primaryBrown + : AppColors.grey200, + width: 1, + ), + ), + onSelected: + (_) => _controller.setFilter( + filter, + ), + ), + ), + ) + .toList(), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: + _controller.filteredProducts.isNotEmpty + ? Column( + children: + _controller.filteredProducts + .map( + (product) => + _buildProductCard(product), + ) + .toList() + .expand( + (card) => [ + card, + const SizedBox(height: 12), + ], + ) + .toList(), + ) + : Center( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 60, + ), + child: Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColors.grey200, + borderRadius: + BorderRadius.circular(20), + ), + child: Icon( + Icons.shopping_bag_outlined, + size: 40, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 16), + Text( + 'Produk Tidak Ditemukan', + style: AppTextStyles.headlineSmall + .copyWith( + color: + AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'Coba ubah filter atau cari dengan kata kunci lain', + style: AppTextStyles.bodySmall + .copyWith( + color: + AppColors.textSecondary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ], + ), + ), + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: 1, + type: BottomNavigationBarType.fixed, + onTap: (index) { + switch (index) { + case 0: + Navigator.popUntil(context, (route) => route.isFirst); + break; + case 2: + Navigator.pushNamed(context, '/transaction'); + break; + case 3: + Navigator.pushNamed(context, '/prediction'); + break; + case 4: + Navigator.pushNamed(context, '/reports'); + break; + case 5: + Navigator.pushNamed(context, '/settings'); + break; + } + }, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + label: 'Dashboard', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_bag_outlined), + label: 'Produk', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_cart_outlined), + label: 'Stock In', + ), + BottomNavigationBarItem( + icon: Icon(Icons.trending_up_outlined), + label: 'Prediksi', + ), + BottomNavigationBarItem( + icon: Icon(Icons.description_outlined), + label: 'Laporan', + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings_outlined), + label: 'Pengaturan', + ), + ], + ), + ); + }, + ); + } + + Widget _buildProductCard(Product product) { + final maxStock = _controller.maxStock; + final stockPercentage = (product.stock / maxStock * 100).toInt(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppColors.shadowLight], + ), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: _controller + .getStatusColor(product.status) + .withOpacity(0.12), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + _controller.getCategoryIcon(product.category), + color: _controller.getStatusColor(product.status), + size: 28, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: AppColors.primaryBrown.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + product.category, + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.primaryBrown, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Text( + _controller.formatPrice(product.price), + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: _controller + .getStatusColor(product.status) + .withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _controller + .getStatusColor(product.status) + .withOpacity(0.3), + width: 1, + ), + ), + child: Text( + _controller.getStatusLabel(product.status), + style: AppTextStyles.labelSmall.copyWith( + color: _controller.getStatusColor(product.status), + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Container(height: 1, color: AppColors.grey200), + const SizedBox(height: 12), + Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Stok Tersedia', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '${product.stock} ${product.unit}', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Kapasitas', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + Text( + '$stockPercentage%', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: product.stock / maxStock, + minHeight: 6, + backgroundColor: AppColors.grey200, + valueColor: AlwaysStoppedAnimation( + _controller.getStatusColor(product.status), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${product.name} - Detail'), + duration: const Duration(seconds: 1), + ), + ); + }, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primaryBrown.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.arrow_forward_ios, + color: AppColors.primaryBrown, + size: 16, + ), + ), + ), + ], + ), + ], + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/report_screen.dart b/lib/screens/report_screen.dart deleted file mode 100644 index d485712..0000000 --- a/lib/screens/report_screen.dart +++ /dev/null @@ -1,468 +0,0 @@ -import 'package:flutter/material.dart'; - -class ReportScreen extends StatefulWidget { - const ReportScreen({Key? key}) : super(key: key); - - @override - State createState() => _ReportScreenState(); -} - -class _ReportScreenState extends State { - String _selectedPeriod = 'bulanan'; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Color(0xFFF5F5F5), - appBar: AppBar( - backgroundColor: Color(0xFFA89080), - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - title: const Text( - 'Laporan & Analitik', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w700, - ), - ), - centerTitle: false, - ), - body: SingleChildScrollView( - child: Column( - children: [ - // Period Filter - Container( - color: Colors.white, - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Text( - 'Period:', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(width: 8), - Container( - decoration: BoxDecoration( - border: Border.all( - color: Color(0xFFE5E7EB), - width: 1, - ), - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedPeriod, - items: [ - 'harian', - 'mingguan', - 'bulanan', - 'tahunan' - ] - .map((period) => DropdownMenuItem( - value: period, - child: Text(period.capitalize()), - )) - .toList(), - onChanged: (value) { - if (value != null) { - setState(() => _selectedPeriod = value); - } - }, - ), - ), - ), - ], - ), - ), - - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Chart Section - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Grafik Penjualan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(height: 2), - Text( - '6 ${_selectedPeriod}', - style: const TextStyle( - fontSize: 12, - color: Color(0xFF9CA3AF), - ), - ), - ], - ), - Icon( - Icons.trending_up, - color: Color(0xFF10B981), - size: 20, - ), - ], - ), - const SizedBox(height: 20), - // Chart Placeholder - Container( - height: 200, - decoration: BoxDecoration( - color: Color(0xFFF9FAFB), - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - '[Area Chart: Sales Data by ${_selectedPeriod.capitalize()}]', - style: TextStyle( - color: Color(0xFFD1D5DB), - fontSize: 12, - ), - ), - ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun'] - .map((month) => Text( - month, - style: const TextStyle( - fontSize: 11, - color: Color(0xFF9CA3AF), - ), - )) - .toList(), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Statistics Section - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Statistik Penjualan', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(height: 16), - GridView.count( - crossAxisCount: 2, - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - mainAxisSpacing: 12, - crossAxisSpacing: 12, - children: [ - _buildStatItem( - icon: Icons.shopping_bag, - label: 'Total Transaksi', - value: '150', - color: Color(0xFF2563EB), - ), - _buildStatItem( - icon: Icons.attach_money, - label: 'Total Penjualan', - value: 'Rp 2.5M', - color: Color(0xFF10B981), - ), - _buildStatItem( - icon: Icons.trending_up, - label: 'Rata-rata', - value: 'Rp 16K', - color: Color(0xFFFB923C), - ), - _buildStatItem( - icon: Icons.inventory_2, - label: 'Produk Terjual', - value: '8', - color: Color(0xFFA89080), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 16), - - // Top Products Section - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Top 5 Produk Terlaris', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - const SizedBox(height: 16), - ...[ - ('Tepung Terigu', 25), - ('Gula Pasir', 18), - ('Telur', 15), - ('Mentega', 12), - ('Cokelat Bubuk', 10), - ] - .asMap() - .entries - .map((entry) => - _buildTopProductItem( - rank: entry.key + 1, - name: entry.value.$1, - quantity: entry.value.$2, - )) - .toList() - .expand((item) => [item, SizedBox(height: 12)]) - .toList(), - ], - ), - ), - const SizedBox(height: 24), - ], - ), - ), - ], - ), - ), - bottomNavigationBar: BottomNavigationBar( - currentIndex: 4, - type: BottomNavigationBarType.fixed, - onTap: (index) { - switch (index) { - case 0: - Navigator.popUntil(context, (route) => route.isFirst); - break; - case 1: - Navigator.pushNamed(context, '/products'); - break; - case 2: - Navigator.pushNamed(context, '/transaction'); - break; - case 3: - Navigator.pushNamed(context, '/prediction'); - break; - } - }, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_outlined), - label: 'Dashboard', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_bag_outlined), - label: 'Produk', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_cart_outlined), - label: 'Transaksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.trending_up_outlined), - label: 'Prediksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.description_outlined), - label: 'Laporan', - ), - ], - ), - ); - } - - Widget _buildStatItem({ - required IconData icon, - required String label, - required String value, - required Color color, - }) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: color.withOpacity(0.2), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: color, size: 18), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - value, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: color, - ), - ), - SizedBox(height: 2), - Text( - label, - style: const TextStyle( - fontSize: 11, - color: Color(0xFF9CA3AF), - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildTopProductItem({ - required int rank, - required String name, - required int quantity, - }) { - return Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: Color(0xFFA89080), - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - rank.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Color(0xFF1F2937), - ), - ), - Text( - '$quantity unit', - style: const TextStyle( - fontSize: 11, - color: Color(0xFF9CA3AF), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Color(0xFFA89080).withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '${((quantity / 25) * 100).toStringAsFixed(0)}%', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Color(0xFFA89080), - ), - ), - ), - ], - ); - } -} - -extension StringExtension on String { - String capitalize() { - return "${this[0].toUpperCase()}${substring(1)}"; - } -} diff --git a/lib/screens/reports/report_controller.dart b/lib/screens/reports/report_controller.dart new file mode 100644 index 0000000..13bf947 --- /dev/null +++ b/lib/screens/reports/report_controller.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +class ReportController extends ChangeNotifier { + String selectedPeriod = 'bulanan'; + + final List periods = ['harian', 'mingguan', 'bulanan', 'tahunan']; + final List<(String, int)> topProducts = const [ + ('Tepung Terigu', 25), + ('Gula Pasir', 18), + ('Telur', 15), + ('Mentega', 12), + ('Cokelat Bubuk', 10), + ]; + + void setSelectedPeriod(String value) { + selectedPeriod = value; + notifyListeners(); + } + + String capitalize(String value) => + '${value[0].toUpperCase()}${value.substring(1)}'; +} diff --git a/lib/screens/reports/report_page.dart b/lib/screens/reports/report_page.dart new file mode 100644 index 0000000..daf85a9 --- /dev/null +++ b/lib/screens/reports/report_page.dart @@ -0,0 +1,473 @@ +import 'package:flutter/material.dart'; + +import 'report_controller.dart'; + +class ReportScreen extends StatefulWidget { + const ReportScreen({super.key}); + + @override + State createState() => _ReportScreenState(); +} + +class _ReportScreenState extends State { + late final ReportController _controller; + + @override + void initState() { + super.initState(); + _controller = ReportController(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: const Color(0xFFA89080), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + 'Laporan & Analitik', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + centerTitle: false, + ), + body: SingleChildScrollView( + child: Column( + children: [ + Container( + color: Colors.white, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Text( + 'Period:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(width: 8), + Container( + decoration: BoxDecoration( + border: Border.all( + color: const Color(0xFFE5E7EB), + width: 1, + ), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _controller.selectedPeriod, + items: + _controller.periods + .map( + (period) => DropdownMenuItem( + value: period, + child: Text( + _controller.capitalize(period), + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + _controller.setSelectedPeriod(value); + } + }, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Grafik Penjualan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 2), + Text( + '6 ${_controller.selectedPeriod}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF9CA3AF), + ), + ), + ], + ), + const Icon( + Icons.trending_up, + color: Color(0xFF10B981), + size: 20, + ), + ], + ), + const SizedBox(height: 20), + Container( + height: 200, + decoration: BoxDecoration( + color: const Color(0xFFF9FAFB), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + '[Area Chart: Sales Data by ${_controller.capitalize(_controller.selectedPeriod)}]', + style: const TextStyle( + color: Color(0xFFD1D5DB), + fontSize: 12, + ), + ), + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: + ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun'] + .map( + (month) => Text( + month, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF9CA3AF), + ), + ), + ) + .toList(), + ), + ], + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Statistik Penjualan', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 16), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + children: [ + _buildStatItem( + icon: Icons.shopping_bag, + label: 'Total Stock In', + value: '150', + color: const Color(0xFF2563EB), + ), + _buildStatItem( + icon: Icons.attach_money, + label: 'Total Penjualan', + value: 'Rp 2.5M', + color: const Color(0xFF10B981), + ), + _buildStatItem( + icon: Icons.trending_up, + label: 'Rata-rata', + value: 'Rp 16K', + color: const Color(0xFFFB923C), + ), + _buildStatItem( + icon: Icons.inventory_2, + label: 'Produk Terjual', + value: '8', + color: const Color(0xFFA89080), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Top 5 Produk Terlaris', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 16), + ..._controller.topProducts + .asMap() + .entries + .map( + (entry) => _buildTopProductItem( + rank: entry.key + 1, + name: entry.value.$1, + quantity: entry.value.$2, + ), + ) + .toList() + .expand( + (item) => [item, const SizedBox(height: 12)], + ) + .toList(), + ], + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: 4, + type: BottomNavigationBarType.fixed, + onTap: (index) { + switch (index) { + case 0: + Navigator.popUntil(context, (route) => route.isFirst); + break; + case 1: + Navigator.pushNamed(context, '/products'); + break; + case 2: + Navigator.pushNamed(context, '/transaction'); + break; + case 3: + Navigator.pushNamed(context, '/prediction'); + break; + case 5: + Navigator.pushNamed(context, '/settings'); + break; + } + }, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + label: 'Dashboard', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_bag_outlined), + label: 'Produk', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_cart_outlined), + label: 'Stock In', + ), + BottomNavigationBarItem( + icon: Icon(Icons.trending_up_outlined), + label: 'Prediksi', + ), + BottomNavigationBarItem( + icon: Icon(Icons.description_outlined), + label: 'Laporan', + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings_outlined), + label: 'Pengaturan', + ), + ], + ), + ); + }, + ); + } + + Widget _buildStatItem({ + required IconData icon, + required String label, + required String value, + required Color color, + }) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.2), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 18), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF9CA3AF), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildTopProductItem({ + required int rank, + required String name, + required int quantity, + }) { + return Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFFA89080), + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + rank.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF1F2937), + ), + ), + Text( + '$quantity unit', + style: const TextStyle(fontSize: 11, color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFA89080).withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${((quantity / 25) * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFFA89080), + ), + ), + ), + ], + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/settings/settings_controller.dart b/lib/screens/settings/settings_controller.dart new file mode 100644 index 0000000..10553ea --- /dev/null +++ b/lib/screens/settings/settings_controller.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class SettingsController extends ChangeNotifier { + int selectedIndex = 5; + bool notificationEnabled = true; + + void setSelectedIndex(int index) { + selectedIndex = index; + notifyListeners(); + } + + void setNotificationEnabled(bool value) { + notificationEnabled = value; + notifyListeners(); + } +} diff --git a/lib/screens/settings/settings_page.dart b/lib/screens/settings/settings_page.dart new file mode 100644 index 0000000..cc0ddc1 --- /dev/null +++ b/lib/screens/settings/settings_page.dart @@ -0,0 +1,420 @@ +import 'package:finalproject/theme/colors.dart'; +import 'package:finalproject/theme/text_styles.dart'; +import 'package:flutter/material.dart'; + +import 'settings_controller.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + late final SettingsController _controller; + + @override + void initState() { + super.initState(); + _controller = SettingsController(); + } + + void _showLogoutDialog() { + showDialog( + context: context, + builder: + (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: Text( + 'Logout', + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.textPrimary, + ), + ), + content: Text( + 'Apakah Anda yakin ingin keluar dari aplikasi?', + style: AppTextStyles.bodyMedium.copyWith( + color: AppColors.textSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + 'Batal', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textSecondary, + ), + ), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + Navigator.of( + context, + ).pushNamedAndRemoveUntil('/login', (route) => false); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.statusError, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Text( + 'Logout', + style: AppTextStyles.labelLarge.copyWith(color: Colors.white), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: AppColors.bgLight, + appBar: AppBar( + backgroundColor: AppColors.primaryBrown, + elevation: 0, + title: Text( + 'Pengaturan', + style: AppTextStyles.headlineLarge.copyWith(color: Colors.white), + ), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Akun', + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppColors.shadowLight], + ), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: AppColors.primaryBrown, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.person, + color: Colors.white, + size: 32, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Admin Sulastri', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + 'admin@sulastri.com', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 32), + Text( + 'Umum', + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(12), + boxShadow: [AppColors.shadowLight], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.language, + color: AppColors.primaryBrown, + size: 24, + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Bahasa', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + Text( + 'Bahasa Indonesia', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + Icon( + Icons.chevron_right, + color: AppColors.textSecondary, + ), + ], + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(12), + boxShadow: [AppColors.shadowLight], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.notifications_outlined, + color: AppColors.primaryBrown, + size: 24, + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Notifikasi', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + Text( + 'Aktifkan notifikasi penting', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + Switch( + value: _controller.notificationEnabled, + onChanged: _controller.setNotificationEnabled, + activeColor: AppColors.primaryBrown, + ), + ], + ), + ), + const SizedBox(height: 32), + Text( + 'Keamanan', + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(12), + boxShadow: [AppColors.shadowLight], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.lock_outlined, + color: AppColors.primaryBrown, + size: 24, + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ubah Password', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + Text( + 'Perbarui password akun Anda', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + Icon( + Icons.chevron_right, + color: AppColors.textSecondary, + ), + ], + ), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _showLogoutDialog, + icon: const Icon(Icons.logout), + label: const Text('Logout'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.statusError, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgLight, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.grey300), + ), + child: Column( + children: [ + Text( + 'Prediksi Stok Bahan Kue', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Version 1.0.0', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + Text( + '© 2025 Toko Bahan Kue Sulastri', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + ), + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _controller.selectedIndex, + onTap: (index) { + _controller.setSelectedIndex(index); + switch (index) { + case 0: + Navigator.of(context).pushNamed('/dashboard'); + break; + case 1: + Navigator.of(context).pushNamed('/products'); + break; + case 2: + Navigator.of(context).pushNamed('/transaction'); + break; + case 3: + Navigator.of(context).pushNamed('/prediction'); + break; + case 4: + Navigator.of(context).pushNamed('/reports'); + break; + case 5: + break; + } + }, + type: BottomNavigationBarType.fixed, + backgroundColor: Colors.white, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + label: 'Dashboard', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_bag_outlined), + label: 'Produk', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_cart_outlined), + label: 'Stock In', + ), + BottomNavigationBarItem( + icon: Icon(Icons.trending_up_outlined), + label: 'Prediksi', + ), + BottomNavigationBarItem( + icon: Icon(Icons.description_outlined), + label: 'Laporan', + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings_outlined), + label: 'Pengaturan', + ), + ], + ), + ); + }, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart deleted file mode 100644 index 8c7a1e8..0000000 --- a/lib/screens/settings_screen.dart +++ /dev/null @@ -1,412 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:finalproject/theme/colors.dart'; -import 'package:finalproject/theme/text_styles.dart'; - -class SettingsScreen extends StatefulWidget { - const SettingsScreen({Key? key}) : super(key: key); - - @override - State createState() => _SettingsScreenState(); -} - -class _SettingsScreenState extends State { - int _selectedIndex = 5; // Settings tab - - void _showLogoutDialog() { - showDialog( - context: context, - builder: - (context) => AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: Text( - 'Logout', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - content: Text( - 'Apakah Anda yakin ingin keluar dari aplikasi?', - style: AppTextStyles.bodyMedium.copyWith( - color: AppColors.textSecondary, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text( - 'Batal', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textSecondary, - ), - ), - ), - ElevatedButton( - onPressed: () { - Navigator.pop(context); - Navigator.of( - context, - ).pushNamedAndRemoveUntil('/login', (route) => false); - }, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.statusError, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - child: Text( - 'Logout', - style: AppTextStyles.labelLarge.copyWith(color: Colors.white), - ), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.bgLight, - appBar: AppBar( - backgroundColor: AppColors.primaryBrown, - elevation: 0, - title: Text( - 'Pengaturan', - style: AppTextStyles.headlineLarge.copyWith(color: Colors.white), - ), - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Account Section - Text( - 'Akun', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 16), - - // User Profile Card - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: Row( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: AppColors.primaryBrown, - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.person, - color: Colors.white, - size: 32, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Admin Sulastri', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - 'admin@sulastri.com', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 32), - - // General Settings Section - Text( - 'Umum', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 16), - - // Settings Item: Language - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(12), - boxShadow: [AppColors.shadowLight], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - Icons.language, - color: AppColors.primaryBrown, - size: 24, - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Bahasa', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - Text( - 'Bahasa Indonesia', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ], - ), - Icon(Icons.chevron_right, color: AppColors.textSecondary), - ], - ), - ), - const SizedBox(height: 12), - - // Settings Item: Notifikasi - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(12), - boxShadow: [AppColors.shadowLight], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - Icons.notifications_outlined, - color: AppColors.primaryBrown, - size: 24, - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Notifikasi', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - Text( - 'Aktifkan notifikasi penting', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ], - ), - Switch( - value: true, - onChanged: (value) {}, - activeColor: AppColors.primaryBrown, - ), - ], - ), - ), - const SizedBox(height: 32), - - // Security Section - Text( - 'Keamanan', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 16), - - // Settings Item: Change Password - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(12), - boxShadow: [AppColors.shadowLight], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - Icons.lock_outlined, - color: AppColors.primaryBrown, - size: 24, - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Ubah Password', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - Text( - 'Perbarui password akun Anda', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ], - ), - Icon(Icons.chevron_right, color: AppColors.textSecondary), - ], - ), - ), - const SizedBox(height: 32), - - // Logout Button - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _showLogoutDialog, - icon: const Icon(Icons.logout), - label: const Text('Logout'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.statusError, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - ), - ), - const SizedBox(height: 32), - - // App Info - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgLight, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.grey300), - ), - child: Column( - children: [ - Text( - 'Prediksi Stok Bahan Kue', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - 'Version 1.0.0', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 12), - Text( - '© 2025 Toko Bahan Kue Sulastri', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ], - ), - ), - ), - bottomNavigationBar: BottomNavigationBar( - currentIndex: _selectedIndex, - onTap: (index) { - setState(() => _selectedIndex = index); - switch (index) { - case 0: - Navigator.of(context).pushNamed('/dashboard'); - break; - case 1: - Navigator.of(context).pushNamed('/products'); - break; - case 2: - Navigator.of(context).pushNamed('/transaction'); - break; - case 3: - Navigator.of(context).pushNamed('/prediction'); - break; - case 4: - Navigator.of(context).pushNamed('/reports'); - break; - case 5: - break; - } - }, - type: BottomNavigationBarType.fixed, - backgroundColor: Colors.white, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_outlined), - label: 'Dashboard', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_bag_outlined), - label: 'Produk', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_cart_outlined), - label: 'Transaksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.trending_up_outlined), - label: 'Prediksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.description_outlined), - label: 'Laporan', - ), - BottomNavigationBarItem( - icon: Icon(Icons.settings_outlined), - label: 'Pengaturan', - ), - ], - ), - ); - } -} diff --git a/lib/screens/transaction/transaction_controller.dart b/lib/screens/transaction/transaction_controller.dart new file mode 100644 index 0000000..e40bea3 --- /dev/null +++ b/lib/screens/transaction/transaction_controller.dart @@ -0,0 +1,258 @@ +import 'package:finalproject/models/transaction_model.dart'; +import 'package:finalproject/services/ml_service.dart'; +import 'package:flutter/material.dart'; + +class CartItem { + final int productId; + final String productName; + final String category; + final int unitPrice; + int quantity; + + CartItem({ + required this.productId, + required this.productName, + required this.category, + required this.unitPrice, + required this.quantity, + }); + + int get totalPrice => unitPrice * quantity; +} + +class TransactionController extends ChangeNotifier { + final quantityController = TextEditingController(); + + DateTime? selectedDate = DateTime.now(); + bool isLoading = false; + int selectedIndex = 2; + + final List products = []; + final Map productIds = {}; + final Map productCategories = {}; + final Map productUnits = {}; + final Map productPrices = {}; + final List categories = []; + + String? selectedProduct; + final List cartItems = []; + final List transactions = []; + + Future loadProducts() async { + final fetchedProducts = await MLService.getProducts(); + + products.clear(); + productIds.clear(); + productCategories.clear(); + productUnits.clear(); + productPrices.clear(); + categories.clear(); + + for (final product in fetchedProducts) { + final id = product['id'] ?? 0; + final name = product['name'] ?? ''; + final category = product['category'] ?? ''; + final unit = product['unit'] ?? _defaultUnitFromCategory(category); + final price = product['price'] ?? 0; + + if (name.isNotEmpty && id > 0) { + products.add(name); + productIds[name] = id; + productCategories[name] = category; + productUnits[name] = unit; + productPrices[name] = price; + + if (!categories.contains(category)) { + categories.add(category); + } + } + } + + notifyListeners(); + } + + void setSelectedProduct(String? value) { + selectedProduct = value; + notifyListeners(); + } + + void setSelectedDate(DateTime value) { + selectedDate = value; + notifyListeners(); + } + + void setSelectedIndex(int index) { + selectedIndex = index; + notifyListeners(); + } + + String? addToCart() { + if (selectedProduct == null || quantityController.text.isEmpty) { + return 'Pilih produk dan masukkan jumlah'; + } + + final quantity = int.tryParse(quantityController.text) ?? 0; + if (quantity <= 0) { + return 'Jumlah harus lebih dari 0'; + } + + final existingIndex = cartItems.indexWhere( + (item) => item.productName == selectedProduct, + ); + + if (existingIndex >= 0) { + cartItems[existingIndex].quantity += quantity; + } else { + cartItems.add( + CartItem( + productId: productIds[selectedProduct!] ?? 0, + productName: selectedProduct!, + category: productCategories[selectedProduct!]!, + unitPrice: productPrices[selectedProduct!]!, + quantity: quantity, + ), + ); + } + + quantityController.clear(); + notifyListeners(); + return null; + } + + void removeFromCart(int index) { + cartItems.removeAt(index); + notifyListeners(); + } + + void updateQuantity(int index, int newQuantity) { + if (newQuantity <= 0) { + removeFromCart(index); + return; + } + + cartItems[index].quantity = newQuantity; + notifyListeners(); + } + + void clearCart() { + cartItems.clear(); + notifyListeners(); + } + + int get totalPrice => + cartItems.fold(0, (total, item) => total + item.totalPrice); + + Future> submitAllTransactions() async { + if (cartItems.isEmpty) { + return {'status': 'error', 'message': 'Keranjang kosong'}; + } + + isLoading = true; + notifyListeners(); + + try { + final date = selectedDate ?? DateTime.now(); + final dateStr = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + + int successCount = 0; + String? firstError; + final originalCount = cartItems.length; + + for (final item in cartItems) { + final result = await MLService.addTransactionWithStockUpdate( + productId: item.productId, + quantity: item.quantity, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + transactionDate: dateStr, + ); + + if (result['status'] == 'success') { + successCount++; + transactions.insert( + 0, + Transaction( + id: transactions.length + 1, + productName: item.productName, + category: item.category, + quantity: item.quantity, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + date: date, + ), + ); + } else { + firstError ??= result['message'] ?? 'Transaksi gagal'; + } + } + + cartItems.clear(); + selectedDate = DateTime.now(); + + return { + 'status': 'success', + 'successCount': successCount, + 'totalCount': originalCount, + 'error': firstError, + }; + } catch (e) { + return {'status': 'error', 'message': 'Error: ${e.toString()}'}; + } finally { + isLoading = false; + notifyListeners(); + } + } + + Future> createProduct({ + required String productName, + required String category, + required int price, + required int initialStock, + required String unit, + required String productType, + }) async { + if (products.contains(productName)) { + return {'status': 'error', 'message': 'Produk "$productName" sudah ada'}; + } + + final result = await MLService.createProduct( + name: productName, + category: category, + price: price, + currentStock: initialStock, + unit: unit, + productType: productType, + ); + + if (result['status'] == 'success') { + final createdProductId = result['product_id']; + products.add(productName); + if (createdProductId is int && createdProductId > 0) { + productIds[productName] = createdProductId; + } + productCategories[productName] = category; + productUnits[productName] = unit; + productPrices[productName] = price; + if (!categories.contains(category)) { + categories.add(category); + } + notifyListeners(); + } + + return result; + } + + @override + void dispose() { + quantityController.dispose(); + super.dispose(); + } + + static String _defaultUnitFromCategory(String category) { + if (category.toLowerCase() == 'barang') { + return 'pcs'; + } + return 'kg'; + } +} diff --git a/lib/screens/transaction/transaction_page.dart b/lib/screens/transaction/transaction_page.dart new file mode 100644 index 0000000..c6f1b94 --- /dev/null +++ b/lib/screens/transaction/transaction_page.dart @@ -0,0 +1,1016 @@ +import 'package:finalproject/theme/colors.dart'; +import 'package:finalproject/theme/text_styles.dart'; +import 'package:flutter/material.dart'; + +import 'transaction_controller.dart'; + +class TransactionScreen extends StatefulWidget { + const TransactionScreen({super.key}); + + @override + State createState() => _TransactionScreenState(); +} + +class _TransactionScreenState extends State { + late final TransactionController _controller; + + @override + void initState() { + super.initState(); + _controller = TransactionController(); + _controller.loadProducts().catchError((_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Gagal memuat data produk'), + backgroundColor: AppColors.statusError, + ), + ); + }); + } + + void _addToCart() { + final error = _controller.addToCart(); + if (error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error), backgroundColor: AppColors.statusError), + ); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${_controller.selectedProduct} ditambahkan ke keranjang', + ), + backgroundColor: AppColors.statusSuccess, + ), + ); + } + + Future _submitAllTransactions() async { + final result = await _controller.submitAllTransactions(); + + if (!mounted) return; + + if (result['status'] == 'error') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(result['message']), + backgroundColor: AppColors.statusError, + ), + ); + return; + } + + final successCount = result['successCount'] as int; + final totalCount = result['totalCount'] as int; + final firstError = result['error'] as String?; + + if (firstError != null && successCount < totalCount) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '⚠️ $successCount/$totalCount Stock In berhasil. Error: $firstError', + ), + backgroundColor: AppColors.statusError, + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '✅ $successCount Stock In berhasil disimpan dan stok terupdate!', + ), + backgroundColor: AppColors.statusSuccess, + ), + ); + } + } + + void _showAddProductDialog() { + final newProductNameController = TextEditingController(); + final newPriceController = TextEditingController(); + final newStockController = TextEditingController(); + const unitOptionsByType = { + 'Bahan': ['kg'], + 'Barang': ['pcs'], + }; + String selectedProductType = 'Bahan'; + String selectedUnit = unitOptionsByType['Bahan']!.first; + + showDialog( + context: context, + builder: + (context) => StatefulBuilder( + builder: + (context, setStateDialog) => Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.primaryBrown, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + padding: const EdgeInsets.all(16), + child: Text( + 'Tambah Produk Baru', + style: AppTextStyles.headlineSmall.copyWith( + color: Colors.white, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Nama Produk', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: newProductNameController, + decoration: InputDecoration( + hintText: 'Masukkan nama produk', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + filled: true, + fillColor: AppColors.bgLight, + ), + ), + const SizedBox(height: 16), + Text( + 'Jenis Kategori', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: selectedProductType, + items: + ['Bahan', 'Barang'] + .map( + (type) => DropdownMenuItem( + value: type, + child: Text(type), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + setStateDialog(() { + selectedProductType = value; + selectedUnit = + unitOptionsByType[value]!.first; + }); + }, + decoration: InputDecoration( + hintText: 'Pilih jenis kategori', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + filled: true, + fillColor: AppColors.bgLight, + ), + ), + const SizedBox(height: 16), + Text( + 'Satuan', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: selectedUnit, + items: + unitOptionsByType[selectedProductType]! + .map( + (unit) => DropdownMenuItem( + value: unit, + child: Text(unit), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + setStateDialog(() { + selectedUnit = value; + }); + }, + decoration: InputDecoration( + hintText: 'Pilih satuan', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + filled: true, + fillColor: AppColors.bgLight, + ), + ), + const SizedBox(height: 16), + Text( + 'Harga (Rp)', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: newPriceController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Masukkan harga', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + filled: true, + fillColor: AppColors.bgLight, + ), + ), + const SizedBox(height: 16), + Text( + 'Stok Awal', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: newStockController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Masukkan jumlah stok awal', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + filled: true, + fillColor: AppColors.bgLight, + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + side: BorderSide( + color: AppColors.textSecondary, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 10, + ), + ), + ), + child: Text( + 'Batal', + style: AppTextStyles.labelLarge + .copyWith( + color: AppColors.textSecondary, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () async { + final productName = + newProductNameController.text + .trim(); + final category = selectedProductType; + final priceText = + newPriceController.text.trim(); + final stockText = + newStockController.text.trim(); + + if (productName.isEmpty || + category.isEmpty || + priceText.isEmpty || + stockText.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: const Text( + 'Semua field harus diisi', + ), + backgroundColor: + AppColors.statusError, + ), + ); + return; + } + + final priceInt = + int.tryParse(priceText) ?? 0; + if (priceInt <= 0) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: const Text( + 'Harga harus lebih dari 0', + ), + backgroundColor: + AppColors.statusError, + ), + ); + return; + } + + final stockInt = + int.tryParse(stockText) ?? -1; + if (stockInt < 0) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: const Text( + 'Stok awal tidak boleh negatif', + ), + backgroundColor: + AppColors.statusError, + ), + ); + return; + } + + final result = await _controller + .createProduct( + productName: productName, + category: category, + price: priceInt, + initialStock: stockInt, + unit: selectedUnit, + productType: + selectedProductType, + ); + + if (!mounted) return; + + if (result['status'] == 'success') { + Navigator.pop(context); + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + 'Produk "$productName" berhasil ditambahkan', + ), + backgroundColor: + AppColors.statusSuccess, + ), + ); + } else { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + result['message'] ?? + 'Gagal menambahkan produk', + ), + backgroundColor: + AppColors.statusError, + ), + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: + AppColors.primaryBrown, + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 10, + ), + ), + ), + child: Text( + 'Simpan', + style: AppTextStyles.labelLarge + .copyWith(color: Colors.white), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: AppColors.bgLight, + appBar: AppBar( + backgroundColor: AppColors.primaryBrown, + elevation: 0, + title: Text( + ' Stock In', + style: AppTextStyles.headlineLarge.copyWith(color: Colors.white), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppColors.shadowLight], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pilih Produk', + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + Text( + 'Produk', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _controller.selectedProduct, + decoration: InputDecoration( + hintText: 'Pilih produk', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + filled: true, + fillColor: AppColors.bgLight, + ), + items: + _controller.products + .map( + (product) => DropdownMenuItem( + value: product, + child: Text(product), + ), + ) + .toList(), + onChanged: _controller.setSelectedProduct, + ), + const SizedBox(height: 24), + Text( + 'Jumlah', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _controller.quantityController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: 'Masukkan jumlah unit', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + ), + filled: true, + fillColor: AppColors.bgLight, + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _addToCart, + icon: const Icon(Icons.add_shopping_cart), + label: const Text('Tambah ke Keranjang'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBrown, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _showAddProductDialog, + icon: const Icon(Icons.add_circle_outline), + label: const Text('Tambah Produk Baru'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + side: BorderSide(color: AppColors.secondaryBlue), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + if (_controller.cartItems.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppColors.shadowLight], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Keranjang (${_controller.cartItems.length})', + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.textPrimary, + ), + ), + GestureDetector( + onTap: _controller.clearCart, + child: Text( + 'Hapus Semua', + style: AppTextStyles.labelMedium.copyWith( + color: AppColors.statusError, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _controller.cartItems.length, + itemBuilder: (context, index) { + final item = _controller.cartItems[index]; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.bgLight, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.grey300), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + item.productName, + style: AppTextStyles.labelLarge + .copyWith( + color: + AppColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + 'Rp ${item.unitPrice} / unit', + style: AppTextStyles.labelSmall + .copyWith( + color: + AppColors + .textSecondary, + ), + ), + ], + ), + ), + Text( + 'Rp ${item.totalPrice}', + style: AppTextStyles.labelLarge + .copyWith( + color: AppColors.statusSuccess, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: + () => _controller + .updateQuantity( + index, + item.quantity - 1, + ), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.primaryBrown, + borderRadius: + BorderRadius.circular(6), + ), + child: const Icon( + Icons.remove, + color: Colors.white, + size: 18, + ), + ), + ), + const SizedBox(width: 12), + Text( + item.quantity.toString(), + style: AppTextStyles.labelLarge + .copyWith( + color: + AppColors.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: + () => _controller + .updateQuantity( + index, + item.quantity + 1, + ), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.primaryBrown, + borderRadius: + BorderRadius.circular(6), + ), + child: const Icon( + Icons.add, + color: Colors.white, + size: 18, + ), + ), + ), + ], + ), + GestureDetector( + onTap: + () => _controller.removeFromCart( + index, + ), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: AppColors.statusError + .withOpacity(0.1), + borderRadius: + BorderRadius.circular(6), + ), + child: Icon( + Icons.delete, + color: AppColors.statusError, + size: 18, + ), + ), + ), + ], + ), + ], + ), + ); + }, + ), + const SizedBox(height: 16), + const Divider(), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total Pembayaran', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + Text( + 'Rp ${_controller.totalPrice}', + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.primaryBrown, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Tanggal Stock In', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + GestureDetector( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: + _controller.selectedDate ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime.now(), + ); + if (picked != null && + picked != _controller.selectedDate) { + _controller.setSelectedDate(picked); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + border: Border.all(color: AppColors.grey300), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon( + Icons.calendar_today, + color: AppColors.primaryBrown, + ), + const SizedBox(width: 12), + Text( + _controller.selectedDate != null + ? '${_controller.selectedDate!.day}/${_controller.selectedDate!.month}/${_controller.selectedDate!.year}' + : 'Pilih tanggal', + style: AppTextStyles.bodyMedium, + ), + ], + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: + _controller.isLoading + ? null + : _submitAllTransactions, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBrown, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + icon: + _controller.isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : const Icon(Icons.check_circle), + label: Text( + _controller.isLoading + ? 'Menyimpan...' + : 'Simpan Stock In', + style: AppTextStyles.labelLarge, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + Text( + 'Riwayat Stock In (${_controller.transactions.length})', + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 16), + if (_controller.transactions.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 32.0), + child: Column( + children: [ + Icon( + Icons.inbox, + size: 64, + color: AppColors.grey300, + ), + const SizedBox(height: 16), + Text( + 'Belum ada Stock In', + style: AppTextStyles.bodyMedium.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _controller.transactions.length, + itemBuilder: (context, index) { + final tx = _controller.transactions[index]; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.grey200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + tx.productName, + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + Text( + 'Rp ${tx.totalPrice}', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.statusSuccess, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + '${tx.quantity} unit × Rp ${tx.unitPrice}', + style: AppTextStyles.labelMedium.copyWith( + color: AppColors.textSecondary, + ), + ), + Text( + '${tx.date.day}/${tx.date.month}/${tx.date.year}', + style: AppTextStyles.labelSmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ], + ), + ); + }, + ), + ], + ), + ), + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _controller.selectedIndex, + onTap: (index) { + _controller.setSelectedIndex(index); + switch (index) { + case 0: + Navigator.of(context).pushNamed('/dashboard'); + break; + case 1: + Navigator.of(context).pushNamed('/products'); + break; + case 2: + break; + case 3: + Navigator.of(context).pushNamed('/prediction'); + break; + case 4: + Navigator.of(context).pushNamed('/reports'); + break; + case 5: + Navigator.of(context).pushNamed('/settings'); + break; + } + }, + type: BottomNavigationBarType.fixed, + backgroundColor: Colors.white, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home_outlined), + label: 'Dashboard', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_bag_outlined), + label: 'Produk', + ), + BottomNavigationBarItem( + icon: Icon(Icons.shopping_cart_outlined), + label: 'Stock In', + ), + BottomNavigationBarItem( + icon: Icon(Icons.trending_up_outlined), + label: 'Prediksi', + ), + BottomNavigationBarItem( + icon: Icon(Icons.description_outlined), + label: 'Laporan', + ), + BottomNavigationBarItem( + icon: Icon(Icons.settings_outlined), + label: 'Pengaturan', + ), + ], + ), + ); + }, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/screens/transaction_screen.dart b/lib/screens/transaction_screen.dart deleted file mode 100644 index f4b133d..0000000 --- a/lib/screens/transaction_screen.dart +++ /dev/null @@ -1,1205 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:finalproject/theme/colors.dart'; -import 'package:finalproject/theme/text_styles.dart'; -import 'package:finalproject/models/transaction_model.dart'; -import 'package:finalproject/services/ml_service.dart'; - -class CartItem { - final int productId; - final String productName; - final String category; - final int unitPrice; - int quantity; - - CartItem({ - required this.productId, - required this.productName, - required this.category, - required this.unitPrice, - required this.quantity, - }); - - int get totalPrice => unitPrice * quantity; -} - -class TransactionScreen extends StatefulWidget { - const TransactionScreen({Key? key}) : super(key: key); - - @override - State createState() => _TransactionScreenState(); -} - -class _TransactionScreenState extends State { - late TextEditingController _quantityController; - DateTime? _selectedDate; - bool _isLoading = false; - int _selectedIndex = 2; // Transaction tab - - // Products list (mutable) - late List products; - late Map productIds; - late Map productCategories; - late Map productPrices; - late List categories; - - String? _selectedProduct; - List cartItems = []; - List transactions = []; - - @override - void initState() { - super.initState(); - _quantityController = TextEditingController(); - _selectedDate = DateTime.now(); - _loadProducts(); - } - - Future _loadProducts() async { - try { - final fetchedProducts = await MLService.getProducts(); - - setState(() { - products = []; - productIds = {}; - productCategories = {}; - productPrices = {}; - categories = []; - - // Convert API response to local maps - for (var product in fetchedProducts) { - int id = product['id'] ?? 0; - String name = product['name'] ?? ''; - String category = product['category'] ?? ''; - int price = product['price'] ?? 0; - - if (name.isNotEmpty && id > 0) { - products.add(name); - productIds[name] = id; - productCategories[name] = category; - productPrices[name] = price; - - if (!categories.contains(category)) { - categories.add(category); - } - } - } - }); - } catch (e) { - print('Error loading products: $e'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Gagal memuat data produk'), - backgroundColor: AppColors.statusError, - ), - ); - } - } - - @override - void dispose() { - _quantityController.dispose(); - super.dispose(); - } - - void _addToCart() { - if (_selectedProduct == null || _quantityController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Pilih produk dan masukkan jumlah'), - backgroundColor: AppColors.statusError, - ), - ); - return; - } - - final quantity = int.tryParse(_quantityController.text) ?? 0; - if (quantity <= 0) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Jumlah harus lebih dari 0'), - backgroundColor: AppColors.statusError, - ), - ); - return; - } - - final existingIndex = cartItems.indexWhere( - (item) => item.productName == _selectedProduct, - ); - - setState(() { - if (existingIndex >= 0) { - // Update quantity if product already in cart - cartItems[existingIndex].quantity += quantity; - } else { - // Add new item to cart - cartItems.add( - CartItem( - productId: productIds[_selectedProduct!] ?? 0, - productName: _selectedProduct!, - category: productCategories[_selectedProduct!]!, - unitPrice: productPrices[_selectedProduct!]!, - quantity: quantity, - ), - ); - } - _quantityController.clear(); - }); - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('$_selectedProduct ditambahkan ke keranjang'), - backgroundColor: AppColors.statusSuccess, - ), - ); - } - - void _removeFromCart(int index) { - setState(() { - cartItems.removeAt(index); - }); - } - - void _updateQuantity(int index, int newQuantity) { - if (newQuantity <= 0) { - _removeFromCart(index); - } else { - setState(() { - cartItems[index].quantity = newQuantity; - }); - } - } - - int _getTotalPrice() { - return cartItems.fold(0, (total, item) => total + item.totalPrice); - } - - void _submitAllTransactions() async { - if (cartItems.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Keranjang kosong'), - backgroundColor: AppColors.statusError, - ), - ); - return; - } - - setState(() => _isLoading = true); - - try { - final dateStr = - '${_selectedDate!.year}-${_selectedDate!.month.toString().padLeft(2, '0')}-${_selectedDate!.day.toString().padLeft(2, '0')}'; - - int successCount = 0; - String? firstError; - - for (final item in cartItems) { - // Use new endpoint that updates stock automatically - final result = await MLService.addTransactionWithStockUpdate( - productId: item.productId, - quantity: item.quantity, - unitPrice: item.unitPrice, - totalPrice: item.totalPrice, - transactionDate: dateStr, - ); - - if (result['status'] == 'success') { - successCount++; - // Add to transaction history - final transaction = Transaction( - id: transactions.length + 1, - productName: item.productName, - category: item.category, - quantity: item.quantity, - unitPrice: item.unitPrice, - totalPrice: item.totalPrice, - date: _selectedDate ?? DateTime.now(), - ); - transactions.insert(0, transaction); - } else { - firstError ??= result['message'] ?? 'Transaksi gagal'; - print('Transaction failed for ${item.productName}: ${result['message']}'); - } - } - - setState(() { - _isLoading = false; - cartItems.clear(); - _selectedDate = DateTime.now(); - }); - - if (firstError != null && successCount < cartItems.length) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '⚠️ $successCount/${cartItems.length} transaksi berhasil. Error: $firstError', - ), - backgroundColor: AppColors.statusError, - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('✅ $successCount transaksi berhasil disimpan dan stok terupdate!'), - backgroundColor: AppColors.statusSuccess, - ), - ); - } - } catch (e) { - setState(() => _isLoading = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Error: ${e.toString()}'), - backgroundColor: AppColors.statusError, - ), - ); - } - } - - void _showAddProductDialog() { - final TextEditingController newProductNameController = - TextEditingController(); - final TextEditingController newCategoryController = TextEditingController(); - final TextEditingController newPriceController = TextEditingController(); - String _selectedCategory = categories.isNotEmpty ? categories.first : ''; - bool _createNewCategory = false; - - showDialog( - context: context, - builder: - (context) => StatefulBuilder( - builder: - (context, setStateDialog) => Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Container( - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Container( - width: double.infinity, - decoration: BoxDecoration( - color: AppColors.primaryBrown, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - padding: const EdgeInsets.all(16), - child: Text( - 'Tambah Produk Baru', - style: AppTextStyles.headlineSmall.copyWith( - color: Colors.white, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Product Name - Text( - 'Nama Produk', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: newProductNameController, - decoration: InputDecoration( - hintText: 'Masukkan nama produk', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - filled: true, - fillColor: AppColors.bgLight, - ), - ), - const SizedBox(height: 16), - - // Category Selection - Text( - 'Kategori', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - if (!_createNewCategory) - Column( - children: [ - DropdownButtonFormField( - value: - _selectedCategory.isNotEmpty - ? _selectedCategory - : null, - items: - categories - .map( - (cat) => DropdownMenuItem( - value: cat, - child: Text(cat), - ), - ) - .toList(), - onChanged: (value) { - setStateDialog( - () => - _selectedCategory = value ?? '', - ); - }, - decoration: InputDecoration( - hintText: 'Pilih kategori', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - 10, - ), - ), - filled: true, - fillColor: AppColors.bgLight, - ), - ), - const SizedBox(height: 8), - GestureDetector( - onTap: () { - setStateDialog( - () => _createNewCategory = true, - ); - }, - child: Text( - '+ Tambah kategori baru', - style: AppTextStyles.labelMedium - .copyWith( - color: AppColors.secondaryBlue, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ) - else - Column( - children: [ - TextFormField( - controller: newCategoryController, - decoration: InputDecoration( - hintText: - 'Masukkan nama kategori baru', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular( - 10, - ), - ), - filled: true, - fillColor: AppColors.bgLight, - ), - ), - const SizedBox(height: 8), - GestureDetector( - onTap: () { - setStateDialog( - () => _createNewCategory = false, - ); - newCategoryController.clear(); - }, - child: Text( - '← Kembali ke kategori existing', - style: AppTextStyles.labelMedium - .copyWith( - color: AppColors.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - // Price - Text( - 'Harga (Rp)', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: newPriceController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: 'Masukkan harga', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - filled: true, - fillColor: AppColors.bgLight, - ), - ), - const SizedBox(height: 24), - - // Buttons - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => Navigator.pop(context), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - vertical: 12, - ), - side: BorderSide( - color: AppColors.textSecondary, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 10, - ), - ), - ), - child: Text( - 'Batal', - style: AppTextStyles.labelLarge - .copyWith( - color: AppColors.textSecondary, - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton( - onPressed: () async { - final productName = - newProductNameController.text - .trim(); - final category = - _createNewCategory - ? newCategoryController.text - .trim() - : _selectedCategory; - final price = - newPriceController.text.trim(); - - if (productName.isEmpty || - category.isEmpty || - price.isEmpty) { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: const Text( - 'Semua field harus diisi', - ), - backgroundColor: - AppColors.statusError, - ), - ); - return; - } - - final priceInt = - int.tryParse(price) ?? 0; - if (priceInt <= 0) { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: const Text( - 'Harga harus lebih dari 0', - ), - backgroundColor: - AppColors.statusError, - ), - ); - return; - } - - // Frontend validation: check duplicate - if (products.contains(productName)) { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - 'Produk "$productName" sudah ada', - ), - backgroundColor: - AppColors.statusError, - ), - ); - return; - } - - // Call API to create product - setStateDialog(() { - // Show loading in dialog via disabling button - }); - - final result = - await MLService.createProduct( - name: productName, - category: category, - price: priceInt, - currentStock: - 0, // Default stock 0 - ); - - if (!mounted) return; - - if (result['status'] == 'success') { - // Add to local state for immediate UI update - setState(() { - products.add(productName); - productCategories[productName] = - category; - productPrices[productName] = - priceInt; - - if (_createNewCategory && - !categories.contains( - category, - )) { - categories.add(category); - } - }); - - Navigator.pop(context); - - // Show success message - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - 'Produk "$productName" berhasil ditambahkan', - ), - backgroundColor: - AppColors.statusSuccess, - ), - ); - } else { - // Handle error from API - String errorMsg = - result['message'] ?? - 'Gagal menambahkan produk'; - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text(errorMsg), - backgroundColor: - AppColors.statusError, - ), - ); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: - AppColors.primaryBrown, - padding: const EdgeInsets.symmetric( - vertical: 12, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - 10, - ), - ), - ), - child: Text( - 'Simpan', - style: AppTextStyles.labelLarge - .copyWith(color: Colors.white), - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.bgLight, - appBar: AppBar( - backgroundColor: AppColors.primaryBrown, - elevation: 0, - title: Text( - 'Transaksi Penjualan', - style: AppTextStyles.headlineLarge.copyWith(color: Colors.white), - ), - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () => Navigator.pop(context), - ), - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Form card untuk memilih produk - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Pilih Produk', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 16), - - // Product dropdown - Text( - 'Produk', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - value: _selectedProduct, - decoration: InputDecoration( - hintText: 'Pilih produk', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - filled: true, - fillColor: AppColors.bgLight, - ), - items: - products - .map( - (product) => DropdownMenuItem( - value: product, - child: Text(product), - ), - ) - .toList(), - onChanged: (value) { - setState(() => _selectedProduct = value); - }, - ), - const SizedBox(height: 24), - - // Quantity input - Text( - 'Jumlah', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - TextField( - controller: _quantityController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: 'Masukkan jumlah unit', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - ), - filled: true, - fillColor: AppColors.bgLight, - ), - ), - const SizedBox(height: 24), - - // Add to cart button - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _addToCart, - icon: const Icon(Icons.add_shopping_cart), - label: const Text('Tambah ke Keranjang'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryBrown, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - ), - ), - const SizedBox(height: 16), - - // Add new product button - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _showAddProductDialog, - icon: const Icon(Icons.add_circle_outline), - label: const Text('Tambah Produk Baru'), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - side: BorderSide(color: AppColors.secondaryBlue), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - // Cart section - if (cartItems.isNotEmpty) ...[ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Keranjang (${cartItems.length})', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - GestureDetector( - onTap: () { - setState(() => cartItems.clear()); - }, - child: Text( - 'Hapus Semua', - style: AppTextStyles.labelMedium.copyWith( - color: AppColors.statusError, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - // Cart items list - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: cartItems.length, - itemBuilder: (context, index) { - final item = cartItems[index]; - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.bgLight, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.grey300), - ), - child: Column( - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - item.productName, - style: AppTextStyles.labelLarge - .copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - 'Rp ${item.unitPrice} / unit', - style: AppTextStyles.labelSmall - .copyWith( - color: - AppColors.textSecondary, - ), - ), - ], - ), - ), - Text( - 'Rp ${item.totalPrice}', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.statusSuccess, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - // Quantity controls - Row( - children: [ - GestureDetector( - onTap: - () => _updateQuantity( - index, - item.quantity - 1, - ), - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: AppColors.primaryBrown, - borderRadius: - BorderRadius.circular(6), - ), - child: const Icon( - Icons.remove, - color: Colors.white, - size: 18, - ), - ), - ), - const SizedBox(width: 12), - Text( - item.quantity.toString(), - style: AppTextStyles.labelLarge - .copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 12), - GestureDetector( - onTap: - () => _updateQuantity( - index, - item.quantity + 1, - ), - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: AppColors.primaryBrown, - borderRadius: - BorderRadius.circular(6), - ), - child: const Icon( - Icons.add, - color: Colors.white, - size: 18, - ), - ), - ), - ], - ), - // Delete button - GestureDetector( - onTap: () => _removeFromCart(index), - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: AppColors.statusError - .withOpacity(0.1), - borderRadius: BorderRadius.circular( - 6, - ), - ), - child: Icon( - Icons.delete, - color: AppColors.statusError, - size: 18, - ), - ), - ), - ], - ), - ], - ), - ); - }, - ), - - const SizedBox(height: 16), - const Divider(), - const SizedBox(height: 12), - - // Total price - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Total Pembayaran', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - Text( - 'Rp ${_getTotalPrice()}', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.primaryBrown, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - const SizedBox(height: 16), - - // Date picker - Text( - 'Tanggal Transaksi', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 8), - GestureDetector( - onTap: () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: _selectedDate ?? DateTime.now(), - firstDate: DateTime(2020), - lastDate: DateTime.now(), - ); - if (picked != null && picked != _selectedDate) { - setState(() => _selectedDate = picked); - } - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - decoration: BoxDecoration( - border: Border.all(color: AppColors.grey300), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - const Icon( - Icons.calendar_today, - color: AppColors.primaryBrown, - ), - const SizedBox(width: 12), - Text( - _selectedDate != null - ? '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}' - : 'Pilih tanggal', - style: AppTextStyles.bodyMedium, - ), - ], - ), - ), - ), - const SizedBox(height: 24), - - // Submit button - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _isLoading ? null : _submitAllTransactions, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryBrown, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - icon: - _isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Colors.white, - ), - ), - ) - : const Icon(Icons.check_circle), - label: Text( - _isLoading ? 'Menyimpan...' : 'Simpan Transaksi', - style: AppTextStyles.labelLarge, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - ], - - // Transactions history - Text( - 'Riwayat Transaksi (${transactions.length})', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 16), - - if (transactions.isEmpty) - Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 32.0), - child: Column( - children: [ - Icon(Icons.inbox, size: 64, color: AppColors.grey300), - const SizedBox(height: 16), - Text( - 'Belum ada transaksi', - style: AppTextStyles.bodyMedium.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - ) - else - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: transactions.length, - itemBuilder: (context, index) { - final tx = transactions[index]; - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.grey200), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - tx.productName, - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - Text( - 'Rp ${tx.totalPrice}', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.statusSuccess, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${tx.quantity} unit × Rp ${tx.unitPrice}', - style: AppTextStyles.labelMedium.copyWith( - color: AppColors.textSecondary, - ), - ), - Text( - '${tx.date.day}/${tx.date.month}/${tx.date.year}', - style: AppTextStyles.labelSmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ], - ), - ); - }, - ), - ], - ), - ), - ), - bottomNavigationBar: BottomNavigationBar( - currentIndex: _selectedIndex, - onTap: (index) { - setState(() => _selectedIndex = index); - switch (index) { - case 0: - Navigator.of(context).pushNamed('/dashboard'); - break; - case 1: - Navigator.of(context).pushNamed('/products'); - break; - case 2: - break; - case 3: - Navigator.of(context).pushNamed('/prediction'); - break; - case 4: - Navigator.of(context).pushNamed('/reports'); - break; - } - }, - type: BottomNavigationBarType.fixed, - backgroundColor: Colors.white, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.home_outlined), - label: 'Dashboard', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_bag_outlined), - label: 'Produk', - ), - BottomNavigationBarItem( - icon: Icon(Icons.shopping_cart_outlined), - label: 'Transaksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.trending_up_outlined), - label: 'Prediksi', - ), - BottomNavigationBarItem( - icon: Icon(Icons.description_outlined), - label: 'Laporan', - ), - ], - ), - ); - } -} diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index 9336f60..2ef9423 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.18.30:5000'; + static const String baseUrl = 'http://192.168.110.16:5000'; static const int timeoutSeconds = 30; @@ -215,6 +215,8 @@ class MLService { required String category, required int price, required int currentStock, + String? unit, + String? productType, }) async { try { final data = { @@ -222,6 +224,8 @@ class MLService { 'category': category, 'price': price, 'current_stock': currentStock, + if (unit != null) 'unit': unit, + if (productType != null) 'product_type': productType, }; final response = await http diff --git a/ml_model/app.py b/ml_model/app.py index 277682c..0628144 100644 --- a/ml_model/app.py +++ b/ml_model/app.py @@ -308,7 +308,9 @@ def create_product(): "name": "Tepung Terigu 1kg", "category": "Tepung", "price": 15000, - "current_stock": 50 + "current_stock": 50, + "unit": "kg", + "product_type": "Bahan" } """ try: @@ -331,6 +333,15 @@ def create_product(): cursor = connection.cursor() + # Optional columns compatibility (works for old/new schemas) + cursor.execute("SHOW COLUMNS FROM products LIKE 'unit'") + has_unit_column = cursor.fetchone() is not None + cursor.execute("SHOW COLUMNS FROM products LIKE 'product_type'") + has_product_type_column = cursor.fetchone() is not None + + unit_value = data.get('unit') + product_type_value = data.get('product_type') + # Check for duplicate product name cursor.execute("SELECT id FROM products WHERE name = %s", (data['name'],)) if cursor.fetchone(): @@ -342,16 +353,54 @@ def create_product(): }), 409 # Insert new product - cursor.execute(""" - INSERT INTO products - (name, category, price, current_stock) - VALUES (%s, %s, %s, %s) - """, ( - data['name'], - data['category'], - data['price'], - data['current_stock'] - )) + if has_unit_column and has_product_type_column: + cursor.execute(""" + INSERT INTO products + (name, category, price, current_stock, unit, product_type) + VALUES (%s, %s, %s, %s, %s, %s) + """, ( + data['name'], + data['category'], + data['price'], + data['current_stock'], + unit_value, + product_type_value + )) + elif has_unit_column: + cursor.execute(""" + INSERT INTO products + (name, category, price, current_stock, unit) + VALUES (%s, %s, %s, %s, %s) + """, ( + data['name'], + data['category'], + data['price'], + data['current_stock'], + unit_value + )) + elif has_product_type_column: + cursor.execute(""" + INSERT INTO products + (name, category, price, current_stock, product_type) + VALUES (%s, %s, %s, %s, %s) + """, ( + data['name'], + data['category'], + data['price'], + data['current_stock'], + product_type_value + )) + else: + cursor.execute(""" + INSERT INTO products + (name, category, price, current_stock) + VALUES (%s, %s, %s, %s) + """, ( + data['name'], + data['category'], + data['price'], + data['current_stock'] + )) connection.commit() product_id = cursor.lastrowid diff --git a/ml_model/setup_database.sql b/ml_model/setup_database.sql index 63ca3d3..46342bd 100644 --- a/ml_model/setup_database.sql +++ b/ml_model/setup_database.sql @@ -10,6 +10,8 @@ CREATE TABLE IF NOT EXISTS products ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, category VARCHAR(100) NOT NULL, + product_type VARCHAR(20) DEFAULT 'Bahan', + unit VARCHAR(20) DEFAULT 'kg', price INT NOT NULL, current_stock INT NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -62,15 +64,15 @@ CREATE TABLE IF NOT EXISTS recipe_ingredients ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- Insert Initial Products (8 items) -INSERT INTO products (name, category, price, current_stock) VALUES -('Tepung Terigu 1kg', 'Tepung', 15000, 50), -('Telur 1kg', 'Telur', 25000, 30), -('Gula Pasir 1kg', 'Gula', 12000, 40), -('Susu Bubuk', 'Susu', 20000, 20), -('Cokelat Bubuk 250gr', 'Cokelat', 18000, 15), -('Mentega 500gr', 'Mentega', 22000, 25), -('Keju Parut 250gr', 'Keju', 28000, 10), -('Baking Powder', 'Bahan Tambahan', 8000, 35); +INSERT INTO products (name, category, product_type, unit, price, current_stock) VALUES +('Tepung Terigu 1kg', 'Tepung', 'Bahan', 'kg', 15000, 50), +('Telur 1kg', 'Telur', 'Bahan', 'kg', 25000, 30), +('Gula Pasir 1kg', 'Gula', 'Bahan', 'kg', 12000, 40), +('Susu Bubuk', 'Susu', 'Bahan', 'kg', 20000, 20), +('Cokelat Bubuk 250gr', 'Cokelat', 'Bahan', 'kg', 18000, 15), +('Mentega 500gr', 'Mentega', 'Bahan', 'kg', 22000, 25), +('Keju Parut 250gr', 'Keju', 'Bahan', 'kg', 28000, 10), +('Baking Powder', 'Bahan Tambahan', 'Bahan', 'kg', 8000, 35); -- Insert Sample Recipes INSERT INTO recipes (recipe_name, description) VALUES