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/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<ProductListScreen> {
String _selectedFilter = 'semua';
String _searchQuery = '';
bool _isLoading = true;
final List<Product> 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<Product> products = [];
@override
void initState() {
super.initState();
_loadProducts();
}
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,
),
);
}
}
}
List<Product> get filteredProducts {
List<Product> result = products;
@ -187,8 +171,8 @@ class _ProductListScreenState extends State<ProductListScreen> {
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<ProductListScreen> {
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<ProductListScreen> {
),
),
),
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>(
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,

View File

@ -46,45 +46,47 @@ class _TransactionScreenState extends State<TransactionScreen> {
@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<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
@ -429,7 +431,7 @@ class _TransactionScreenState extends State<TransactionScreen> {
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<TransactionScreen> {
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,

View File

@ -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<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
static Future<Map<String, dynamic>?> getProduct(int productId) async {
try {

View File

@ -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():
"""