Files
Kifi/kifi-app/update_add_product.py

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)