362 lines
14 KiB
Dart
362 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
import '../providers/customers_provider.dart';
|
|
import '../domain/customer.dart';
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import '../../../core/network/dio_client.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
|
|
import '../../../core/widgets/premium_text_field.dart';
|
|
import '../../../core/widgets/smart_search_dropdown.dart';
|
|
import '../../business/providers/indian_states_provider.dart';
|
|
|
|
class AddCustomerSheet extends ConsumerStatefulWidget {
|
|
final Customer? customer;
|
|
|
|
const AddCustomerSheet({super.key, this.customer});
|
|
|
|
@override
|
|
ConsumerState<AddCustomerSheet> createState() => _AddCustomerSheetState();
|
|
}
|
|
|
|
class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
late TextEditingController _nameCtrl;
|
|
late TextEditingController _phoneCtrl;
|
|
late TextEditingController _emailCtrl;
|
|
late TextEditingController _addressCtrl;
|
|
late TextEditingController _gstinCtrl;
|
|
late TextEditingController _idNumberCtrl;
|
|
late TextEditingController _fatherNameCtrl;
|
|
late TextEditingController _ageCtrl;
|
|
String? _selectedGender;
|
|
int? _selectedStateId;
|
|
XFile? _photo;
|
|
String? _token;
|
|
bool _isLoading = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_nameCtrl = TextEditingController(text: widget.customer?.name ?? '');
|
|
_phoneCtrl = TextEditingController(text: widget.customer?.phone ?? '');
|
|
_emailCtrl = TextEditingController(text: widget.customer?.email ?? '');
|
|
_addressCtrl = TextEditingController(text: widget.customer?.address ?? '');
|
|
_gstinCtrl = TextEditingController(text: widget.customer?.gstin ?? '');
|
|
_idNumberCtrl = TextEditingController(text: widget.customer?.idNumber ?? '');
|
|
_fatherNameCtrl = TextEditingController(text: widget.customer?.fatherName ?? '');
|
|
_ageCtrl = TextEditingController(text: widget.customer?.age?.toString() ?? '');
|
|
_selectedGender = widget.customer?.gender;
|
|
_selectedStateId = widget.customer?.stateId;
|
|
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
|
|
if (mounted) setState(() => _token = val);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameCtrl.dispose();
|
|
_phoneCtrl.dispose();
|
|
_emailCtrl.dispose();
|
|
_addressCtrl.dispose();
|
|
_gstinCtrl.dispose();
|
|
_idNumberCtrl.dispose();
|
|
_fatherNameCtrl.dispose();
|
|
_ageCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _pickImage(ImageSource source) async {
|
|
final picker = ImagePicker();
|
|
final picked = await picker.pickImage(source: source, imageQuality: 80);
|
|
if (picked != null) {
|
|
setState(() => _photo = picked);
|
|
}
|
|
}
|
|
|
|
void _showImagePickerModal() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
|
builder: (ctx) => SafeArea(
|
|
child: Wrap(
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(LucideIcons.camera),
|
|
title: const Text('Take a photo'),
|
|
onTap: () {
|
|
Navigator.pop(ctx);
|
|
_pickImage(ImageSource.camera);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(LucideIcons.image),
|
|
title: const Text('Choose from gallery'),
|
|
onTap: () {
|
|
Navigator.pop(ctx);
|
|
_pickImage(ImageSource.gallery);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
final customer = Customer(
|
|
id: widget.customer?.id,
|
|
name: _nameCtrl.text,
|
|
phone: _phoneCtrl.text.isNotEmpty ? _phoneCtrl.text : null,
|
|
email: _emailCtrl.text.isNotEmpty ? _emailCtrl.text : null,
|
|
address: _addressCtrl.text.isNotEmpty ? _addressCtrl.text : null,
|
|
gstin: _gstinCtrl.text.isNotEmpty ? _gstinCtrl.text : null,
|
|
idNumber: _idNumberCtrl.text.isNotEmpty ? _idNumberCtrl.text : null,
|
|
fatherName: _fatherNameCtrl.text.isNotEmpty ? _fatherNameCtrl.text : null,
|
|
age: int.tryParse(_ageCtrl.text),
|
|
gender: _selectedGender,
|
|
stateId: _selectedStateId,
|
|
);
|
|
|
|
try {
|
|
if (widget.customer == null) {
|
|
await ref.read(customersProvider.notifier).addCustomer(customer, photo: _photo);
|
|
} else {
|
|
await ref.read(customersProvider.notifier).updateCustomer(widget.customer!.id!, customer, photo: _photo);
|
|
}
|
|
if (mounted) {
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(widget.customer == null ? 'Customer added' : 'Customer updated')));
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('Error'),
|
|
content: Text(e.toString()),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('OK'))
|
|
],
|
|
)
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
|
),
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
left: 24,
|
|
right: 24,
|
|
top: 48,
|
|
),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(LucideIcons.chevronLeft, size: 28),
|
|
padding: EdgeInsets.zero,
|
|
constraints: const BoxConstraints(),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(widget.customer == null ? "New Customer" : "Edit Customer", style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
Center(
|
|
child: GestureDetector(
|
|
onTap: _showImagePickerModal,
|
|
child: Stack(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 50,
|
|
backgroundColor: Colors.grey[200],
|
|
backgroundImage: _photo != null
|
|
? FileImage(File(_photo!.path)) as ImageProvider
|
|
: (widget.customer?.photoUrl != null && _token != null
|
|
? NetworkImage(
|
|
'${DioClient().dio.options.baseUrl}/customers/${widget.customer!.id}/photo/content',
|
|
headers: {'Authorization': 'Bearer $_token'},
|
|
)
|
|
: null),
|
|
child: _photo == null && widget.customer?.photoUrl == null
|
|
? const Icon(LucideIcons.user, size: 50, color: Colors.grey)
|
|
: null,
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
right: 0,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.blue,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(LucideIcons.camera, color: Colors.white, size: 16),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
PremiumTextField(
|
|
controller: _nameCtrl,
|
|
labelText: 'Customer Name *',
|
|
prefixIcon: const Icon(LucideIcons.user),
|
|
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
|
|
),
|
|
const SizedBox(height: 16),
|
|
PremiumTextField(
|
|
controller: _phoneCtrl,
|
|
labelText: 'Phone Number',
|
|
prefixIcon: const Icon(LucideIcons.phone),
|
|
keyboardType: TextInputType.phone,
|
|
),
|
|
const SizedBox(height: 16),
|
|
PremiumTextField(
|
|
controller: _fatherNameCtrl,
|
|
labelText: 'Father Name',
|
|
prefixIcon: const Icon(LucideIcons.users),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: PremiumTextField(
|
|
controller: _ageCtrl,
|
|
labelText: 'Age',
|
|
prefixIcon: const Icon(LucideIcons.calendar),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[100],
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: _selectedGender,
|
|
isExpanded: true,
|
|
hint: const Text('Gender'),
|
|
items: ['Male', 'Female', 'Other']
|
|
.map((g) => DropdownMenuItem(value: g, child: Text(g)))
|
|
.toList(),
|
|
onChanged: (val) => setState(() => _selectedGender = val),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
PremiumTextField(
|
|
controller: _emailCtrl,
|
|
labelText: 'Email Address',
|
|
prefixIcon: const Icon(LucideIcons.mail),
|
|
keyboardType: TextInputType.emailAddress,
|
|
),
|
|
const SizedBox(height: 16),
|
|
PremiumTextField(
|
|
controller: _gstinCtrl,
|
|
labelText: 'GSTIN (Optional)',
|
|
prefixIcon: const Icon(LucideIcons.building),
|
|
textCapitalization: TextCapitalization.characters,
|
|
validator: (val) {
|
|
if (val == null || val.isEmpty) return null; // Optional
|
|
final RegExp gstRegExp = RegExp(r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$');
|
|
if (!gstRegExp.hasMatch(val.toUpperCase())) {
|
|
return 'Invalid GSTIN format';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
PremiumTextField(
|
|
controller: _idNumberCtrl,
|
|
labelText: 'ID No. (Aadhar, License, etc.)',
|
|
prefixIcon: const Icon(LucideIcons.creditCard),
|
|
textCapitalization: TextCapitalization.characters,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Consumer(
|
|
builder: (context, ref, _) {
|
|
final statesState = ref.watch(indianStatesProvider);
|
|
return statesState.when(
|
|
data: (states) {
|
|
return SmartSearchDropdown<int>(
|
|
labelText: 'State',
|
|
hintText: 'Select State',
|
|
value: _selectedStateId,
|
|
fillColor: Colors.grey[100],
|
|
items: states.map((s) => s.id).toList(),
|
|
itemAsString: (id) {
|
|
final s = states.firstWhere((st) => st.id == id);
|
|
return '${s.name} (${s.gstCode})';
|
|
},
|
|
onChanged: (val) => setState(() => _selectedStateId = val),
|
|
);
|
|
},
|
|
loading: () => const CircularProgressIndicator(),
|
|
error: (e, stack) => Text('Error loading states: $e'),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
PremiumTextField(
|
|
controller: _addressCtrl,
|
|
labelText: 'Billing Address',
|
|
prefixIcon: const Icon(LucideIcons.mapPin),
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 24),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: _isLoading ? null : _save,
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
backgroundColor: Colors.blue,
|
|
foregroundColor: Colors.white,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
child: _isLoading
|
|
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
|
: Text(widget.customer == null ? 'Save Customer' : 'Update Customer'),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|