diff --git a/lib/main.dart b/lib/main.dart index bc469f4..aced403 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ 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'; +import 'package:finalproject/utils/route_observer.dart'; void main() { runApp(const MyApp()); @@ -22,7 +23,8 @@ class MyApp extends StatelessWidget { title: 'Prediksi Stok Bahan Kue', theme: AppTheme.lightTheme(), debugShowCheckedModeBanner: false, - initialRoute: '/login', + navigatorObservers: [routeObserver], + initialRoute: '/splash', routes: { '/splash': (context) => const SplashScreen(), '/login': (context) => const LoginScreen(), diff --git a/lib/screens/prediction/prediction_page.dart b/lib/screens/prediction/prediction_page.dart index bff9835..030fd3f 100644 --- a/lib/screens/prediction/prediction_page.dart +++ b/lib/screens/prediction/prediction_page.dart @@ -11,6 +11,7 @@ class PredictionScreen extends StatefulWidget { class _PredictionScreenState extends State { late final PredictionController _controller; + final TextEditingController _productionController = TextEditingController(); @override void initState() { @@ -32,9 +33,94 @@ class _PredictionScreenState extends State { if (!mounted) return; if (result['status'] == 'success') { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('✅ Stok berhasil diperbarui')), + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 24), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF10B981).withOpacity(0.15), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_circle, + color: Color(0xFF10B981), + size: 36, + ), + ), + const SizedBox(height: 14), + const Text( + 'Produksi Berhasil', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 6), + const Text( + 'Stok bahan sudah diperbarui sesuai produksi.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + height: 1.4, + color: Color(0xFF6B7280), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFA89080), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Sukses', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ); + }, ); + await _controller.refreshStock(); + _controller.reset(); + _productionController.clear(); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(result['message'] ?? 'Gagal submit')), @@ -341,6 +427,7 @@ class _PredictionScreenState extends State { ), const SizedBox(height: 8), TextField( + controller: _productionController, keyboardType: TextInputType.number, onChanged: _controller.setProductionQuantity, @@ -419,7 +506,10 @@ class _PredictionScreenState extends State { ), const SizedBox(width: 10), OutlinedButton( - onPressed: _controller.reset, + onPressed: () { + _controller.reset(); + _productionController.clear(); + }, style: OutlinedButton.styleFrom( side: const BorderSide( color: Color(0xFFE5E7EB), @@ -1151,6 +1241,7 @@ class _PredictionScreenState extends State { @override void dispose() { + _productionController.dispose(); _controller.dispose(); super.dispose(); } diff --git a/lib/screens/products/product_list_page.dart b/lib/screens/products/product_list_page.dart index 23a432b..74394fb 100644 --- a/lib/screens/products/product_list_page.dart +++ b/lib/screens/products/product_list_page.dart @@ -1,6 +1,7 @@ import 'package:finalproject/models/product_model.dart'; import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/text_styles.dart'; +import 'package:finalproject/utils/route_observer.dart'; import 'package:flutter/material.dart'; import 'product_list_controller.dart'; @@ -12,7 +13,7 @@ class ProductListScreen extends StatefulWidget { State createState() => _ProductListScreenState(); } -class _ProductListScreenState extends State { +class _ProductListScreenState extends State with RouteAware { late final ProductListController _controller; @override @@ -30,6 +31,20 @@ class _ProductListScreenState extends State { }); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route != null) { + routeObserver.subscribe(this, route); + } + } + + @override + void didPopNext() { + _controller.loadProducts(); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -546,6 +561,7 @@ class _ProductListScreenState extends State { @override void dispose() { + routeObserver.unsubscribe(this); _controller.dispose(); super.dispose(); } diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index d476f55..9840a0f 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -64,16 +64,16 @@ class _SplashScreenState extends State await Future.delayed(const Duration(seconds: 1)); if (mounted) { - // Navigate ke Dashboard - Navigator.of(context).pushReplacementNamed('/dashboard'); + // Navigate ke Login + Navigator.of(context).pushReplacementNamed('/login'); } } catch (e) { setState(() => _statusMessage = 'Error: ${e.toString()}'); await Future.delayed(const Duration(seconds: 2)); if (mounted) { - // Navigate ke Dashboard even on error - Navigator.of(context).pushReplacementNamed('/dashboard'); + // Navigate ke Login even on error + Navigator.of(context).pushReplacementNamed('/login'); } } } @@ -166,8 +166,10 @@ class _SplashScreenState extends State // App name with fade-in FadeTransition( - opacity: Tween(begin: 0.0, end: 1.0) - .animate( + opacity: Tween( + begin: 0.0, + end: 1.0, + ).animate( CurvedAnimation( parent: _fadeController, curve: Curves.easeInOut, @@ -237,8 +239,10 @@ class _SplashScreenState extends State mainAxisAlignment: MainAxisAlignment.center, children: [ ScaleTransition( - scale: Tween(begin: 0.4, end: 1.0) - .animate( + scale: Tween( + begin: 0.4, + end: 1.0, + ).animate( CurvedAnimation( parent: _rotateController, curve: Curves.easeInOut, @@ -255,8 +259,10 @@ class _SplashScreenState extends State ), const SizedBox(width: 6), ScaleTransition( - scale: Tween(begin: 0.4, end: 1.0) - .animate( + scale: Tween( + begin: 0.4, + end: 1.0, + ).animate( CurvedAnimation( parent: _rotateController, curve: Curves.easeInOut, @@ -273,8 +279,10 @@ class _SplashScreenState extends State ), const SizedBox(width: 6), ScaleTransition( - scale: Tween(begin: 0.4, end: 1.0) - .animate( + scale: Tween( + begin: 0.4, + end: 1.0, + ).animate( CurvedAnimation( parent: _rotateController, curve: Curves.easeInOut, diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index 1828702..24733c6 100644 --- a/lib/services/ml_service.dart +++ b/lib/services/ml_service.dart @@ -5,7 +5,7 @@ class MLService { // API URL - Change based on environment // Untuk emulator Android: 10.0.2.2 // Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local - static const String baseUrl = 'http://192.168.1.12:5000'; + static const String baseUrl = 'http://10.103.65.129:5000'; static const int timeoutSeconds = 30; @@ -229,14 +229,23 @@ class MLService { print('[consumeStock] Response status: ${response.statusCode}'); print('[consumeStock] Response body: ${response.body}'); + final parsedBody = + response.body.isNotEmpty ? jsonDecode(response.body) : null; + if (response.statusCode == 200) { - return jsonDecode(response.body); - } else { - return { - 'status': 'error', - 'message': 'Server error: ${response.statusCode}', - }; + return parsedBody is Map + ? parsedBody + : {'status': 'success'}; } + + if (parsedBody is Map) { + return parsedBody; + } + + return { + 'status': 'error', + 'message': 'Server error: ${response.statusCode}', + }; } catch (e) { print('Consume stock error: $e'); return {'status': 'error', 'message': 'Connection error: $e'}; diff --git a/lib/utils/route_observer.dart b/lib/utils/route_observer.dart new file mode 100644 index 0000000..1aa8450 --- /dev/null +++ b/lib/utils/route_observer.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +final RouteObserver> routeObserver = + RouteObserver>(); diff --git a/ml_model/app.py b/ml_model/app.py index 61d6ab2..23c696c 100644 --- a/ml_model/app.py +++ b/ml_model/app.py @@ -21,9 +21,6 @@ logger = logging.getLogger(__name__) app = Flask(__name__) CORS(app) -# Prevent overlapping stock consumption transactions -transaction_in_progress = False - # ============================================================================ # DATABASE CONFIGURATION # ============================================================================ @@ -63,6 +60,13 @@ def get_product_name_column(connection) -> str: cursor.close() return 'name' if has_name else 'product_name' +def has_product_unit_column(connection) -> bool: + cursor = connection.cursor() + cursor.execute("SHOW COLUMNS FROM products LIKE 'unit'") + has_unit = cursor.fetchone() is not None + cursor.close() + return has_unit + def grams_to_kg_rounded(grams: float) -> float: """ Convert grams to kilograms with rounding rules: @@ -468,17 +472,14 @@ def consume_stock(): "allow_partial": false } """ - global transaction_in_progress - if transaction_in_progress: - return jsonify({ - 'status': 'error', - 'message': 'Transaction already in progress' - }), 429 - - transaction_in_progress = True + print("FLASK TERBARU AKTIF") + print("REQUEST RECEIVED") + connection = None + cursor = None + started_transaction = False try: data = request.json or {} - logger.info(f"[stock/consume] Incoming payload: {data}") + print("REQUEST DATA:", data) items = data.get('items', []) allow_partial = bool(data.get('allow_partial', False)) @@ -493,10 +494,11 @@ def consume_stock(): return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500 name_column = get_product_name_column(connection) + unit_column_exists = has_product_unit_column(connection) history_enabled = table_exists(connection, 'stock_usage_history') cursor = connection.cursor(dictionary=True) - connection.start_transaction() + started_transaction = True errors = [] valid_items = [] @@ -514,14 +516,19 @@ def consume_stock(): }) continue + if unit_column_exists: + select_fields = f"id, {name_column} as name, current_stock, unit" + else: + select_fields = f"id, {name_column} as name, current_stock" + if product_id: cursor.execute( - f"SELECT id, {name_column} as name, current_stock, unit FROM products WHERE id = %s", + f"SELECT {select_fields} FROM products WHERE id = %s", (product_id,) ) else: cursor.execute( - f"SELECT id, {name_column} as name, current_stock, unit FROM products WHERE {name_column} = %s", + f"SELECT {select_fields} FROM products WHERE {name_column} = %s", (product_name,) ) @@ -573,14 +580,13 @@ def consume_stock(): if errors and not allow_partial: connection.rollback() - cursor.close() - connection.close() return jsonify({ 'status': 'error', 'message': 'Ada item gagal diproses', 'errors': errors }), 400 + print("UPDATING STOCK") results = [] for entry in valid_items: product = entry['product'] @@ -618,8 +624,6 @@ def consume_stock(): }) connection.commit() - cursor.close() - connection.close() return jsonify({ 'status': 'success', @@ -629,10 +633,25 @@ def consume_stock(): }), 200 except Exception as e: + if connection and started_transaction: + try: + connection.rollback() + except Exception: + logger.exception("[stock/consume] Failed to rollback transaction") logger.error(f"Consume stock error: {str(e)}") return jsonify({'status': 'error', 'message': str(e)}), 500 finally: - transaction_in_progress = False + if cursor is not None: + try: + cursor.close() + except Exception: + logger.exception("[stock/consume] Failed to close cursor") + if connection is not None: + try: + connection.close() + except Exception: + logger.exception("[stock/consume] Failed to close connection") + print("REQUEST FINISHED") @app.route('/transactions', methods=['POST']) @@ -998,6 +1017,11 @@ def internal_error(error): return jsonify({'status': 'error', 'message': 'Internal server error'}), 500 +@app.route('/test') +def test(): + return {"message": "FLASK BARU AKTIF"} + + # ============================================================================ # MAIN # ============================================================================