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 {
Product( try {
id: 2, final fetchedProducts = await MLService.getProducts();
name: 'Telur 1kg',
category: 'Telur', // Determine stock status based on quantity
price: 35000, String _getStatus(int stock) {
stock: 12, if (stock == 0) return 'kritis';
status: 'rendah', if (stock <= 5) return 'rendah';
), return 'tersedia';
Product( }
id: 3,
name: 'Gula Pasir 1kg', final productList = fetchedProducts.map((p) {
category: 'Gula', int stock = p['current_stock'] ?? 0;
price: 20000, return Product(
stock: 28, id: p['id'] ?? 0,
status: 'tersedia', name: p['name'] ?? '',
), category: p['category'] ?? '',
Product( price: p['price'] ?? 0,
id: 4, stock: stock,
name: 'Susu Bubuk', status: _getStatus(stock),
category: 'Susu', );
price: 45000, }).toList();
stock: 8,
status: 'kritis', setState(() {
), products = productList;
Product( _isLoading = false;
id: 5, });
name: 'Cokelat Bubuk 250gr', } catch (e) {
category: 'Cokelat', print('Error loading products: $e');
price: 35000, setState(() => _isLoading = false);
stock: 22, if (mounted) {
status: 'tersedia', ScaffoldMessenger.of(context).showSnackBar(
), SnackBar(
Product( content: const Text('Gagal memuat data produk'),
id: 6, backgroundColor: AppColors.statusError,
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,108 +234,127 @@ class _ProductListScreenState extends State<ProductListScreen> {
), ),
), ),
), ),
body: SingleChildScrollView( body: RefreshIndicator(
child: Column( onRefresh: _loadProducts,
children: [ color: AppColors.primaryBrown,
// Filter Chips Section (moved to body) child: _isLoading
Container( ? Center(
color: AppColors.bgWhite, child: Column(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), mainAxisAlignment: MainAxisAlignment.center,
child: SingleChildScrollView( children: const [
scrollDirection: Axis.horizontal, CircularProgressIndicator(
child: Row( valueColor: AlwaysStoppedAnimation<Color>(
children: [ Color(0xFF8B6E58),
'semua', ),
'tersedia', ),
'rendah', SizedBox(height: 16),
'kritis' Text('Memuat data produk...'),
] ],
.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(),
), ),
), )
), : SingleChildScrollView(
child: Column(
// Products List children: [
Padding( // Filter Chips Section (moved to body)
padding: const EdgeInsets.all(16), Container(
child: filteredProducts.isNotEmpty color: AppColors.bgWhite,
? Column( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: filteredProducts child: SingleChildScrollView(
.map((product) => _buildProductCard(product)) scrollDirection: Axis.horizontal,
.toList() child: Row(
.expand((card) =>
[card, const SizedBox(height: 12)])
.toList(),
)
: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 60),
child: Column(
children: [ children: [
Container( 'semua',
width: 80, 'tersedia',
height: 80, 'rendah',
decoration: BoxDecoration( 'kritis'
color: AppColors.grey200, ]
borderRadius: BorderRadius.circular(20), .map((filter) => Padding(
), padding: const EdgeInsets.only(right: 8),
child: Icon( child: FilterChip(
Icons.shopping_bag_outlined, label: Text(
size: 40, _capitalize(filter),
color: AppColors.textSecondary, style: AppTextStyles.labelSmall.copyWith(
), color: _selectedFilter == filter
), ? Colors.white
const SizedBox(height: 16), : AppColors.textSecondary,
Text( fontWeight: FontWeight.w600,
'Produk Tidak Ditemukan', ),
style: AppTextStyles.headlineSmall.copyWith( ),
color: AppColors.textPrimary, backgroundColor: _selectedFilter == filter
), ? AppColors.primaryBrown
), : AppColors.bgLight,
const SizedBox(height: 8), shape: RoundedRectangleBorder(
Text( borderRadius: BorderRadius.circular(8),
'Coba ubah filter atau cari dengan kata kunci lain', side: BorderSide(
style: AppTextStyles.bodySmall.copyWith( color: _selectedFilter == filter
color: AppColors.textSecondary, ? AppColors.primaryBrown
), : AppColors.grey200,
textAlign: TextAlign.center, 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( bottomNavigationBar: BottomNavigationBar(
currentIndex: 1, currentIndex: 1,

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,32 +469,74 @@ class _TransactionScreenState extends State<TransactionScreen> {
return; return;
} }
// Add new product // Frontend validation: check duplicate
setState(() { if (products.contains(productName)) {
products.add(productName); ScaffoldMessenger.of(context)
productCategories[productName] = .showSnackBar(
category; SnackBar(
productPrices[productName] = priceInt; content: Text(
'Produk "$productName" sudah ada'),
backgroundColor:
AppColors.statusError,
),
);
return;
}
// Add new category if created // Call API to create product
if (_createNewCategory && setStateDialog(() {
!categories.contains(category)) { // Show loading in dialog via disabling button
categories.add(category);
}
}); });
Navigator.pop(context); final result =
await MLService.createProduct(
// Show success message name: productName,
ScaffoldMessenger.of(context) category: category,
.showSnackBar( price: priceInt,
SnackBar( currentStock: 0, // Default stock 0
content: Text(
'Produk "$productName" berhasil ditambahkan'),
backgroundColor:
AppColors.statusSuccess,
),
); );
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( 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():
""" """