478 lines
20 KiB
Python
478 lines
20 KiB
Python
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)
|