From 244395c2344088cab6f3dbc4dee9f48e2e51fdf2 Mon Sep 17 00:00:00 2001 From: rhanarmt Date: Mon, 20 Apr 2026 23:25:20 +0700 Subject: [PATCH] Implement real-time product API synchronization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add POST /products endpoint to Flask backend for creating new products - Add createProduct() method to MLService for API communication - Update Transaction Screen to fetch products from API in dropdown - Update Product List Screen with RefreshIndicator and dynamic product loading - Add product count display to header subtitle - Implement filter and search functionality with computed filteredProducts getter - All screens now sync with backend database in real-time Build status: ✅ Compiles successfully on Android device --- lib/screens/product_list_screen.dart | 339 ++++++++++++++------------- lib/screens/transaction_screen.dart | 164 ++++++++----- lib/services/ml_service.dart | 42 +++- ml_model/app.py | 69 ++++++ 4 files changed, 385 insertions(+), 229 deletions(-) diff --git a/lib/screens/product_list_screen.dart b/lib/screens/product_list_screen.dart index 05835fc..3b5853e 100644 --- a/lib/screens/product_list_screen.dart +++ b/lib/screens/product_list_screen.dart @@ -2,6 +2,7 @@ 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); @@ -13,73 +14,56 @@ class ProductListScreen extends StatefulWidget { class _ProductListScreenState extends State { String _selectedFilter = 'semua'; String _searchQuery = ''; + bool _isLoading = true; - final List products = [ - Product( - id: 1, - name: 'Tepung Terigu 1kg', - category: 'Tepung', - price: 15000, - stock: 45, - status: 'tersedia', - ), - Product( - id: 2, - name: 'Telur 1kg', - category: 'Telur', - price: 35000, - stock: 12, - status: 'rendah', - ), - Product( - id: 3, - name: 'Gula Pasir 1kg', - category: 'Gula', - price: 20000, - stock: 28, - status: 'tersedia', - ), - Product( - id: 4, - name: 'Susu Bubuk', - category: 'Susu', - price: 45000, - stock: 8, - status: 'kritis', - ), - Product( - id: 5, - name: 'Cokelat Bubuk 250gr', - category: 'Cokelat', - price: 35000, - stock: 22, - status: 'tersedia', - ), - Product( - id: 6, - name: 'Mentega 500gr', - category: 'Mentega', - price: 50000, - stock: 15, - status: 'tersedia', - ), - Product( - id: 7, - name: 'Keju Parut 250gr', - category: 'Keju', - price: 40000, - stock: 3, - status: 'rendah', - ), - Product( - id: 8, - name: 'Baking Powder', - category: 'Bahan Tambahan', - price: 12000, - stock: 60, - status: 'tersedia', - ), - ]; + 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; @@ -187,8 +171,8 @@ class _ProductListScreenState extends State { const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text( + children: [ + const Text( 'Data Produk', style: TextStyle( color: Colors.white, @@ -196,10 +180,10 @@ class _ProductListScreenState extends State { fontWeight: FontWeight.w700, ), ), - SizedBox(height: 2), + const SizedBox(height: 2), Text( - '8 Produk', - style: TextStyle( + '${products.length} Produk', + style: const TextStyle( color: Colors.white70, fontSize: 12, fontWeight: FontWeight.w400, @@ -250,108 +234,127 @@ class _ProductListScreenState extends State { ), ), ), - body: 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(), + 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...'), + ], ), - ), - ), - - // 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( + ) + : 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: [ - 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, - ), - ], + '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, diff --git a/lib/screens/transaction_screen.dart b/lib/screens/transaction_screen.dart index 456f0b5..b58f0e1 100644 --- a/lib/screens/transaction_screen.dart +++ b/lib/screens/transaction_screen.dart @@ -46,45 +46,47 @@ class _TransactionScreenState extends State { @override void initState() { super.initState(); - // Initialize products - products = [ - 'Tepung Terigu 1kg', - 'Telur 1kg', - 'Gula Pasir 1kg', - 'Susu Bubuk', - 'Cokelat Bubuk 250gr', - 'Mentega 500gr', - 'Keju Parut 250gr', - 'Baking Powder', - ]; - - productCategories = { - 'Tepung Terigu 1kg': 'Tepung', - 'Telur 1kg': 'Telur', - 'Gula Pasir 1kg': 'Gula', - 'Susu Bubuk': 'Susu', - 'Cokelat Bubuk 250gr': 'Cokelat', - 'Mentega 500gr': 'Mentega', - 'Keju Parut 250gr': 'Keju', - 'Baking Powder': 'Bahan Tambahan', - }; - - productPrices = { - 'Tepung Terigu 1kg': 15000, - 'Telur 1kg': 35000, - 'Gula Pasir 1kg': 20000, - 'Susu Bubuk': 45000, - 'Cokelat Bubuk 250gr': 35000, - 'Mentega 500gr': 50000, - 'Keju Parut 250gr': 40000, - 'Baking Powder': 12000, - }; - - // Extract unique categories - categories = productCategories.values.toSet().toList(); - _quantityController = TextEditingController(); _selectedDate = DateTime.now(); + _loadProducts(); + } + + Future _loadProducts() async { + try { + final fetchedProducts = await MLService.getProducts(); + + setState(() { + products = []; + productCategories = {}; + productPrices = {}; + categories = []; + + // Convert API response to local maps + for (var product in fetchedProducts) { + String name = product['name'] ?? ''; + String category = product['category'] ?? ''; + int price = product['price'] ?? 0; + + if (name.isNotEmpty) { + products.add(name); + 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 @@ -429,7 +431,7 @@ class _TransactionScreenState extends State { const SizedBox(width: 12), Expanded( child: ElevatedButton( - onPressed: () { + onPressed: () async { final productName = newProductNameController.text.trim(); final category = _createNewCategory @@ -467,32 +469,74 @@ class _TransactionScreenState extends State { return; } - // Add new product - setState(() { - products.add(productName); - productCategories[productName] = - category; - productPrices[productName] = priceInt; + // Frontend validation: check duplicate + if (products.contains(productName)) { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + 'Produk "$productName" sudah ada'), + backgroundColor: + AppColors.statusError, + ), + ); + return; + } - // Add new category if created - if (_createNewCategory && - !categories.contains(category)) { - categories.add(category); - } + // Call API to create product + setStateDialog(() { + // Show loading in dialog via disabling button }); - Navigator.pop(context); - - // Show success message - ScaffoldMessenger.of(context) - .showSnackBar( - SnackBar( - content: Text( - 'Produk "$productName" berhasil ditambahkan'), - backgroundColor: - AppColors.statusSuccess, - ), + 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, diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index a8f865b..9ff3760 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.13:5000'; + static const String baseUrl = 'http://192.168.1.2:5000'; static const int timeoutSeconds = 30; @@ -209,6 +209,46 @@ class MLService { } } + /// Create new product in database + static Future> createProduct({ + required String name, + required String category, + required int price, + required int currentStock, + }) async { + try { + final data = { + 'name': name, + 'category': category, + 'price': price, + 'current_stock': currentStock, + }; + + final response = await http + .post( + Uri.parse('$baseUrl/products'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(data), + ) + .timeout(Duration(seconds: timeoutSeconds)); + + if (response.statusCode == 201) { + return jsonDecode(response.body); + } else if (response.statusCode == 409) { + final result = jsonDecode(response.body); + return result; + } else { + return { + 'status': 'error', + 'message': 'Server error: ${response.statusCode}', + }; + } + } catch (e) { + print('Create product error: $e'); + return {'status': 'error', 'message': 'Connection error: $e'}; + } + } + /// Get specific product by ID static Future?> getProduct(int productId) async { try { diff --git a/ml_model/app.py b/ml_model/app.py index 80fecb6..7657c76 100644 --- a/ml_model/app.py +++ b/ml_model/app.py @@ -299,6 +299,75 @@ def get_product(product_id): return jsonify({'status': 'error', 'message': str(e)}), 500 +@app.route('/products', methods=['POST']) +def create_product(): + """ + Create new product in database + Body: { + "name": "Tepung Terigu 1kg", + "category": "Tepung", + "price": 15000, + "current_stock": 50 + } + """ + try: + data = request.json + + # Validate required fields + required_fields = ['name', 'category', 'price', 'current_stock'] + missing_fields = [f for f in required_fields if f not in data] + + if missing_fields: + return jsonify({ + 'status': 'error', + 'message': f'Missing fields: {", ".join(missing_fields)}', + 'required_fields': required_fields + }), 400 + + connection = get_db_connection() + if not connection: + return jsonify({'status': 'error', 'message': 'Database connection failed'}), 500 + + cursor = connection.cursor() + + # Check for duplicate product name + cursor.execute("SELECT id FROM products WHERE name = %s", (data['name'],)) + if cursor.fetchone(): + cursor.close() + connection.close() + return jsonify({ + 'status': 'error', + 'message': f'Product "{data["name"]}" already exists' + }), 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'] + )) + connection.commit() + + product_id = cursor.lastrowid + cursor.close() + connection.close() + + return jsonify({ + 'status': 'success', + 'product_id': product_id, + 'message': 'Product created successfully' + }), 201 + + except Exception as e: + logger.error(f"Create product error: {str(e)}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + + @app.route('/transactions', methods=['POST']) def save_transaction(): """