Implement real-time product API synchronization

- 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
This commit is contained in:
rhanarmt 2026-04-20 23:25:20 +07:00
parent 9d7bd0c6cd
commit 244395c234
4 changed files with 385 additions and 229 deletions

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:finalproject/models/product_model.dart'; import 'package:finalproject/models/product_model.dart';
import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/colors.dart';
import 'package:finalproject/theme/text_styles.dart'; import 'package:finalproject/theme/text_styles.dart';
import 'package:finalproject/services/ml_service.dart';
class ProductListScreen extends StatefulWidget { class ProductListScreen extends StatefulWidget {
const ProductListScreen({Key? key}) : super(key: key); const ProductListScreen({Key? key}) : super(key: key);
@ -13,73 +14,56 @@ class ProductListScreen extends StatefulWidget {
class _ProductListScreenState extends State<ProductListScreen> { class _ProductListScreenState extends State<ProductListScreen> {
String _selectedFilter = 'semua'; String _selectedFilter = 'semua';
String _searchQuery = ''; String _searchQuery = '';
bool _isLoading = true;
final List<Product> products = [ late List<Product> products = [];
Product(
id: 1, @override
name: 'Tepung Terigu 1kg', void initState() {
category: 'Tepung', super.initState();
price: 15000, _loadProducts();
stock: 45, }
status: 'tersedia',
Future<void> _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,
), ),
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',
),
];
List<Product> get filteredProducts { List<Product> get filteredProducts {
List<Product> result = products; List<Product> result = products;
@ -187,8 +171,8 @@ class _ProductListScreenState extends State<ProductListScreen> {
const SizedBox(width: 12), const SizedBox(width: 12),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: const [ children: [
Text( const Text(
'Data Produk', 'Data Produk',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
@ -196,10 +180,10 @@ class _ProductListScreenState extends State<ProductListScreen> {
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'8 Produk', '${products.length} Produk',
style: TextStyle( style: const TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@ -250,7 +234,25 @@ class _ProductListScreenState extends State<ProductListScreen> {
), ),
), ),
), ),
body: SingleChildScrollView( body: RefreshIndicator(
onRefresh: _loadProducts,
color: AppColors.primaryBrown,
child: _isLoading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF8B6E58),
),
),
SizedBox(height: 16),
Text('Memuat data produk...'),
],
),
)
: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
// Filter Chips Section (moved to body) // Filter Chips Section (moved to body)
@ -353,6 +355,7 @@ class _ProductListScreenState extends State<ProductListScreen> {
], ],
), ),
), ),
),
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
currentIndex: 1, currentIndex: 1,
type: BottomNavigationBarType.fixed, type: BottomNavigationBarType.fixed,

View File

@ -46,45 +46,47 @@ class _TransactionScreenState extends State<TransactionScreen> {
@override @override
void initState() { void initState() {
super.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(); _quantityController = TextEditingController();
_selectedDate = DateTime.now(); _selectedDate = DateTime.now();
_loadProducts();
}
Future<void> _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 @override
@ -429,7 +431,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () async {
final productName = final productName =
newProductNameController.text.trim(); newProductNameController.text.trim();
final category = _createNewCategory final category = _createNewCategory
@ -467,14 +469,43 @@ class _TransactionScreenState extends State<TransactionScreen> {
return; return;
} }
// Add new product // 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(() { setState(() {
products.add(productName); products.add(productName);
productCategories[productName] = productCategories[productName] =
category; category;
productPrices[productName] = priceInt; productPrices[productName] = priceInt;
// Add new category if created
if (_createNewCategory && if (_createNewCategory &&
!categories.contains(category)) { !categories.contains(category)) {
categories.add(category); categories.add(category);
@ -493,6 +524,19 @@ class _TransactionScreenState extends State<TransactionScreen> {
AppColors.statusSuccess, 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( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBrown, backgroundColor: AppColors.primaryBrown,

View File

@ -5,7 +5,7 @@ class MLService {
// API URL - Change based on environment // API URL - Change based on environment
// Untuk emulator Android: 10.0.2.2 // Untuk emulator Android: 10.0.2.2
// Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local // 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; static const int timeoutSeconds = 30;
@ -209,6 +209,46 @@ class MLService {
} }
} }
/// Create new product in database
static Future<Map<String, dynamic>> 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 /// Get specific product by ID
static Future<Map<String, dynamic>?> getProduct(int productId) async { static Future<Map<String, dynamic>?> getProduct(int productId) async {
try { try {

View File

@ -299,6 +299,75 @@ def get_product(product_id):
return jsonify({'status': 'error', 'message': str(e)}), 500 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']) @app.route('/transactions', methods=['POST'])
def save_transaction(): def save_transaction():
""" """