Initial commit of Doormile CRM Mobile with UI modernizations
This commit is contained in:
538
lib/screens/pricing_screen.dart
Normal file
538
lib/screens/pricing_screen.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/crm_api_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../utils/snackbar_utils.dart';
|
||||
|
||||
class PricingScreen extends StatefulWidget {
|
||||
final List<dynamic> pricing;
|
||||
final bool backendReady;
|
||||
final bool isActive;
|
||||
final VoidCallback onDataChanged;
|
||||
|
||||
const PricingScreen({
|
||||
super.key,
|
||||
required this.pricing,
|
||||
required this.backendReady,
|
||||
required this.onDataChanged,
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PricingScreen> createState() => _PricingScreenState();
|
||||
}
|
||||
|
||||
class _PricingScreenState extends State<PricingScreen> {
|
||||
final CrmApiService _api = CrmApiService();
|
||||
|
||||
bool _isSaving = false;
|
||||
|
||||
// Group pricing by company
|
||||
Map<String, List<dynamic>> get groupedPricing {
|
||||
final Map<String, List<dynamic>> map = {};
|
||||
for (var p in widget.pricing) {
|
||||
final company = p['company']?.toString() ?? 'Unknown';
|
||||
if (!map.containsKey(company)) {
|
||||
map[company] = [];
|
||||
}
|
||||
map[company]!.add(p);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
void _showAddPricingDialog([String? defaultProvider]) {
|
||||
_showPricingFormDialog(defaultProvider: defaultProvider);
|
||||
}
|
||||
|
||||
void _showEditPricingDialog(Map<String, dynamic> rateData) {
|
||||
_showPricingFormDialog(rateData: rateData);
|
||||
}
|
||||
|
||||
void _showPricingFormDialog({Map<String, dynamic>? rateData, String? defaultProvider}) {
|
||||
final isEditing = rateData != null;
|
||||
final companyCtrl = TextEditingController(text: rateData?['company']?.toString() ?? defaultProvider ?? '');
|
||||
final slabCtrl = TextEditingController(text: rateData?['weight_slab']?.toString() ?? '');
|
||||
final zoneCtrl = TextEditingController(text: rateData?['zone']?.toString().isNotEmpty == true ? rateData!['zone'] : rateData?['service_type'] ?? '');
|
||||
final rateCtrl = TextEditingController(text: rateData?['rate']?.toString() ?? '');
|
||||
final String id = rateData?['id']?.toString() ?? '';
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.blueLight,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: const BoxDecoration(color: Colors.white, shape: BoxShape.circle),
|
||||
child: Icon(isEditing ? Icons.edit_outlined : Icons.add_circle_outline, color: AppColors.primary, size: 24),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(isEditing ? 'Edit Rate' : 'Add New Rate', style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.darkBlue, fontSize: 18)),
|
||||
const SizedBox(height: 2),
|
||||
Text(isEditing ? 'Update pricing information' : 'Add to provider pricing slab', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Form Fields
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildModernTextField(
|
||||
controller: companyCtrl,
|
||||
label: 'Provider Name',
|
||||
hint: 'e.g. DTDC, Delhivery',
|
||||
icon: Icons.storefront_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: slabCtrl,
|
||||
label: 'Weight Slab',
|
||||
hint: 'e.g. 500g, 1kg-2kg',
|
||||
icon: Icons.scale_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: zoneCtrl,
|
||||
label: 'Zone / Service',
|
||||
hint: 'e.g. LOCAL, SOUTH',
|
||||
icon: Icons.place_outlined,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildModernTextField(
|
||||
controller: rateCtrl,
|
||||
label: 'Rate (₹)',
|
||||
hint: '0.00',
|
||||
icon: Icons.currency_rupee,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Actions
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (companyCtrl.text.isEmpty || slabCtrl.text.isEmpty || rateCtrl.text.isEmpty) {
|
||||
SnackbarUtils.showError(ctx, 'Please fill in required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
Navigator.pop(ctx);
|
||||
|
||||
final payload = {
|
||||
'company': companyCtrl.text,
|
||||
'weight_slab': slabCtrl.text,
|
||||
'zone': zoneCtrl.text,
|
||||
'service_type': '',
|
||||
'rate': rateCtrl.text,
|
||||
};
|
||||
|
||||
final success = isEditing
|
||||
? await _api.updatePricing(id, payload)
|
||||
: await _api.createPricing(payload);
|
||||
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
widget.onDataChanged();
|
||||
if (mounted) {
|
||||
SnackbarUtils.showSuccess(context, isEditing ? 'Rate updated!' : 'Rate added!');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
SnackbarUtils.showError(context, isEditing ? 'Update failed.' : 'Failed to add rate.');
|
||||
}
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: Text(isEditing ? 'Save Changes' : 'Add Rate', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModernTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required String hint,
|
||||
required IconData icon,
|
||||
TextInputType keyboardType = TextInputType.text,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.darkBlue, letterSpacing: 0.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.darkBlue, fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: TextStyle(color: AppColors.textSecondary.withValues(alpha: 0.5)),
|
||||
prefixIcon: Icon(icon, size: 18, color: AppColors.textSecondary),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF8F9FA),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _deletePricing(String id) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: AppColors.primary.withValues(alpha: 0.1), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.warning_amber_rounded, color: AppColors.primary, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Delete Pricing Rate?', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: AppColors.darkBlue)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('This action cannot be undone. Are you sure you want to delete this rate?', textAlign: TextAlign.center, style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
|
||||
child: const Text('Cancel', style: TextStyle(color: AppColors.textSecondary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Delete', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
final success = await _api.deletePricing(id);
|
||||
setState(() => _isSaving = false);
|
||||
|
||||
if (success) {
|
||||
widget.onDataChanged();
|
||||
if (mounted) {
|
||||
SnackbarUtils.showSuccess(context, 'Pricing rate deleted.');
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
SnackbarUtils.showError(context, 'Failed to delete pricing rate.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = groupedPricing;
|
||||
final companies = groups.keys.toList()..sort();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.scaffold,
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
||||
child: WideTopBanner(
|
||||
backendReady: widget.backendReady,
|
||||
subtitle: 'CARRIER PRICING',
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'All Providers',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.darkBlue,
|
||||
),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isSaving ? null : () => _showAddPricingDialog(),
|
||||
icon: const Icon(Icons.add, size: 18, color: Colors.white),
|
||||
label: const Text('Add Provider / Rate', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isSaving)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20.0),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
if (companies.isEmpty && !_isSaving)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No pricing data available.\nAdd a provider to get started.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final company = companies[index];
|
||||
final rates = groups[company] ?? [];
|
||||
|
||||
final zones = rates.map((r) => r['zone']?.toString().toUpperCase() ?? 'LOCAL').toSet().toList()..sort();
|
||||
final slabs = rates.map((r) => r['weight_slab']?.toString() ?? '').toSet().toList()..sort();
|
||||
final initial = company.isNotEmpty ? company.substring(0, 1).toUpperCase() : 'P';
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||
),
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
child: ExpansionTile(
|
||||
shape: const Border(),
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFDECEE),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initial,
|
||||
style: const TextStyle(color: AppColors.primary, fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
company,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.darkBlue, fontSize: 16),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, size: 14, color: AppColors.primary),
|
||||
const SizedBox(width: 4),
|
||||
Text('${rates.length} Registered Rates', style: const TextStyle(color: AppColors.textSecondary, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(16)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
headingRowColor: WidgetStateProperty.all(Colors.grey.shade50),
|
||||
dataRowMinHeight: 56,
|
||||
dataRowMaxHeight: 56,
|
||||
columnSpacing: 32,
|
||||
horizontalMargin: 24,
|
||||
columns: [
|
||||
const DataColumn(label: Text('WEIGHT SLAB', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.textSecondary))),
|
||||
...zones.map((z) => DataColumn(label: Text(z, style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w800, color: AppColors.textSecondary)))),
|
||||
],
|
||||
rows: slabs.map((slab) {
|
||||
return DataRow(
|
||||
cells: [
|
||||
DataCell(Text(slab, style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.darkBlue, fontSize: 13))),
|
||||
...zones.map((zone) {
|
||||
final matchingRate = rates.firstWhere(
|
||||
(r) => (r['weight_slab'] == slab) && ((r['zone']?.toString().toUpperCase() ?? 'LOCAL') == zone),
|
||||
orElse: () => null,
|
||||
);
|
||||
if (matchingRate == null) {
|
||||
return const DataCell(Center(child: Text('—', style: TextStyle(color: Colors.grey))));
|
||||
}
|
||||
return DataCell(
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Show options to edit or delete
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined, color: AppColors.textSecondary),
|
||||
title: const Text('Edit Rate'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_showEditPricingDialog(matchingRate as Map<String, dynamic>);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: AppColors.primary),
|
||||
title: const Text('Delete Rate', style: TextStyle(color: AppColors.primary)),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_deletePricing(matchingRate['id'].toString());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'₹${matchingRate['rate']}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800, color: AppColors.greenDark, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _showAddPricingDialog(company),
|
||||
icon: const Icon(Icons.add, size: 16, color: AppColors.primary),
|
||||
label: const Text('Add Rate', style: TextStyle(color: AppColors.primary, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: companies.length,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user