bharat_erp/lib/core/utils/formatters.dart

226 lines
6.6 KiB
Dart

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<TextInputFormatter> 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(' ');
}