226 lines
6.6 KiB
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(' ');
|
|
}
|