import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; class DateFormatter { DateFormatter._(); /// Application-wide display format: DD/MM/YYYY static const displayDatePattern = 'dd/MM/yyyy'; static const displayDateTimePattern = 'dd/MM/yyyy, hh:mm a'; static final _dateFormat = DateFormat(displayDatePattern); static final _dateTimeFormat = DateFormat(displayDateTimePattern); static final _apiDateFormat = DateFormat('yyyy-MM-dd'); static String displayDate(DateTime? date) { if (date == null) return '-'; return _dateFormat.format(date.toLocal()); } static String displayDateTime(DateTime? date) { if (date == null) return '-'; return _dateTimeFormat.format(date.toLocal()); } static String toApiDate(DateTime date) => _apiDateFormat.format(date); static DateTime? parseApiDate(String? value) { if (value == null || value.isEmpty) return null; return DateTime.tryParse(value); } static String formatUserLastLogin(DateTime? value) { if (value == null) return '—'; final local = value.toLocal(); final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final date = DateTime(local.year, local.month, local.day); final dayDiff = today.difference(date).inDays; final time = DateFormat('hh:mm a').format(local); if (dayDiff == 0) return 'Today $time'; if (dayDiff == 1) return 'Yesterday'; if (dayDiff < 7) return '$dayDiff days ago'; return displayDateTime(local); } /// Multiple date formats for client-side column search. static String searchableDate(DateTime? date) { if (date == null) return ''; final local = date.toLocal(); return [ displayDate(local), displayDateTime(local), formatUserLastLogin(local), DateFormat('yyyy-MM-dd').format(local), DateFormat('dd-MM-yyyy').format(local), DateFormat('dd/MM/yy').format(local), DateFormat('d/M/yyyy').format(local), DateFormat('d/MM/yyyy').format(local), ].join(' '); } } class CurrencyFormatter { CurrencyFormatter._(); static const locale = 'en_IN'; static final _formatter = NumberFormat.currency( locale: locale, symbol: '₹', decimalDigits: 2, ); static String format(double? amount) { if (amount == null) return '-'; return _formatter.format(amount); } /// Grouped amount for text fields (no currency symbol), e.g. `1,23,456.5`. static String formatEditable(num? amount, {int maxDecimals = 2}) { if (amount == null) return ''; final value = amount.toDouble(); if (value % 1 == 0) { return NumberFormat('#,##,##0', locale).format(value); } return NumberFormat( '#,##,##0.${'#' * maxDecimals}', locale, ).format(value); } /// Removes grouping commas so values can be parsed / sent to the API. static String stripGrouping(String value) => value.replaceAll(',', '').trim(); static double? tryParse(String? value) { if (value == null) return null; final cleaned = stripGrouping(value); if (cleaned.isEmpty || cleaned == '.' || cleaned == '-') return null; return double.tryParse(cleaned); } /// Formatted + raw numeric text for client-side column search. static String searchable(double? amount) { if (amount == null) return ''; return '${format(amount)} $amount'; } /// Input formatters for cost / amount fields (Indian comma grouping). static List get amountInput => const [ AmountThousandsSeparatorFormatter(), ]; } /// Formats numeric input with Indian-style thousand separators while typing. /// /// Example: `1234567.5` → `12,34,567.5` class AmountThousandsSeparatorFormatter extends TextInputFormatter { const AmountThousandsSeparatorFormatter({this.maxDecimals = 2}); final int maxDecimals; @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final raw = newValue.text; if (raw.isEmpty) { return newValue; } // Keep only digits and a single decimal point. final buffer = StringBuffer(); var seenDot = false; var decimals = 0; for (final rune in raw.runes) { final ch = String.fromCharCode(rune); if (ch == ',') continue; if (ch == '.') { if (seenDot) continue; seenDot = true; buffer.write(ch); continue; } if (ch.compareTo('0') >= 0 && ch.compareTo('9') <= 0) { if (seenDot) { if (decimals >= maxDecimals) continue; decimals++; } buffer.write(ch); } } final cleaned = buffer.toString(); if (cleaned.isEmpty) { return const TextEditingValue( text: '', selection: TextSelection.collapsed(offset: 0), ); } final parts = cleaned.split('.'); final intPart = parts[0]; final hasDot = cleaned.contains('.'); final fracPart = parts.length > 1 ? parts[1] : ''; final formattedInt = intPart.isEmpty ? '' : NumberFormat('#,##,##0', CurrencyFormatter.locale) .format(int.parse(intPart)); final formatted = hasDot ? '$formattedInt.$fracPart' : formattedInt; // Place caret at end; stable enough for amount entry. return TextEditingValue( text: formatted, selection: TextSelection.collapsed(offset: formatted.length), ); } } /// Converts `snake_case` / `kebab-case` keys into readable Title Case labels. String humanizeLabel(String key) { final cleaned = key.trim().replaceAll(RegExp(r'[_\-.]+'), ' '); if (cleaned.isEmpty) return key; const acronyms = { 'id': 'ID', 'amc': 'AMC', 'gst': 'GST', 'hsn': 'HSN', 'uom': 'UOM', 'po': 'PO', 'grn': 'GRN', 'url': 'URL', 'api': 'API', }; return cleaned .split(RegExp(r'\s+')) .where((part) => part.isNotEmpty) .map((part) { final lower = part.toLowerCase(); if (acronyms.containsKey(lower)) return acronyms[lower]!; return '${lower[0].toUpperCase()}${lower.substring(1)}'; }) .join(' '); } /// Subject used in dropdown hints (`Select …` / `Search …`). /// Strips required asterisks and title-cases each word. String dropdownHintLabel(String label) { final cleaned = label.replaceAll('*', '').trim().replaceAll(RegExp(r'\s+'), ' '); if (cleaned.isEmpty) return label.trim(); return cleaned.split(' ').map((word) { if (word.isEmpty) return word; // Keep short all-caps tokens (GST, UOM, PO…). if (word.length <= 4 && word == word.toUpperCase()) return word; final lower = word.toLowerCase(); return '${lower[0].toUpperCase()}${lower.substring(1)}'; }).join(' '); }