Done Category, Product Catalogue, Customer, Vendor, Purchase Invoice Module
This commit is contained in:
477
kifi-app/update_add_product.py
Normal file
477
kifi-app/update_add_product.py
Normal file
@@ -0,0 +1,477 @@
|
||||
import os
|
||||
|
||||
new_content = """import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../../../../core/widgets/smart_search_dropdown.dart';
|
||||
import '../domain/product.dart';
|
||||
import '../providers/products_provider.dart';
|
||||
import '../providers/product_categories_provider.dart';
|
||||
import '../../business/providers/business_provider.dart';
|
||||
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
|
||||
|
||||
class AddProductScreen extends ConsumerStatefulWidget {
|
||||
final Product? product;
|
||||
const AddProductScreen({super.key, this.product});
|
||||
|
||||
@override
|
||||
ConsumerState<AddProductScreen> createState() => _AddProductScreenState();
|
||||
}
|
||||
|
||||
class _AddProductScreenState extends ConsumerState<AddProductScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _hsnController = TextEditingController();
|
||||
final _gstController = TextEditingController();
|
||||
|
||||
// Basic
|
||||
String _name = '';
|
||||
String _sku = '';
|
||||
ProductCategory? _selectedCategory;
|
||||
int? _uomId;
|
||||
|
||||
// Properties
|
||||
String _color = '';
|
||||
|
||||
// Pricing & Inventory
|
||||
double _purchasePrice = 0;
|
||||
double _sellingPrice = 0;
|
||||
|
||||
// Media
|
||||
final List<XFile> _images = [];
|
||||
final List<int> _existingImageIds = [];
|
||||
bool _isSaving = false;
|
||||
String? _token;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
|
||||
if (mounted) setState(() => _token = val);
|
||||
});
|
||||
|
||||
if (widget.product != null) {
|
||||
final p = widget.product!;
|
||||
_name = p.name;
|
||||
_sku = p.sku ?? '';
|
||||
_hsnController.text = p.hsnCode ?? '';
|
||||
_uomId = p.uomId;
|
||||
_color = p.color ?? '';
|
||||
_gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : '';
|
||||
_sellingPrice = p.sellingPrice ?? 0.0;
|
||||
|
||||
if (p.imageIds.isNotEmpty) {
|
||||
_existingImageIds.addAll(p.imageIds);
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final cats = ref.read(productCategoriesProvider).value ?? [];
|
||||
if (cats.isNotEmpty) {
|
||||
setState(() {
|
||||
_selectedCategory = cats.firstWhere((c) => c.id == p.categoryId, orElse: () => cats.first);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hsnController.dispose();
|
||||
_gstController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImages() async {
|
||||
if ((_images.length + _existingImageIds.length) >= 4) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed')));
|
||||
return;
|
||||
}
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final List<XFile> picked = await picker.pickMultiImage();
|
||||
if (picked.isNotEmpty) {
|
||||
setState(() {
|
||||
_images.addAll(picked.take(4 - (_images.length + _existingImageIds.length)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _removeImage(int index) {
|
||||
setState(() {
|
||||
_images.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
void _previewImage(int index) {
|
||||
List<ImageProvider> allImages = [];
|
||||
for (final imageId in _existingImageIds) {
|
||||
if (_token != null) {
|
||||
allImages.add(NetworkImage('${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content', headers: {'Authorization': 'Bearer $_token'}));
|
||||
} else {
|
||||
allImages.add(const AssetImage('assets/images/placeholder.png'));
|
||||
}
|
||||
}
|
||||
for (final file in _images) {
|
||||
allImages.add(FileImage(File(file.path)));
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AttachmentGalleryScreen(
|
||||
images: allImages,
|
||||
initialIndex: index,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_formKey.currentState!.save();
|
||||
|
||||
if (_selectedCategory == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final product = Product(
|
||||
id: widget.product?.id,
|
||||
name: _name,
|
||||
hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text : null,
|
||||
sku: _sku.isNotEmpty ? _sku : null,
|
||||
categoryId: _selectedCategory?.id,
|
||||
uomId: _uomId,
|
||||
color: _color.isNotEmpty ? _color : null,
|
||||
sellingPrice: _sellingPrice,
|
||||
gstRate: double.tryParse(_gstController.text) ?? 0,
|
||||
priceCalcRule: 'MANUAL',
|
||||
autoCalculatePrice: false,
|
||||
);
|
||||
|
||||
if (widget.product != null) {
|
||||
await ref.read(productsProvider.notifier).updateProduct(widget.product!.id!, product, newImages: _images);
|
||||
} else {
|
||||
await ref.read(productsProvider.notifier).createProduct(product, images: _images);
|
||||
}
|
||||
if (mounted) Navigator.pop(context);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categoriesState = ref.watch(productCategoriesProvider);
|
||||
final taxInclusive = ref.watch(businessProfileProvider).value?.taxIncludedInPrice ?? false;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
title: Text(widget.product != null ? 'Edit Product' : 'New Product', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
),
|
||||
body: GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Basic Information', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildPremiumTextField(
|
||||
label: 'Product Name*',
|
||||
initialValue: _name,
|
||||
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
||||
onChanged: (val) => _name = val,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildPremiumTextField(
|
||||
label: 'SKU / Barcode',
|
||||
initialValue: _sku,
|
||||
onChanged: (val) => _sku = val,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const Text('Category & Unit', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
categoriesState.when(
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (err, stack) => Text('Error loading categories: $err'),
|
||||
data: (categories) {
|
||||
final leafCategories = categories.where((c) => !categories.any((child) => child.parentCategoryId == c.id)).toList();
|
||||
return leafCategories.isEmpty
|
||||
? const Text('No categories found. Manage categories in Business Hub.', style: TextStyle(color: Colors.orange))
|
||||
: SmartSearchDropdown<ProductCategory>(
|
||||
hintText: 'Select Category*',
|
||||
value: _selectedCategory,
|
||||
items: leafCategories,
|
||||
itemAsString: (c) => c.name,
|
||||
itemBuilder: (context, item) {
|
||||
List<String> path = [];
|
||||
ProductCategory? current = item;
|
||||
while (current != null) {
|
||||
path.insert(0, current.name);
|
||||
if (current.parentCategoryId != null) {
|
||||
current = categories.where((p) => p.id == current!.parentCategoryId).firstOrNull;
|
||||
} else {
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(path.join(' -> '), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_selectedCategory = val;
|
||||
if (val != null) {
|
||||
if (val.defaultHsn != null && val.defaultHsn!.isNotEmpty) {
|
||||
_hsnController.text = val.defaultHsn!;
|
||||
}
|
||||
if (val.defaultGst != null && val.defaultGst! > 0) {
|
||||
_gstController.text = val.defaultGst.toString();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
decoration: const InputDecoration(labelText: 'Unit of Measure (Optional)'),
|
||||
value: _uomId,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 1, child: Text('Grams (g)')),
|
||||
DropdownMenuItem(value: 2, child: Text('Kilograms (kg)')),
|
||||
DropdownMenuItem(value: 3, child: Text('Pieces (pcs)')),
|
||||
],
|
||||
onChanged: (val) => setState(() => _uomId = val),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
_buildPremiumTextField(
|
||||
label: taxInclusive ? 'Selling Price (Inc. Tax)*' : 'Selling Price (Exc. Tax)*',
|
||||
initialValue: _sellingPrice == 0 ? '' : _sellingPrice.toString(),
|
||||
prefixText: '₹ ',
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (val) => _sellingPrice = double.tryParse(val) ?? 0,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _hsnController,
|
||||
decoration: const InputDecoration(labelText: 'HSN Code'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _gstController,
|
||||
decoration: const InputDecoration(labelText: 'GST Rate (%)', suffixText: '%'),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 16),
|
||||
if (_images.isNotEmpty || _existingImageIds.isNotEmpty)
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, childAspectRatio: 1,
|
||||
),
|
||||
itemCount: _images.length + _existingImageIds.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < _existingImageIds.length) {
|
||||
final imageId = _existingImageIds[index];
|
||||
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content';
|
||||
return _buildImageThumbnail(
|
||||
isNetwork: true,
|
||||
url: imageUrl,
|
||||
onTap: () => _previewImage(index),
|
||||
onDelete: () async {
|
||||
try {
|
||||
setState(() => _isSaving = true);
|
||||
await DioClient().dio.delete('/inventory/products/images/$imageId');
|
||||
setState(() => _existingImageIds.removeAt(index));
|
||||
ref.invalidate(productsProvider);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
final localIndex = index - _existingImageIds.length;
|
||||
return _buildImageThumbnail(
|
||||
isNetwork: false,
|
||||
file: File(_images[localIndex].path),
|
||||
onTap: () => _previewImage(index),
|
||||
onDelete: () => _removeImage(localIndex),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if ((_images.length + _existingImageIds.length) < 4) ...[
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: _pickImages,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.05),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.3)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(LucideIcons.uploadCloud, size: 32, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Tap to Upload Images', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text('${4 - (_images.length + _existingImageIds.length)} slots remaining', style: const TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar() {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSaving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 4,
|
||||
),
|
||||
child: _isSaving
|
||||
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: Text(widget.product != null ? 'Update Product' : 'Publish Product', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageThumbnail({required bool isNetwork, String? url, File? file, required VoidCallback onDelete, required VoidCallback onTap}) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
color: Colors.grey[200],
|
||||
child: isNetwork
|
||||
? (_token == null
|
||||
? const Icon(LucideIcons.image, color: Colors.grey)
|
||||
: Image.network(
|
||||
url!,
|
||||
fit: BoxFit.cover,
|
||||
headers: {'Authorization': 'Bearer $_token'},
|
||||
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey),
|
||||
))
|
||||
: Image.file(file!, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: onDelete,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: const Icon(LucideIcons.x, color: Colors.white, size: 16),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPremiumTextField({
|
||||
required String label,
|
||||
String? initialValue,
|
||||
String? prefixText,
|
||||
String? suffixText,
|
||||
TextInputType? keyboardType,
|
||||
void Function(String)? onChanged,
|
||||
void Function(String?)? onSaved,
|
||||
String? Function(String?)? validator,
|
||||
}) {
|
||||
return TextFormField(
|
||||
initialValue: initialValue,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
prefixText: prefixText,
|
||||
suffixText: suffixText,
|
||||
),
|
||||
keyboardType: keyboardType,
|
||||
onChanged: onChanged,
|
||||
onSaved: onSaved,
|
||||
validator: validator,
|
||||
);
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
with open('lib/features/inventory/presentation/add_product_screen.dart', 'w') as f:
|
||||
f.write(new_content)
|
||||
Reference in New Issue
Block a user