Files
Kifi/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart

812 lines
33 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../providers/product_categories_provider.dart';
import '../providers/commodity_rates_provider.dart';
import '../domain/commodity_rate.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/snackbar_service.dart';
class DailyRatesScreen extends ConsumerStatefulWidget {
const DailyRatesScreen({super.key});
@override
ConsumerState<DailyRatesScreen> createState() => _DailyRatesScreenState();
}
class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
final Map<String, TextEditingController> _rateControllers = {};
final Map<String, bool> _isSaving = {};
final Map<String, bool> _isSyncing = {};
final Map<String, String> _commodityLabels = {
'XAU': 'Gold (XAU)',
'XAG': 'Silver (XAG)',
'XPT': 'Platinum (XPT)',
'XPD': 'Palladium (XPD)',
'OTH': 'Other Commodity',
};
@override
void dispose() {
for (var controller in _rateControllers.values) {
controller.dispose();
}
super.dispose();
}
Color _getCommodityColor(String code, bool isDark) {
switch (code.toUpperCase()) {
case 'XAU':
return const Color(0xFFD4AF37); // Classic Gold
case 'XAG':
return const Color(0xFF8A9BA8); // Silver metallic
case 'XPT':
return const Color(0xFF00A896); // Platinum teal
case 'XPD':
return const Color(0xFF7209B7); // Palladium purple
default:
return const Color(0xFF3B82F6); // Modern Blue
}
}
IconData _getCommodityIcon(String code) {
switch (code.toUpperCase()) {
case 'XAU':
return LucideIcons.gem;
case 'XAG':
return LucideIcons.sparkles;
case 'XPT':
return LucideIcons.shieldCheck;
case 'XPD':
return LucideIcons.cpu;
default:
return LucideIcons.boxes;
}
}
Future<void> _saveRate(String commodityCode, String baseUnit) async {
final text = _rateControllers[commodityCode]?.text.trim();
if (text == null || text.isEmpty) {
SnackBarService.showError(context, 'Please enter a valid rate');
return;
}
final rate = double.tryParse(text);
if (rate == null || rate <= 0) {
SnackBarService.showError(context, 'Please enter a positive numeric rate');
return;
}
setState(() => _isSaving[commodityCode] = true);
try {
final name = _commodityLabels[commodityCode] ?? commodityCode;
await ref.read(commodityRatesProvider.notifier).addRate(
commodityCode,
rate,
commodityName: name,
source: 'MANUAL',
);
if (mounted) {
SnackBarService.showSuccess(context, '$name rate updated to ₹${NumberFormat('#,##,##0.00').format(rate)} / $baseUnit');
}
} catch (e) {
if (mounted) {
SnackBarService.showError(context, 'Failed to save rate: $e');
}
} finally {
if (mounted) {
setState(() => _isSaving[commodityCode] = false);
}
}
}
Future<void> _syncRates(String commodityCode) async {
setState(() => _isSyncing[commodityCode] = true);
try {
final count = await ref.read(commodityRatesProvider.notifier).syncRateToProducts(commodityCode);
if (mounted) {
SnackBarService.showSuccess(
context,
count > 0
? 'Successfully synced $commodityCode rates to $count products!'
: 'Rates synced. No products currently require rate updates.',
);
}
} catch (e) {
if (mounted) {
SnackBarService.showError(context, 'Failed to sync rates: $e');
}
} finally {
if (mounted) {
setState(() => _isSyncing[commodityCode] = false);
}
}
}
void _showHistorySheet(BuildContext context, String commodityCode, String baseUnit) {
final color = _getCommodityColor(commodityCode, Theme.of(context).brightness == Brightness.dark);
final label = _commodityLabels[commodityCode] ?? commodityCode;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) {
return DraggableScrollableSheet(
initialChildSize: 0.65,
minChildSize: 0.4,
maxChildSize: 0.9,
builder: (_, scrollController) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.2), blurRadius: 25, offset: const Offset(0, -5)),
],
),
child: Column(
children: [
// Handle
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 44,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// Title Header
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 16, 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(_getCommodityIcon(commodityCode), color: color, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$label History',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 2),
Text(
'Historical rate log per $baseUnit',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.pop(ctx),
),
],
),
),
const Divider(height: 1),
// History List
Expanded(
child: FutureBuilder<List<CommodityRateHistory>>(
future: ref.read(commodityRatesProvider.notifier).fetchRateHistory(commodityCode),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('Error loading history: ${snapshot.error}', style: const TextStyle(color: Colors.red)),
),
);
}
final list = snapshot.data ?? [];
if (list.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.history, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text('No rate history recorded yet', style: TextStyle(color: Colors.grey.shade600, fontWeight: FontWeight.w500)),
],
),
);
}
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.all(20),
itemCount: list.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, idx) {
final item = list[idx];
final isLatest = idx == 0;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isLatest ? color.withValues(alpha: 0.08) : Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isLatest ? color.withValues(alpha: 0.4) : Colors.grey.withValues(alpha: 0.15),
width: isLatest ? 1.5 : 1,
),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: isLatest ? color : Colors.grey.shade400,
shape: BoxShape.circle,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
DateFormat('dd MMM yyyy, hh:mm a').format(item.effectiveAt),
style: TextStyle(
fontSize: 13,
fontWeight: isLatest ? FontWeight.bold : FontWeight.w500,
color: isLatest ? null : Colors.grey.shade700,
),
),
if (isLatest) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(6),
),
child: const Text('LATEST', style: TextStyle(color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold)),
),
],
],
),
if (item.source != null && item.source!.isNotEmpty) ...[
const SizedBox(height: 2),
Text('Source: ${item.source}', style: TextStyle(fontSize: 11, color: Colors.grey.shade500)),
],
],
),
),
Text(
'${NumberFormat('#,##,##0.00').format(item.rate)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isLatest ? color : null,
),
),
],
),
);
},
);
},
),
),
],
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final categoriesState = ref.watch(productCategoriesProvider);
final commodityRatesState = ref.watch(commodityRatesProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? const Color(0xFF0F172A) : const Color(0xFFF8FAFC),
appBar: AppBar(
title: const Text(
'Daily Commodity Rates',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 19),
),
elevation: 0,
backgroundColor: isDark ? const Color(0xFF1E293B) : Colors.white,
foregroundColor: isDark ? Colors.white : const Color(0xFF0F172A),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(LucideIcons.refreshCw, size: 20),
tooltip: 'Refresh Rates',
onPressed: () {
ref.invalidate(productCategoriesProvider);
ref.invalidate(commodityRatesProvider);
},
),
],
),
body: categoriesState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(child: Text('Error loading categories: $err')),
data: (categories) {
// Leaf categories that have a commodity code assigned
final leafCommodities = categories.where((c) =>
!c.hasChild && c.commodityCode != null && c.commodityCode!.trim().isNotEmpty
).toList();
if (leafCommodities.isEmpty) {
return _buildEmptyState(isDark);
}
// Group leaf categories by commodity code
final Map<String, List<ProductCategory>> grouped = {};
for (var cat in leafCommodities) {
final code = cat.commodityCode!.trim().toUpperCase();
grouped.putIfAbsent(code, () => []).add(cat);
}
final commodityCodes = grouped.keys.toList()..sort();
final latestRates = commodityRatesState.value ?? [];
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(productCategoriesProvider);
ref.invalidate(commodityRatesProvider);
await Future.wait([
ref.read(productCategoriesProvider.future),
ref.read(commodityRatesProvider.future),
]);
},
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
// Info Banner
_buildInfoBanner(isDark, commodityCodes.length, leafCommodities.length),
const SizedBox(height: 16),
// Commodity Cards
...commodityCodes.map((code) {
final linkedCats = grouped[code]!;
final baseUnit = linkedCats.first.baseUnit;
// Find latest rate for this code
final rateEntry = latestRates.where((r) =>
r.commodityCode.toUpperCase() == code || (r.commodityName != null && r.commodityName!.toUpperCase() == code)
).firstOrNull;
final currentRate = rateEntry?.rate ?? 0.0;
// Initialize controller if not present
if (!_rateControllers.containsKey(code) || _rateControllers[code]!.text.isEmpty) {
_rateControllers[code] = TextEditingController(
text: currentRate > 0 ? currentRate.toStringAsFixed(2) : '',
);
}
return _buildCommodityCard(
context: context,
commodityCode: code,
linkedCategories: linkedCats,
currentRate: currentRate,
rateEntry: rateEntry,
baseUnit: baseUnit,
isDark: isDark,
);
}),
],
),
);
},
),
);
}
Widget _buildInfoBanner(bool isDark, int commodityCount, int categoryCount) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.black.withValues(alpha: 0.06)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(LucideIcons.trendingUp, color: AppTheme.primaryColor, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pricing Flow & Valuation',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 2),
Text(
'$commodityCount active commodities governing $categoryCount leaf product categories.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
),
],
),
);
}
Widget _buildCommodityCard({
required BuildContext context,
required String commodityCode,
required List<ProductCategory> linkedCategories,
required double currentRate,
required CommodityRateHistory? rateEntry,
required String baseUnit,
required bool isDark,
}) {
final color = _getCommodityColor(commodityCode, isDark);
final label = _commodityLabels[commodityCode] ?? commodityCode;
final controller = _rateControllers[commodityCode]!;
final isSaving = _isSaving[commodityCode] ?? false;
final isSyncing = _isSyncing[commodityCode] ?? false;
return Container(
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: color.withValues(alpha: isDark ? 0.35 : 0.25),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: isDark ? 0.15 : 0.08),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header banner
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Icon(_getCommodityIcon(commodityCode), color: color, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: -0.3,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
commodityCode,
style: TextStyle(
color: color,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 3),
Text(
rateEntry != null
? 'Updated ${DateFormat('dd MMM, hh:mm a').format(rateEntry.effectiveAt)}'
: 'No rate recorded yet',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
],
),
),
// History Button
IconButton.filledTonal(
onPressed: () => _showHistorySheet(context, commodityCode, baseUnit),
style: IconButton.styleFrom(
backgroundColor: isDark ? Colors.white10 : Colors.grey.shade100,
foregroundColor: isDark ? Colors.white : Colors.black87,
),
icon: const Icon(LucideIcons.history, size: 18),
tooltip: 'View History',
),
],
),
),
// Rate Display & Editor Box
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF0F172A) : const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: isDark ? Colors.white12 : Colors.black.withValues(alpha: 0.05)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'CURRENT RATE',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
color: Colors.grey.shade500,
),
),
Text(
currentRate > 0
? '${NumberFormat('#,##,##0.00').format(currentRate)} / $baseUnit'
: 'Not Set',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: currentRate > 0 ? color : Colors.grey.shade400,
),
),
],
),
const SizedBox(height: 12),
// Inline Updater Field & Save Button
Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E293B) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: isDark ? Colors.white24 : Colors.grey.shade300),
),
child: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
decoration: InputDecoration(
hintText: '0.00',
prefixIcon: const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text('', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
prefixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0),
suffixText: '/ $baseUnit',
suffixStyle: TextStyle(color: Colors.grey.shade500, fontSize: 12),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
border: InputBorder.none,
),
),
),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: isSaving ? null : () => _saveRate(commodityCode, baseUnit),
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 0,
),
child: isSaving
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.check, size: 16),
SizedBox(width: 6),
Text('Save', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
),
],
),
],
),
),
const SizedBox(height: 14),
// Linked Leaf Categories Section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'LINKED LEAF CATEGORIES',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
color: Colors.grey.shade500,
),
),
Text(
'${linkedCategories.length} linked',
style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: linkedCategories.map((cat) {
final purity = cat.purityFactor ?? 1.0;
final effectiveUnitRate = currentRate * purity;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF0F172A) : Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade300),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.tag, size: 12, color: color),
const SizedBox(width: 6),
Text(
cat.name,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${(purity * 100).toStringAsFixed(1)}% (₹${effectiveUnitRate.toStringAsFixed(0)}/g)',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: color),
),
),
],
),
);
}).toList(),
),
],
),
),
const SizedBox(height: 16),
// Sync to Products Action Bar
Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
decoration: BoxDecoration(
color: isDark ? Colors.black.withValues(alpha: 0.2) : Colors.grey.shade50,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(24)),
),
child: Row(
children: [
Expanded(
child: Text(
'Sync live rates to all products under this commodity:',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
),
OutlinedButton.icon(
onPressed: isSyncing ? null : () => _syncRates(commodityCode),
style: OutlinedButton.styleFrom(
foregroundColor: color,
side: BorderSide(color: color.withValues(alpha: 0.5)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
),
icon: isSyncing
? SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: color))
: Icon(LucideIcons.refreshCw, size: 14, color: color),
label: const Text('Sync Products', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
),
],
),
),
],
),
);
}
Widget _buildEmptyState(bool isDark) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFFD4AF37).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.coins, size: 56, color: Color(0xFFD4AF37)),
),
const SizedBox(height: 20),
const Text(
'No Commodity Categories Configured',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
'Assign a commodity code (e.g. Gold XAU, Silver XAG) and purity factor to your leaf categories in Category Management to track their live daily rates.',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
],
),
),
);
}
}