482 lines
19 KiB
Python
482 lines
19 KiB
Python
import os
|
|
|
|
file_path = "/Users/maddy/Projects/Kifi/kifi-app/lib/features/inventory/presentation/add_product_screen.dart"
|
|
|
|
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 '../../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>();
|
|
|
|
// Basic
|
|
String _name = '';
|
|
String _sku = '';
|
|
String _hsnCode = '';
|
|
ProductCategory? _selectedCategory;
|
|
|
|
// Jewellery Properties
|
|
double _weight = 0; // Gross Weight
|
|
double _purityFactor = 1.0;
|
|
double _wastagePercentage = 0;
|
|
|
|
// Pricing
|
|
double _makingCharges = 0;
|
|
String _makingChargesType = 'FLAT'; // FLAT, PER_GRAM
|
|
double _gstRate = 3.0; // Default 3% for jewellery
|
|
|
|
// Inventory
|
|
bool _trackInventory = true;
|
|
|
|
// 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 ?? '';
|
|
_hsnCode = p.hsnCode ?? '';
|
|
_weight = p.weight ?? 0;
|
|
_gstRate = p.gstRate ?? 3.0;
|
|
_trackInventory = p.trackInventory;
|
|
_purityFactor = p.purityFactor ?? 1.0;
|
|
_makingCharges = p.makingCharges ?? 0.0;
|
|
_makingChargesType = p.makingChargesType ?? 'FLAT';
|
|
_wastagePercentage = p.wastagePercentage ?? 0.0;
|
|
|
|
if (p.imageIds.isNotEmpty) {
|
|
_existingImageIds.addAll(p.imageIds);
|
|
}
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final cats = ref.read(productCategoriesProvider).value ?? [];
|
|
if (cats.isNotEmpty && p.categoryId != null) {
|
|
setState(() {
|
|
_selectedCategory = cats.firstWhere((c) => c.id == p.categoryId, orElse: () => cats.first);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
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: _hsnCode.isNotEmpty ? _hsnCode : null,
|
|
sku: _sku.isNotEmpty ? _sku : null,
|
|
categoryId: _selectedCategory?.id,
|
|
gstRate: _gstRate,
|
|
weight: _weight,
|
|
purityFactor: _purityFactor,
|
|
makingCharges: _makingCharges,
|
|
makingChargesType: _makingChargesType,
|
|
wastagePercentage: _wastagePercentage,
|
|
trackInventory: _trackInventory,
|
|
);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
double get _netWeight {
|
|
final weightAfterWastage = _weight * (1 - (_wastagePercentage / 100));
|
|
return weightAfterWastage;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
|
appBar: AppBar(
|
|
title: Text(widget.product != null ? 'Edit Jewellery Product' : 'New Jewellery Product', style: const TextStyle(fontWeight: FontWeight.bold)),
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
),
|
|
body: Form(
|
|
key: _formKey,
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(24),
|
|
children: [
|
|
_buildSectionTitle('Basic Details'),
|
|
_buildPremiumTextField(
|
|
label: 'Product Name*',
|
|
initialValue: _name,
|
|
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
|
onChanged: (val) => _name = val,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildPremiumTextField(
|
|
label: 'SKU / HUID',
|
|
initialValue: _sku,
|
|
onChanged: (val) => _sku = val,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: _buildCategoryDropdown(),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
_buildSectionTitle('Metal & Weight'),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildPremiumTextField(
|
|
label: 'Gross Weight (g)*',
|
|
initialValue: _weight > 0 ? _weight.toString() : '',
|
|
keyboardType: TextInputType.number,
|
|
validator: (val) => val == null || double.tryParse(val) == null ? 'Invalid' : null,
|
|
onChanged: (val) {
|
|
_weight = double.tryParse(val) ?? 0;
|
|
setState(() {});
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: _buildPremiumTextField(
|
|
label: 'Purity Factor (e.g. 0.916)*',
|
|
initialValue: _purityFactor.toString(),
|
|
keyboardType: TextInputType.number,
|
|
validator: (val) => val == null || double.tryParse(val) == null ? 'Invalid' : null,
|
|
onChanged: (val) => _purityFactor = double.tryParse(val) ?? 1.0,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildPremiumTextField(
|
|
label: 'Wastage (%)',
|
|
initialValue: _wastagePercentage.toString(),
|
|
keyboardType: TextInputType.number,
|
|
onChanged: (val) {
|
|
_wastagePercentage = double.tryParse(val) ?? 0.0;
|
|
setState(() {});
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.blue.withOpacity(0.2)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Net Weight', style: TextStyle(fontSize: 12, color: Colors.blue.shade700)),
|
|
const SizedBox(height: 4),
|
|
Text('${_netWeight.toStringAsFixed(3)} g', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.blue)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
_buildSectionTitle('Pricing Details'),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildPremiumTextField(
|
|
label: 'Making Charges*',
|
|
initialValue: _makingCharges.toString(),
|
|
keyboardType: TextInputType.number,
|
|
validator: (val) => val == null || double.tryParse(val) == null ? 'Invalid' : null,
|
|
onChanged: (val) => _makingCharges = double.tryParse(val) ?? 0,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: DropdownButtonFormField<String>(
|
|
value: _makingChargesType,
|
|
decoration: InputDecoration(
|
|
labelText: 'Charge Type',
|
|
filled: true,
|
|
fillColor: Colors.grey.shade50,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)),
|
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)),
|
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)),
|
|
),
|
|
items: const [
|
|
DropdownMenuItem(value: 'FLAT', child: Text('Flat Amount')),
|
|
DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')),
|
|
],
|
|
onChanged: (val) => setState(() => _makingChargesType = val!),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildPremiumTextField(
|
|
label: 'HSN Code',
|
|
initialValue: _hsnCode,
|
|
onChanged: (val) => _hsnCode = val,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: _buildPremiumTextField(
|
|
label: 'GST Rate (%)',
|
|
initialValue: _gstRate.toString(),
|
|
keyboardType: TextInputType.number,
|
|
onChanged: (val) => _gstRate = double.tryParse(val) ?? 3.0,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
_buildSectionTitle('Media'),
|
|
_buildImagePicker(),
|
|
|
|
const SizedBox(height: 32),
|
|
SwitchListTile(
|
|
title: const Text('Track Inventory', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
subtitle: const Text('Manage stock levels for this item'),
|
|
value: _trackInventory,
|
|
onChanged: (val) => setState(() => _trackInventory = val),
|
|
activeColor: Theme.of(context).colorScheme.primary,
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
|
|
const SizedBox(height: 32),
|
|
ElevatedButton(
|
|
onPressed: _isSaving ? null : _save,
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
),
|
|
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)),
|
|
),
|
|
const SizedBox(height: 32),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionTitle(String title) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 16),
|
|
child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87)),
|
|
);
|
|
}
|
|
|
|
Widget _buildPremiumTextField({
|
|
required String label,
|
|
required String initialValue,
|
|
required Function(String) onChanged,
|
|
TextInputType? keyboardType,
|
|
String? Function(String?)? validator,
|
|
}) {
|
|
return TextFormField(
|
|
initialValue: initialValue,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
labelStyle: TextStyle(color: Colors.grey.shade600),
|
|
filled: true,
|
|
fillColor: Colors.grey.shade50,
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)),
|
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)),
|
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)),
|
|
errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Colors.red)),
|
|
),
|
|
keyboardType: keyboardType,
|
|
validator: validator,
|
|
onChanged: onChanged,
|
|
);
|
|
}
|
|
|
|
Widget _buildCategoryDropdown() {
|
|
final categoriesState = ref.watch(productCategoriesProvider);
|
|
return categoriesState.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, s) => Text('Error: $e'),
|
|
data: (categories) {
|
|
if (categories.isEmpty) return const Text('No categories available');
|
|
final items = categories.where((c) => !c.hasChild).map((c) => SmartSearchItem(id: c.id.toString(), label: c.name, subtitle: c.parentCategoryId != null ? 'Subcategory' : 'Root Category')).toList();
|
|
return SmartSearchDropdown(
|
|
label: 'Category*',
|
|
items: items,
|
|
initialValue: _selectedCategory?.id.toString(),
|
|
onChanged: (val) {
|
|
if (val != null) setState(() => _selectedCategory = categories.firstWhere((c) => c.id.toString() == val));
|
|
},
|
|
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildImagePicker() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
...List.generate(_existingImageIds.length, (index) {
|
|
final imageId = _existingImageIds[index];
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 12),
|
|
child: Stack(
|
|
children: [
|
|
GestureDetector(
|
|
onTap: () => _previewImage(index),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
width: 100, height: 100, color: Colors.grey.shade200,
|
|
child: _token == null ? const Icon(LucideIcons.image, color: Colors.grey)
|
|
: Image.network('${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content', fit: BoxFit.cover, headers: {'Authorization': 'Bearer $_token'},
|
|
errorBuilder: (ctx, err, trace) => const Icon(LucideIcons.imageOff, color: Colors.grey)),
|
|
),
|
|
),
|
|
),
|
|
Positioned(top: 4, right: 4, child: GestureDetector(onTap: () => setState(() => _existingImageIds.removeAt(index)), child: Container(padding: const EdgeInsets.all(4), decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle), child: const Icon(LucideIcons.x, color: Colors.white, size: 16)))),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
...List.generate(_images.length, (index) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 12),
|
|
child: Stack(
|
|
children: [
|
|
GestureDetector(onTap: () => _previewImage(_existingImageIds.length + index), child: ClipRRect(borderRadius: BorderRadius.circular(12), child: Image.file(File(_images[index].path), width: 100, height: 100, fit: BoxFit.cover))),
|
|
Positioned(top: 4, right: 4, child: GestureDetector(onTap: () => _removeImage(index), child: Container(padding: const EdgeInsets.all(4), decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle), child: const Icon(LucideIcons.x, color: Colors.white, size: 16)))),
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
if ((_images.length + _existingImageIds.length) < 4)
|
|
GestureDetector(
|
|
onTap: _pickImages,
|
|
child: Container(
|
|
width: 100, height: 100,
|
|
decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey.shade300, style: BorderStyle.solid)),
|
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(LucideIcons.plus, color: Colors.grey.shade600), const SizedBox(height: 4), Text('Add Photo', style: TextStyle(color: Colors.grey.shade600, fontSize: 12))]),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
"""
|
|
|
|
with open(file_path, "w") as f:
|
|
f.write(content)
|