runable
This commit is contained in:
278
lib/core/utils/README_L10N.md
Normal file
278
lib/core/utils/README_L10N.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# Localization Extensions - Quick Start Guide
|
||||
|
||||
## Using Localization in the Worker App
|
||||
|
||||
This file demonstrates how to use the localization utilities in the Worker Flutter app.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### 1. Import the Extension
|
||||
|
||||
```dart
|
||||
import 'package:worker/core/utils/l10n_extensions.dart';
|
||||
```
|
||||
|
||||
### 2. Access Translations
|
||||
|
||||
```dart
|
||||
// In any widget with BuildContext
|
||||
class MyWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(context.l10n.home), // "Trang chủ" or "Home"
|
||||
Text(context.l10n.products), // "Sản phẩm" or "Products"
|
||||
Text(context.l10n.loyalty), // "Hội viên" or "Loyalty"
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### Date and Time
|
||||
|
||||
```dart
|
||||
// Format date
|
||||
final dateStr = L10nHelper.formatDate(context, DateTime.now());
|
||||
// Vietnamese: "17/10/2025"
|
||||
// English: "10/17/2025"
|
||||
|
||||
// Format date-time
|
||||
final dateTimeStr = L10nHelper.formatDateTime(context, DateTime.now());
|
||||
// Vietnamese: "17/10/2025 lúc 14:30"
|
||||
// English: "10/17/2025 at 14:30"
|
||||
|
||||
// Relative time
|
||||
final relativeTime = L10nHelper.formatRelativeTime(
|
||||
context,
|
||||
DateTime.now().subtract(Duration(minutes: 5)),
|
||||
);
|
||||
// Vietnamese: "5 phút trước"
|
||||
// English: "5 minutes ago"
|
||||
```
|
||||
|
||||
### Currency
|
||||
|
||||
```dart
|
||||
// Format Vietnamese Dong
|
||||
final price = L10nHelper.formatCurrency(context, 1500000);
|
||||
// Vietnamese: "1.500.000 ₫"
|
||||
// English: "1,500,000 ₫"
|
||||
```
|
||||
|
||||
### Status Helpers
|
||||
|
||||
```dart
|
||||
// Get localized order status
|
||||
final status = L10nHelper.getOrderStatus(context, 'pending');
|
||||
// Vietnamese: "Chờ xử lý"
|
||||
// English: "Pending"
|
||||
|
||||
// Get localized project status
|
||||
final projectStatus = L10nHelper.getProjectStatus(context, 'in_progress');
|
||||
// Vietnamese: "Đang thực hiện"
|
||||
// English: "In Progress"
|
||||
|
||||
// Get localized member tier
|
||||
final tier = L10nHelper.getMemberTier(context, 'diamond');
|
||||
// Vietnamese: "Kim cương"
|
||||
// English: "Diamond"
|
||||
|
||||
// Get localized user type
|
||||
final userType = L10nHelper.getUserType(context, 'contractor');
|
||||
// Vietnamese: "Thầu thợ"
|
||||
// English: "Contractor"
|
||||
```
|
||||
|
||||
### Counts with Pluralization
|
||||
|
||||
```dart
|
||||
// Format points with sign
|
||||
final points = L10nHelper.formatPoints(context, 100);
|
||||
// Vietnamese: "+100 điểm"
|
||||
// English: "+100 points"
|
||||
|
||||
// Format item count
|
||||
final items = L10nHelper.formatItemCount(context, 5);
|
||||
// Vietnamese: "5 sản phẩm"
|
||||
// English: "5 items"
|
||||
|
||||
// Format order count
|
||||
final orders = L10nHelper.formatOrderCount(context, 3);
|
||||
// Vietnamese: "3 đơn hàng"
|
||||
// English: "3 orders"
|
||||
|
||||
// Format project count
|
||||
final projects = L10nHelper.formatProjectCount(context, 2);
|
||||
// Vietnamese: "2 công trình"
|
||||
// English: "2 projects"
|
||||
|
||||
// Format days remaining
|
||||
final days = L10nHelper.formatDaysRemaining(context, 7);
|
||||
// Vietnamese: "Còn 7 ngày"
|
||||
// English: "7 days left"
|
||||
```
|
||||
|
||||
## Context Extensions
|
||||
|
||||
### Language Checks
|
||||
|
||||
```dart
|
||||
// Get current language code
|
||||
final languageCode = context.languageCode; // "vi" or "en"
|
||||
|
||||
// Check if Vietnamese
|
||||
if (context.isVietnamese) {
|
||||
// Do something specific for Vietnamese
|
||||
}
|
||||
|
||||
// Check if English
|
||||
if (context.isEnglish) {
|
||||
// Do something specific for English
|
||||
}
|
||||
```
|
||||
|
||||
## Parameterized Translations
|
||||
|
||||
```dart
|
||||
// Simple parameter
|
||||
final welcome = context.l10n.welcomeTo('Worker App');
|
||||
// Vietnamese: "Chào mừng đến với Worker App"
|
||||
// English: "Welcome to Worker App"
|
||||
|
||||
// Multiple parameters
|
||||
final message = context.l10n.pointsToNextTier(500, 'Platinum');
|
||||
// Vietnamese: "Còn 500 điểm để lên hạng Platinum"
|
||||
// English: "500 points to reach Platinum"
|
||||
|
||||
// Order number
|
||||
final orderNum = context.l10n.orderNumberIs('ORD-2024-001');
|
||||
// Vietnamese: "Số đơn hàng: ORD-2024-001"
|
||||
// English: "Order Number: ORD-2024-001"
|
||||
|
||||
// Redeem confirmation
|
||||
final confirm = context.l10n.redeemConfirmMessage(500, 'Gift Voucher');
|
||||
// Vietnamese: "Bạn có chắc chắn muốn đổi 500 điểm để nhận Gift Voucher?"
|
||||
// English: "Are you sure you want to redeem 500 points for Gift Voucher?"
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:worker/core/utils/l10n_extensions.dart';
|
||||
|
||||
class OrderDetailPage extends ConsumerWidget {
|
||||
final Order order;
|
||||
|
||||
const OrderDetailPage({required this.order});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.orderDetails),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Order number
|
||||
Text(
|
||||
context.l10n.orderNumberIs(order.orderNumber),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Order date
|
||||
Text(
|
||||
'${context.l10n.orderDate}: ${L10nHelper.formatDate(context, order.createdAt)}',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Order status
|
||||
Row(
|
||||
children: [
|
||||
Text(context.l10n.orderStatus + ': '),
|
||||
Chip(
|
||||
label: Text(
|
||||
L10nHelper.getOrderStatus(context, order.status),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Items count
|
||||
Text(
|
||||
L10nHelper.formatItemCount(context, order.items.length),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Total amount
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.total,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
L10nHelper.formatCurrency(context, order.total),
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Relative time
|
||||
Text(
|
||||
'${context.l10n.orderPlacedAt} ${L10nHelper.formatRelativeTime(context, order.createdAt)}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use `context.l10n` instead of `AppLocalizations.of(context)!`**
|
||||
- Shorter and cleaner
|
||||
- Consistent throughout the codebase
|
||||
|
||||
2. **Use helper functions for formatting**
|
||||
- `L10nHelper.formatCurrency()` instead of manual formatting
|
||||
- `L10nHelper.formatDate()` for locale-aware dates
|
||||
- `L10nHelper.getOrderStatus()` for localized status strings
|
||||
|
||||
3. **Check language when needed**
|
||||
- Use `context.isVietnamese` and `context.isEnglish`
|
||||
- Useful for conditional rendering or logic
|
||||
|
||||
4. **Never hard-code strings**
|
||||
- Always use translation keys
|
||||
- Supports both Vietnamese and English automatically
|
||||
|
||||
5. **Test both languages**
|
||||
- Switch device language to test
|
||||
- Verify text fits in UI for both languages
|
||||
|
||||
## See Also
|
||||
|
||||
- Full documentation: `/Users/ssg/project/worker/LOCALIZATION.md`
|
||||
- Vietnamese translations: `/Users/ssg/project/worker/lib/l10n/app_vi.arb`
|
||||
- English translations: `/Users/ssg/project/worker/lib/l10n/app_en.arb`
|
||||
- Helper source code: `/Users/ssg/project/worker/lib/core/utils/l10n_extensions.dart`
|
||||
471
lib/core/utils/extensions.dart
Normal file
471
lib/core/utils/extensions.dart
Normal file
@@ -0,0 +1,471 @@
|
||||
/// Dart Extension Methods
|
||||
///
|
||||
/// Provides useful extension methods for common data types
|
||||
/// used throughout the app.
|
||||
library;
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// ============================================================================
|
||||
// String Extensions
|
||||
// ============================================================================
|
||||
|
||||
extension StringExtensions on String {
|
||||
/// Check if string is null or empty
|
||||
bool get isNullOrEmpty => trim().isEmpty;
|
||||
|
||||
/// Check if string is not null and not empty
|
||||
bool get isNotNullOrEmpty => trim().isNotEmpty;
|
||||
|
||||
/// Capitalize first letter
|
||||
String get capitalize {
|
||||
if (isEmpty) return this;
|
||||
return '${this[0].toUpperCase()}${substring(1)}';
|
||||
}
|
||||
|
||||
/// Capitalize each word
|
||||
String get capitalizeWords {
|
||||
if (isEmpty) return this;
|
||||
return split(' ').map((word) => word.capitalize).join(' ');
|
||||
}
|
||||
|
||||
/// Convert to title case
|
||||
String get titleCase => capitalizeWords;
|
||||
|
||||
/// Remove all whitespace
|
||||
String get removeWhitespace => replaceAll(RegExp(r'\s+'), '');
|
||||
|
||||
/// Check if string is a valid email
|
||||
bool get isEmail {
|
||||
final emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
return emailRegex.hasMatch(this);
|
||||
}
|
||||
|
||||
/// Check if string is a valid Vietnamese phone number
|
||||
bool get isPhoneNumber {
|
||||
final phoneRegex = RegExp(r'^(0|\+?84)(3|5|7|8|9)[0-9]{8}$');
|
||||
return phoneRegex.hasMatch(replaceAll(RegExp(r'[^\d+]'), ''));
|
||||
}
|
||||
|
||||
/// Check if string is numeric
|
||||
bool get isNumeric {
|
||||
return double.tryParse(this) != null;
|
||||
}
|
||||
|
||||
/// Convert string to int (returns null if invalid)
|
||||
int? get toIntOrNull => int.tryParse(this);
|
||||
|
||||
/// Convert string to double (returns null if invalid)
|
||||
double? get toDoubleOrNull => double.tryParse(this);
|
||||
|
||||
/// Truncate string with ellipsis
|
||||
String truncate(int maxLength, {String ellipsis = '...'}) {
|
||||
if (length <= maxLength) return this;
|
||||
return '${substring(0, maxLength - ellipsis.length)}$ellipsis';
|
||||
}
|
||||
|
||||
/// Remove Vietnamese diacritics
|
||||
String get removeDiacritics {
|
||||
const withDiacritics =
|
||||
'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ';
|
||||
const withoutDiacritics =
|
||||
'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd';
|
||||
|
||||
var result = toLowerCase();
|
||||
for (var i = 0; i < withDiacritics.length; i++) {
|
||||
result = result.replaceAll(withDiacritics[i], withoutDiacritics[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Convert to URL-friendly slug
|
||||
String get slugify {
|
||||
var slug = removeDiacritics;
|
||||
slug = slug.toLowerCase();
|
||||
slug = slug.replaceAll(RegExp(r'[^\w\s-]'), '');
|
||||
slug = slug.replaceAll(RegExp(r'[-\s]+'), '-');
|
||||
return slug;
|
||||
}
|
||||
|
||||
/// Mask email (e.g., "j***@example.com")
|
||||
String get maskEmail {
|
||||
if (!isEmail) return this;
|
||||
final parts = split('@');
|
||||
final name = parts[0];
|
||||
final maskedName = name.length > 2
|
||||
? '${name[0]}${'*' * (name.length - 1)}'
|
||||
: name;
|
||||
return '$maskedName@${parts[1]}';
|
||||
}
|
||||
|
||||
/// Mask phone number (e.g., "0xxx xxx ***")
|
||||
String get maskPhone {
|
||||
final cleaned = replaceAll(RegExp(r'\D'), '');
|
||||
if (cleaned.length < 10) return this;
|
||||
return '${cleaned.substring(0, 4)} ${cleaned.substring(4, 7)} ***';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DateTime Extensions
|
||||
// ============================================================================
|
||||
|
||||
extension DateTimeExtensions on DateTime {
|
||||
/// Check if date is today
|
||||
bool get isToday {
|
||||
final now = DateTime.now();
|
||||
return year == now.year && month == now.month && day == now.day;
|
||||
}
|
||||
|
||||
/// Check if date is yesterday
|
||||
bool get isYesterday {
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
return year == yesterday.year &&
|
||||
month == yesterday.month &&
|
||||
day == yesterday.day;
|
||||
}
|
||||
|
||||
/// Check if date is tomorrow
|
||||
bool get isTomorrow {
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
return year == tomorrow.year &&
|
||||
month == tomorrow.month &&
|
||||
day == tomorrow.day;
|
||||
}
|
||||
|
||||
/// Check if date is in the past
|
||||
bool get isPast => isBefore(DateTime.now());
|
||||
|
||||
/// Check if date is in the future
|
||||
bool get isFuture => isAfter(DateTime.now());
|
||||
|
||||
/// Get start of day (00:00:00)
|
||||
DateTime get startOfDay => DateTime(year, month, day);
|
||||
|
||||
/// Get end of day (23:59:59)
|
||||
DateTime get endOfDay => DateTime(year, month, day, 23, 59, 59, 999);
|
||||
|
||||
/// Get start of month
|
||||
DateTime get startOfMonth => DateTime(year, month, 1);
|
||||
|
||||
/// Get end of month
|
||||
DateTime get endOfMonth => DateTime(year, month + 1, 0, 23, 59, 59, 999);
|
||||
|
||||
/// Get start of year
|
||||
DateTime get startOfYear => DateTime(year, 1, 1);
|
||||
|
||||
/// Get end of year
|
||||
DateTime get endOfYear => DateTime(year, 12, 31, 23, 59, 59, 999);
|
||||
|
||||
/// Add days
|
||||
DateTime addDays(int days) => add(Duration(days: days));
|
||||
|
||||
/// Subtract days
|
||||
DateTime subtractDays(int days) => subtract(Duration(days: days));
|
||||
|
||||
/// Add months
|
||||
DateTime addMonths(int months) => DateTime(year, month + months, day);
|
||||
|
||||
/// Subtract months
|
||||
DateTime subtractMonths(int months) => DateTime(year, month - months, day);
|
||||
|
||||
/// Add years
|
||||
DateTime addYears(int years) => DateTime(year + years, month, day);
|
||||
|
||||
/// Subtract years
|
||||
DateTime subtractYears(int years) => DateTime(year - years, month, day);
|
||||
|
||||
/// Get age in years from this date
|
||||
int get ageInYears {
|
||||
final today = DateTime.now();
|
||||
var age = today.year - year;
|
||||
if (today.month < month || (today.month == month && today.day < day)) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
/// Get difference in days from now
|
||||
int get daysFromNow => DateTime.now().difference(this).inDays;
|
||||
|
||||
/// Get difference in hours from now
|
||||
int get hoursFromNow => DateTime.now().difference(this).inHours;
|
||||
|
||||
/// Get difference in minutes from now
|
||||
int get minutesFromNow => DateTime.now().difference(this).inMinutes;
|
||||
|
||||
/// Copy with new values
|
||||
DateTime copyWith({
|
||||
int? year,
|
||||
int? month,
|
||||
int? day,
|
||||
int? hour,
|
||||
int? minute,
|
||||
int? second,
|
||||
int? millisecond,
|
||||
int? microsecond,
|
||||
}) {
|
||||
return DateTime(
|
||||
year ?? this.year,
|
||||
month ?? this.month,
|
||||
day ?? this.day,
|
||||
hour ?? this.hour,
|
||||
minute ?? this.minute,
|
||||
second ?? this.second,
|
||||
millisecond ?? this.millisecond,
|
||||
microsecond ?? this.microsecond,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Duration Extensions
|
||||
// ============================================================================
|
||||
|
||||
extension DurationExtensions on Duration {
|
||||
/// Format duration as readable string (e.g., "2 giờ 30 phút")
|
||||
String get formatted {
|
||||
final hours = inHours;
|
||||
final minutes = inMinutes.remainder(60);
|
||||
final seconds = inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
if (minutes > 0) {
|
||||
return '$hours giờ $minutes phút';
|
||||
}
|
||||
return '$hours giờ';
|
||||
} else if (minutes > 0) {
|
||||
if (seconds > 0) {
|
||||
return '$minutes phút $seconds giây';
|
||||
}
|
||||
return '$minutes phút';
|
||||
} else {
|
||||
return '$seconds giây';
|
||||
}
|
||||
}
|
||||
|
||||
/// Format as HH:MM:SS
|
||||
String get hhmmss {
|
||||
final hours = inHours;
|
||||
final minutes = inMinutes.remainder(60);
|
||||
final seconds = inSeconds.remainder(60);
|
||||
|
||||
return '${hours.toString().padLeft(2, '0')}:'
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Format as MM:SS
|
||||
String get mmss {
|
||||
final minutes = inMinutes.remainder(60);
|
||||
final seconds = inSeconds.remainder(60);
|
||||
|
||||
return '${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// List Extensions
|
||||
// ============================================================================
|
||||
|
||||
extension ListExtensions<T> on List<T> {
|
||||
/// Get first element or null if list is empty
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
|
||||
/// Get last element or null if list is empty
|
||||
T? get lastOrNull => isEmpty ? null : last;
|
||||
|
||||
/// Get element at index or null if out of bounds
|
||||
T? elementAtOrNull(int index) {
|
||||
if (index < 0 || index >= length) return null;
|
||||
return this[index];
|
||||
}
|
||||
|
||||
/// Group list by key
|
||||
Map<K, List<T>> groupBy<K>(K Function(T) keySelector) {
|
||||
final map = <K, List<T>>{};
|
||||
for (final element in this) {
|
||||
final key = keySelector(element);
|
||||
if (!map.containsKey(key)) {
|
||||
map[key] = [];
|
||||
}
|
||||
map[key]!.add(element);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// Get distinct elements
|
||||
List<T> get distinct => toSet().toList();
|
||||
|
||||
/// Get distinct elements by key
|
||||
List<T> distinctBy<K>(K Function(T) keySelector) {
|
||||
final seen = <K>{};
|
||||
return where((element) => seen.add(keySelector(element))).toList();
|
||||
}
|
||||
|
||||
/// Chunk list into smaller lists of specified size
|
||||
List<List<T>> chunk(int size) {
|
||||
final chunks = <List<T>>[];
|
||||
for (var i = 0; i < length; i += size) {
|
||||
chunks.add(sublist(i, (i + size) > length ? length : (i + size)));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Map Extensions
|
||||
// ============================================================================
|
||||
|
||||
extension MapExtensions<K, V> on Map<K, V> {
|
||||
/// Get value or default if key doesn't exist
|
||||
V getOrDefault(K key, V defaultValue) {
|
||||
return containsKey(key) ? this[key] as V : defaultValue;
|
||||
}
|
||||
|
||||
/// Get value or null if key doesn't exist
|
||||
V? getOrNull(K key) {
|
||||
return containsKey(key) ? this[key] : null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BuildContext Extensions
|
||||
// ============================================================================
|
||||
|
||||
extension BuildContextExtensions on BuildContext {
|
||||
/// Get screen size
|
||||
Size get screenSize => MediaQuery.of(this).size;
|
||||
|
||||
/// Get screen width
|
||||
double get screenWidth => MediaQuery.of(this).size.width;
|
||||
|
||||
/// Get screen height
|
||||
double get screenHeight => MediaQuery.of(this).size.height;
|
||||
|
||||
/// Check if screen is small (<600dp)
|
||||
bool get isSmallScreen => MediaQuery.of(this).size.width < 600;
|
||||
|
||||
/// Check if screen is medium (600-960dp)
|
||||
bool get isMediumScreen {
|
||||
final width = MediaQuery.of(this).size.width;
|
||||
return width >= 600 && width < 960;
|
||||
}
|
||||
|
||||
/// Check if screen is large (>=960dp)
|
||||
bool get isLargeScreen => MediaQuery.of(this).size.width >= 960;
|
||||
|
||||
/// Get theme
|
||||
ThemeData get theme => Theme.of(this);
|
||||
|
||||
/// Get text theme
|
||||
TextTheme get textTheme => Theme.of(this).textTheme;
|
||||
|
||||
/// Get color scheme
|
||||
ColorScheme get colorScheme => Theme.of(this).colorScheme;
|
||||
|
||||
/// Get primary color
|
||||
Color get primaryColor => Theme.of(this).primaryColor;
|
||||
|
||||
/// Check if dark mode is enabled
|
||||
bool get isDarkMode => Theme.of(this).brightness == Brightness.dark;
|
||||
|
||||
/// Get safe area padding
|
||||
EdgeInsets get safeAreaPadding => MediaQuery.of(this).padding;
|
||||
|
||||
/// Get bottom safe area padding (for devices with notch)
|
||||
double get bottomSafeArea => MediaQuery.of(this).padding.bottom;
|
||||
|
||||
/// Get top safe area padding (for status bar)
|
||||
double get topSafeArea => MediaQuery.of(this).padding.top;
|
||||
|
||||
/// Show snackbar
|
||||
void showSnackBar(String message, {Duration? duration}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
duration: duration ?? const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show error snackbar
|
||||
void showErrorSnackBar(String message, {Duration? duration}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: colorScheme.error,
|
||||
duration: duration ?? const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show success snackbar
|
||||
void showSuccessSnackBar(String message, {Duration? duration}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: Colors.green,
|
||||
duration: duration ?? const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Hide keyboard
|
||||
void hideKeyboard() {
|
||||
FocusScope.of(this).unfocus();
|
||||
}
|
||||
|
||||
/// Navigate to route
|
||||
Future<T?> push<T>(Widget page) {
|
||||
return Navigator.of(this).push<T>(
|
||||
MaterialPageRoute(builder: (_) => page),
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate and replace current route
|
||||
Future<T?> pushReplacement<T>(Widget page) {
|
||||
return Navigator.of(this).pushReplacement<T, void>(
|
||||
MaterialPageRoute(builder: (_) => page),
|
||||
);
|
||||
}
|
||||
|
||||
/// Pop current route
|
||||
void pop<T>([T? result]) {
|
||||
Navigator.of(this).pop(result);
|
||||
}
|
||||
|
||||
/// Pop until first route
|
||||
void popUntilFirst() {
|
||||
Navigator.of(this).popUntil((route) => route.isFirst);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Num Extensions
|
||||
// ============================================================================
|
||||
|
||||
extension NumExtensions on num {
|
||||
/// Check if number is positive
|
||||
bool get isPositive => this > 0;
|
||||
|
||||
/// Check if number is negative
|
||||
bool get isNegative => this < 0;
|
||||
|
||||
/// Check if number is zero
|
||||
bool get isZero => this == 0;
|
||||
|
||||
/// Clamp number between min and max
|
||||
num clampTo(num min, num max) => clamp(min, max);
|
||||
|
||||
/// Round to specified decimal places
|
||||
double roundToDecimal(int places) {
|
||||
final mod = math.pow(10.0, places);
|
||||
return ((this * mod).round().toDouble() / mod);
|
||||
}
|
||||
}
|
||||
371
lib/core/utils/formatters.dart
Normal file
371
lib/core/utils/formatters.dart
Normal file
@@ -0,0 +1,371 @@
|
||||
/// Data Formatters for Vietnamese Locale
|
||||
///
|
||||
/// Provides formatting utilities for currency, dates, phone numbers,
|
||||
/// and other data types commonly used in the app.
|
||||
library;
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Currency formatter for Vietnamese Dong (VND)
|
||||
class CurrencyFormatter {
|
||||
CurrencyFormatter._();
|
||||
|
||||
/// Format amount as Vietnamese currency (e.g., "100,000 ₫")
|
||||
static String format(double amount, {bool showSymbol = true}) {
|
||||
final formatter = NumberFormat.currency(
|
||||
locale: 'vi_VN',
|
||||
symbol: showSymbol ? '₫' : '',
|
||||
decimalDigits: 0,
|
||||
);
|
||||
return formatter.format(amount);
|
||||
}
|
||||
|
||||
/// Format amount with custom precision
|
||||
static String formatWithDecimals(
|
||||
double amount, {
|
||||
int decimalDigits = 2,
|
||||
bool showSymbol = true,
|
||||
}) {
|
||||
final formatter = NumberFormat.currency(
|
||||
locale: 'vi_VN',
|
||||
symbol: showSymbol ? '₫' : '',
|
||||
decimalDigits: decimalDigits,
|
||||
);
|
||||
return formatter.format(amount);
|
||||
}
|
||||
|
||||
/// Format as compact currency (e.g., "1.5M ₫")
|
||||
static String formatCompact(double amount, {bool showSymbol = true}) {
|
||||
final formatter = NumberFormat.compactCurrency(
|
||||
locale: 'vi_VN',
|
||||
symbol: showSymbol ? '₫' : '',
|
||||
decimalDigits: 1,
|
||||
);
|
||||
return formatter.format(amount);
|
||||
}
|
||||
|
||||
/// Parse currency string to double
|
||||
static double? parse(String value) {
|
||||
try {
|
||||
// Remove currency symbol and spaces
|
||||
final cleaned = value.replaceAll(RegExp(r'[₫\s,]'), '');
|
||||
return double.tryParse(cleaned);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Date and time formatter
|
||||
class DateFormatter {
|
||||
DateFormatter._();
|
||||
|
||||
/// Format date as "dd/MM/yyyy" (Vietnamese format)
|
||||
static String formatDate(DateTime date) {
|
||||
final formatter = DateFormat('dd/MM/yyyy', 'vi_VN');
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
/// Format date as "dd-MM-yyyy"
|
||||
static String formatDateDash(DateTime date) {
|
||||
final formatter = DateFormat('dd-MM-yyyy', 'vi_VN');
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
/// Format time as "HH:mm"
|
||||
static String formatTime(DateTime date) {
|
||||
final formatter = DateFormat('HH:mm', 'vi_VN');
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
/// Format date and time as "dd/MM/yyyy HH:mm"
|
||||
static String formatDateTime(DateTime date) {
|
||||
final formatter = DateFormat('dd/MM/yyyy HH:mm', 'vi_VN');
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
/// Format date as "dd/MM/yyyy lúc HH:mm"
|
||||
static String formatDateTimeVN(DateTime date) {
|
||||
final formatter = DateFormat('dd/MM/yyyy', 'vi_VN');
|
||||
final timeFormatter = DateFormat('HH:mm', 'vi_VN');
|
||||
return '${formatter.format(date)} lúc ${timeFormatter.format(date)}';
|
||||
}
|
||||
|
||||
/// Format as relative time (e.g., "2 giờ trước")
|
||||
static String formatRelative(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(date);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return 'Vừa xong';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes} phút trước';
|
||||
} else if (difference.inHours < 24) {
|
||||
return '${difference.inHours} giờ trước';
|
||||
} else if (difference.inDays < 7) {
|
||||
return '${difference.inDays} ngày trước';
|
||||
} else if (difference.inDays < 30) {
|
||||
final weeks = (difference.inDays / 7).floor();
|
||||
return '$weeks tuần trước';
|
||||
} else if (difference.inDays < 365) {
|
||||
final months = (difference.inDays / 30).floor();
|
||||
return '$months tháng trước';
|
||||
} else {
|
||||
final years = (difference.inDays / 365).floor();
|
||||
return '$years năm trước';
|
||||
}
|
||||
}
|
||||
|
||||
/// Format as day of week (e.g., "Thứ Hai")
|
||||
static String formatDayOfWeek(DateTime date) {
|
||||
final formatter = DateFormat('EEEE', 'vi_VN');
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
/// Format as month and year (e.g., "Tháng 10 năm 2024")
|
||||
static String formatMonthYear(DateTime date) {
|
||||
final formatter = DateFormat('MMMM yyyy', 'vi_VN');
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
/// Format as full date with day of week (e.g., "Thứ Hai, 17/10/2024")
|
||||
static String formatFullDate(DateTime date) {
|
||||
final dayOfWeek = formatDayOfWeek(date);
|
||||
final dateStr = formatDate(date);
|
||||
return '$dayOfWeek, $dateStr';
|
||||
}
|
||||
|
||||
/// Parse date string in format "dd/MM/yyyy"
|
||||
static DateTime? parseDate(String dateStr) {
|
||||
try {
|
||||
final formatter = DateFormat('dd/MM/yyyy');
|
||||
return formatter.parse(dateStr);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse datetime string in format "dd/MM/yyyy HH:mm"
|
||||
static DateTime? parseDateTime(String dateTimeStr) {
|
||||
try {
|
||||
final formatter = DateFormat('dd/MM/yyyy HH:mm');
|
||||
return formatter.parse(dateTimeStr);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phone number formatter for Vietnamese phone numbers
|
||||
class PhoneFormatter {
|
||||
PhoneFormatter._();
|
||||
|
||||
/// Format phone number as "(0xxx) xxx xxx"
|
||||
static String format(String phone) {
|
||||
// Remove all non-digit characters
|
||||
final cleaned = phone.replaceAll(RegExp(r'\D'), '');
|
||||
|
||||
if (cleaned.isEmpty) return '';
|
||||
|
||||
// Handle Vietnamese phone number formats
|
||||
if (cleaned.startsWith('84')) {
|
||||
// +84 format
|
||||
final local = cleaned.substring(2);
|
||||
if (local.length >= 9) {
|
||||
return '(+84${local.substring(0, 2)}) ${local.substring(2, 5)} ${local.substring(5)}';
|
||||
}
|
||||
} else if (cleaned.startsWith('0')) {
|
||||
// 0xxx format
|
||||
if (cleaned.length >= 10) {
|
||||
return '(${cleaned.substring(0, 4)}) ${cleaned.substring(4, 7)} ${cleaned.substring(7)}';
|
||||
}
|
||||
}
|
||||
|
||||
return phone; // Return original if format doesn't match
|
||||
}
|
||||
|
||||
/// Format as international number (+84xxx xxx xxx)
|
||||
static String formatInternational(String phone) {
|
||||
final cleaned = phone.replaceAll(RegExp(r'\D'), '');
|
||||
|
||||
if (cleaned.isEmpty) return '';
|
||||
|
||||
if (cleaned.startsWith('0')) {
|
||||
// Convert 0xxx to +84xxx
|
||||
final local = cleaned.substring(1);
|
||||
if (local.length >= 9) {
|
||||
return '+84${local.substring(0, 2)} ${local.substring(2, 5)} ${local.substring(5)}';
|
||||
}
|
||||
} else if (cleaned.startsWith('84')) {
|
||||
final local = cleaned.substring(2);
|
||||
if (local.length >= 9) {
|
||||
return '+84${local.substring(0, 2)} ${local.substring(2, 5)} ${local.substring(5)}';
|
||||
}
|
||||
}
|
||||
|
||||
return phone;
|
||||
}
|
||||
|
||||
/// Remove formatting to get clean phone number
|
||||
static String clean(String phone) {
|
||||
return phone.replaceAll(RegExp(r'\D'), '');
|
||||
}
|
||||
|
||||
/// Convert to E.164 format (+84xxxxxxxxx)
|
||||
static String toE164(String phone) {
|
||||
final cleaned = clean(phone);
|
||||
|
||||
if (cleaned.startsWith('0')) {
|
||||
return '+84${cleaned.substring(1)}';
|
||||
} else if (cleaned.startsWith('84')) {
|
||||
return '+$cleaned';
|
||||
} else if (cleaned.startsWith('+84')) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
return '+84$cleaned';
|
||||
}
|
||||
|
||||
/// Mask phone number (e.g., "0xxx xxx ***")
|
||||
static String mask(String phone) {
|
||||
final cleaned = clean(phone);
|
||||
|
||||
if (cleaned.length >= 10) {
|
||||
return '${cleaned.substring(0, 4)} ${cleaned.substring(4, 7)} ***';
|
||||
}
|
||||
|
||||
return phone;
|
||||
}
|
||||
}
|
||||
|
||||
/// Number formatter
|
||||
class NumberFormatter {
|
||||
NumberFormatter._();
|
||||
|
||||
/// Format number with thousand separators
|
||||
static String format(num number, {int decimalDigits = 0}) {
|
||||
final formatter = NumberFormat('#,###', 'vi_VN');
|
||||
if (decimalDigits > 0) {
|
||||
return formatter.format(number);
|
||||
}
|
||||
return formatter.format(number.round());
|
||||
}
|
||||
|
||||
/// Format as percentage
|
||||
static String formatPercentage(
|
||||
double value, {
|
||||
int decimalDigits = 0,
|
||||
bool showSymbol = true,
|
||||
}) {
|
||||
final formatter = NumberFormat.percentPattern('vi_VN');
|
||||
formatter.maximumFractionDigits = decimalDigits;
|
||||
formatter.minimumFractionDigits = decimalDigits;
|
||||
|
||||
final result = formatter.format(value / 100);
|
||||
return showSymbol ? result : result.replaceAll('%', '');
|
||||
}
|
||||
|
||||
/// Format as compact number (e.g., "1.5K")
|
||||
static String formatCompact(num number) {
|
||||
final formatter = NumberFormat.compact(locale: 'vi_VN');
|
||||
return formatter.format(number);
|
||||
}
|
||||
|
||||
/// Format file size
|
||||
static String formatBytes(int bytes, {int decimals = 2}) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
|
||||
const suffixes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
final i = (bytes.bitLength - 1) ~/ 10;
|
||||
final value = bytes / (1 << (i * 10));
|
||||
|
||||
return '${value.toStringAsFixed(decimals)} ${suffixes[i]}';
|
||||
}
|
||||
|
||||
/// Format duration (e.g., "1:30:45")
|
||||
static String formatDuration(Duration duration) {
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
final seconds = duration.inSeconds.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text formatter utilities
|
||||
class TextFormatter {
|
||||
TextFormatter._();
|
||||
|
||||
/// Capitalize first letter
|
||||
static String capitalize(String text) {
|
||||
if (text.isEmpty) return text;
|
||||
return text[0].toUpperCase() + text.substring(1);
|
||||
}
|
||||
|
||||
/// Capitalize each word
|
||||
static String capitalizeWords(String text) {
|
||||
if (text.isEmpty) return text;
|
||||
return text.split(' ').map((word) => capitalize(word)).join(' ');
|
||||
}
|
||||
|
||||
/// Truncate text with ellipsis
|
||||
static String truncate(String text, int maxLength, {String ellipsis = '...'}) {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - ellipsis.length) + ellipsis;
|
||||
}
|
||||
|
||||
/// Remove diacritics from Vietnamese text
|
||||
static String removeDiacritics(String text) {
|
||||
const withDiacritics = 'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ';
|
||||
const withoutDiacritics = 'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd';
|
||||
|
||||
var result = text.toLowerCase();
|
||||
for (var i = 0; i < withDiacritics.length; i++) {
|
||||
result = result.replaceAll(withDiacritics[i], withoutDiacritics[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Create URL-friendly slug
|
||||
static String slugify(String text) {
|
||||
var slug = removeDiacritics(text);
|
||||
slug = slug.toLowerCase();
|
||||
slug = slug.replaceAll(RegExp(r'[^\w\s-]'), '');
|
||||
slug = slug.replaceAll(RegExp(r'[-\s]+'), '-');
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
/// Loyalty tier formatter
|
||||
class LoyaltyFormatter {
|
||||
LoyaltyFormatter._();
|
||||
|
||||
/// Format tier name in Vietnamese
|
||||
static String formatTier(String tier) {
|
||||
switch (tier.toLowerCase()) {
|
||||
case 'diamond':
|
||||
return 'Kim Cương';
|
||||
case 'platinum':
|
||||
return 'Bạch Kim';
|
||||
case 'gold':
|
||||
return 'Vàng';
|
||||
default:
|
||||
return tier;
|
||||
}
|
||||
}
|
||||
|
||||
/// Format points with label
|
||||
static String formatPoints(int points) {
|
||||
return '${NumberFormatter.format(points)} điểm';
|
||||
}
|
||||
|
||||
/// Format points progress (e.g., "1,200 / 5,000 điểm")
|
||||
static String formatPointsProgress(int current, int target) {
|
||||
return '${NumberFormatter.format(current)} / ${NumberFormatter.format(target)} điểm';
|
||||
}
|
||||
}
|
||||
274
lib/core/utils/l10n_extensions.dart
Normal file
274
lib/core/utils/l10n_extensions.dart
Normal file
@@ -0,0 +1,274 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:worker/generated/l10n/app_localizations.dart';
|
||||
|
||||
/// Extension for easy access to AppLocalizations
|
||||
///
|
||||
/// This extension provides convenient access to localization strings
|
||||
/// throughout the app without having to write `AppLocalizations.of(context)!`
|
||||
/// every time.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // Instead of:
|
||||
/// Text(AppLocalizations.of(context)!.login)
|
||||
///
|
||||
/// // You can use:
|
||||
/// Text(context.l10n.login)
|
||||
/// ```
|
||||
extension L10nExtension on BuildContext {
|
||||
/// Get the current AppLocalizations instance
|
||||
///
|
||||
/// This getter provides quick access to all localized strings.
|
||||
/// It will throw an error if called before the app is initialized,
|
||||
/// which helps catch localization issues during development.
|
||||
AppLocalizations get l10n => AppLocalizations.of(this)!;
|
||||
|
||||
/// Get the current locale language code (e.g., 'vi', 'en')
|
||||
String get languageCode => Localizations.localeOf(this).languageCode;
|
||||
|
||||
/// Check if the current locale is Vietnamese
|
||||
bool get isVietnamese => languageCode == 'vi';
|
||||
|
||||
/// Check if the current locale is English
|
||||
bool get isEnglish => languageCode == 'en';
|
||||
}
|
||||
|
||||
/// Helper class for common localization patterns
|
||||
///
|
||||
/// This class provides utility methods for formatting dates, times,
|
||||
/// currencies, and other locale-specific data.
|
||||
class L10nHelper {
|
||||
const L10nHelper._();
|
||||
|
||||
/// Format a DateTime to localized date string (DD/MM/YYYY for Vietnamese, MM/DD/YYYY for English)
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// final dateStr = L10nHelper.formatDate(context, DateTime.now());
|
||||
/// // Vietnamese: "17/10/2025"
|
||||
/// // English: "10/17/2025"
|
||||
/// ```
|
||||
static String formatDate(BuildContext context, DateTime date) {
|
||||
final day = date.day.toString().padLeft(2, '0');
|
||||
final month = date.month.toString().padLeft(2, '0');
|
||||
final year = date.year.toString();
|
||||
|
||||
return context.l10n.formatDate(day, month, year);
|
||||
}
|
||||
|
||||
/// Format a DateTime to localized date-time string
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// final dateTimeStr = L10nHelper.formatDateTime(context, DateTime.now());
|
||||
/// // Vietnamese: "17/10/2025 lúc 14:30"
|
||||
/// // English: "10/17/2025 at 14:30"
|
||||
/// ```
|
||||
static String formatDateTime(BuildContext context, DateTime dateTime) {
|
||||
final day = dateTime.day.toString().padLeft(2, '0');
|
||||
final month = dateTime.month.toString().padLeft(2, '0');
|
||||
final year = dateTime.year.toString();
|
||||
final hour = dateTime.hour.toString().padLeft(2, '0');
|
||||
final minute = dateTime.minute.toString().padLeft(2, '0');
|
||||
|
||||
return context.l10n.formatDateTime(day, month, year, hour, minute);
|
||||
}
|
||||
|
||||
/// Format a number as Vietnamese Dong currency
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// final price = L10nHelper.formatCurrency(context, 1500000);
|
||||
/// // Returns: "1.500.000 ₫"
|
||||
/// ```
|
||||
static String formatCurrency(BuildContext context, double amount) {
|
||||
final formatted = context.isVietnamese
|
||||
? _formatNumberVietnamese(amount)
|
||||
: _formatNumberEnglish(amount);
|
||||
|
||||
return context.l10n.formatCurrency(formatted);
|
||||
}
|
||||
|
||||
/// Format number with Vietnamese grouping (dots)
|
||||
static String _formatNumberVietnamese(double number) {
|
||||
final parts = number.toStringAsFixed(0).split('.');
|
||||
final intPart = parts[0];
|
||||
|
||||
// Add dots every 3 digits from right
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < intPart.length; i++) {
|
||||
if (i > 0 && (intPart.length - i) % 3 == 0) {
|
||||
buffer.write('.');
|
||||
}
|
||||
buffer.write(intPart[i]);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Format number with English grouping (commas)
|
||||
static String _formatNumberEnglish(double number) {
|
||||
final parts = number.toStringAsFixed(0).split('.');
|
||||
final intPart = parts[0];
|
||||
|
||||
// Add commas every 3 digits from right
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < intPart.length; i++) {
|
||||
if (i > 0 && (intPart.length - i) % 3 == 0) {
|
||||
buffer.write(',');
|
||||
}
|
||||
buffer.write(intPart[i]);
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Format a relative time (e.g., "5 minutes ago", "2 days ago")
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// final relativeTime = L10nHelper.formatRelativeTime(
|
||||
/// context,
|
||||
/// DateTime.now().subtract(Duration(minutes: 5)),
|
||||
/// );
|
||||
/// // Returns: "5 phút trước" (Vietnamese) or "5 minutes ago" (English)
|
||||
/// ```
|
||||
static String formatRelativeTime(BuildContext context, DateTime dateTime) {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(dateTime);
|
||||
|
||||
if (difference.inSeconds < 60) {
|
||||
return context.l10n.justNow;
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return context.l10n.minutesAgo(difference.inMinutes);
|
||||
} else if (difference.inHours < 24) {
|
||||
return context.l10n.hoursAgo(difference.inHours);
|
||||
} else if (difference.inDays < 7) {
|
||||
return context.l10n.daysAgo(difference.inDays);
|
||||
} else if (difference.inDays < 30) {
|
||||
return context.l10n.weeksAgo((difference.inDays / 7).floor());
|
||||
} else if (difference.inDays < 365) {
|
||||
return context.l10n.monthsAgo((difference.inDays / 30).floor());
|
||||
} else {
|
||||
return context.l10n.yearsAgo((difference.inDays / 365).floor());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get localized order status string
|
||||
static String getOrderStatus(BuildContext context, String status) {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'pending':
|
||||
return context.l10n.pending;
|
||||
case 'processing':
|
||||
return context.l10n.processing;
|
||||
case 'shipping':
|
||||
return context.l10n.shipping;
|
||||
case 'completed':
|
||||
return context.l10n.completed;
|
||||
case 'cancelled':
|
||||
return context.l10n.cancelled;
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get localized project status string
|
||||
static String getProjectStatus(BuildContext context, String status) {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'planning':
|
||||
return context.l10n.planningProjects;
|
||||
case 'in_progress':
|
||||
case 'inprogress':
|
||||
return context.l10n.inProgressProjects;
|
||||
case 'completed':
|
||||
return context.l10n.completedProjects;
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get localized member tier string
|
||||
static String getMemberTier(BuildContext context, String tier) {
|
||||
switch (tier.toLowerCase()) {
|
||||
case 'diamond':
|
||||
return context.l10n.diamond;
|
||||
case 'platinum':
|
||||
return context.l10n.platinum;
|
||||
case 'gold':
|
||||
return context.l10n.gold;
|
||||
default:
|
||||
return tier;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get localized user type string
|
||||
static String getUserType(BuildContext context, String userType) {
|
||||
switch (userType.toLowerCase()) {
|
||||
case 'contractor':
|
||||
return context.l10n.contractor;
|
||||
case 'architect':
|
||||
return context.l10n.architect;
|
||||
case 'distributor':
|
||||
return context.l10n.distributor;
|
||||
case 'broker':
|
||||
return context.l10n.broker;
|
||||
default:
|
||||
return userType;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get localized password strength string
|
||||
static String getPasswordStrength(BuildContext context, String strength) {
|
||||
switch (strength.toLowerCase()) {
|
||||
case 'weak':
|
||||
return context.l10n.weak;
|
||||
case 'medium':
|
||||
return context.l10n.medium;
|
||||
case 'strong':
|
||||
return context.l10n.strong;
|
||||
case 'very_strong':
|
||||
case 'verystrong':
|
||||
return context.l10n.veryStrong;
|
||||
default:
|
||||
return strength;
|
||||
}
|
||||
}
|
||||
|
||||
/// Format points with proper pluralization
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// final pointsText = L10nHelper.formatPoints(context, 100);
|
||||
/// // Returns: "+100 điểm" (Vietnamese) or "+100 points" (English)
|
||||
/// ```
|
||||
static String formatPoints(BuildContext context, int points,
|
||||
{bool showSign = true}) {
|
||||
if (showSign && points > 0) {
|
||||
return context.l10n.earnedPoints(points);
|
||||
} else if (showSign && points < 0) {
|
||||
return context.l10n.spentPoints(points.abs());
|
||||
} else {
|
||||
return context.l10n.pointsBalance(points);
|
||||
}
|
||||
}
|
||||
|
||||
/// Format item count with pluralization
|
||||
static String formatItemCount(BuildContext context, int count) {
|
||||
return context.l10n.itemsInCart(count);
|
||||
}
|
||||
|
||||
/// Format order count with pluralization
|
||||
static String formatOrderCount(BuildContext context, int count) {
|
||||
return context.l10n.ordersCount(count);
|
||||
}
|
||||
|
||||
/// Format project count with pluralization
|
||||
static String formatProjectCount(BuildContext context, int count) {
|
||||
return context.l10n.projectsCount(count);
|
||||
}
|
||||
|
||||
/// Format days remaining with pluralization
|
||||
static String formatDaysRemaining(BuildContext context, int days) {
|
||||
return context.l10n.daysRemaining(days);
|
||||
}
|
||||
}
|
||||
136
lib/core/utils/localization_extension.dart
Normal file
136
lib/core/utils/localization_extension.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:worker/generated/l10n/app_localizations.dart';
|
||||
|
||||
/// Extension on [BuildContext] for easy access to localizations
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// Text(context.l10n.login)
|
||||
/// ```
|
||||
///
|
||||
/// This provides a shorter and more convenient way to access localized strings
|
||||
/// compared to the verbose `AppLocalizations.of(context)!` syntax.
|
||||
extension LocalizationExtension on BuildContext {
|
||||
/// Get the current app localizations
|
||||
///
|
||||
/// Returns the [AppLocalizations] instance for the current context.
|
||||
/// This will never be null because the app always has a default locale.
|
||||
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||
}
|
||||
|
||||
/// Extension on [AppLocalizations] for additional formatting utilities
|
||||
extension LocalizationUtilities on AppLocalizations {
|
||||
/// Format currency in Vietnamese Dong
|
||||
///
|
||||
/// Example: 100000 -> "100.000 ₫"
|
||||
String formatCurrency(double amount) {
|
||||
final formatter = _getCurrencyFormatter();
|
||||
return formatter.format(amount);
|
||||
}
|
||||
|
||||
/// Format points display with formatted number
|
||||
///
|
||||
/// Example: 1500 -> "1.500 điểm" or "1,500 points"
|
||||
String formatPointsDisplay(int points) {
|
||||
// Use the generated method which already handles the formatting
|
||||
return pointsBalance(points);
|
||||
}
|
||||
|
||||
/// Format large numbers with thousand separators
|
||||
///
|
||||
/// Example: 1000000 -> "1.000.000"
|
||||
String formatNumber(num number) {
|
||||
return _formatNumber(number);
|
||||
}
|
||||
|
||||
/// Get currency formatter based on locale
|
||||
_CurrencyFormatter _getCurrencyFormatter() {
|
||||
if (localeName.startsWith('vi')) {
|
||||
return const _VietnameseCurrencyFormatter();
|
||||
} else {
|
||||
return const _EnglishCurrencyFormatter();
|
||||
}
|
||||
}
|
||||
|
||||
/// Format number with thousand separators
|
||||
String _formatNumber(num number) {
|
||||
final parts = number.toString().split('.');
|
||||
final integerPart = parts[0];
|
||||
final decimalPart = parts.length > 1 ? parts[1] : '';
|
||||
|
||||
// Add thousand separators
|
||||
final buffer = StringBuffer();
|
||||
final reversedInteger = integerPart.split('').reversed.join();
|
||||
|
||||
for (var i = 0; i < reversedInteger.length; i++) {
|
||||
if (i > 0 && i % 3 == 0) {
|
||||
buffer.write(localeName.startsWith('vi') ? '.' : ',');
|
||||
}
|
||||
buffer.write(reversedInteger[i]);
|
||||
}
|
||||
|
||||
final formattedInteger = buffer.toString().split('').reversed.join();
|
||||
|
||||
if (decimalPart.isNotEmpty) {
|
||||
return '$formattedInteger.$decimalPart';
|
||||
}
|
||||
|
||||
return formattedInteger;
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract currency formatter
|
||||
abstract class _CurrencyFormatter {
|
||||
const _CurrencyFormatter();
|
||||
|
||||
String format(double amount);
|
||||
}
|
||||
|
||||
/// Vietnamese currency formatter
|
||||
///
|
||||
/// Format: 100.000 ₫
|
||||
class _VietnameseCurrencyFormatter extends _CurrencyFormatter {
|
||||
const _VietnameseCurrencyFormatter();
|
||||
|
||||
@override
|
||||
String format(double amount) {
|
||||
final rounded = amount.round();
|
||||
final parts = rounded.toString().split('').reversed.join();
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (i > 0 && i % 3 == 0) {
|
||||
buffer.write('.');
|
||||
}
|
||||
buffer.write(parts[i]);
|
||||
}
|
||||
|
||||
final formatted = buffer.toString().split('').reversed.join();
|
||||
return '$formatted ₫';
|
||||
}
|
||||
}
|
||||
|
||||
/// English currency formatter
|
||||
///
|
||||
/// Format: ₫100,000
|
||||
class _EnglishCurrencyFormatter extends _CurrencyFormatter {
|
||||
const _EnglishCurrencyFormatter();
|
||||
|
||||
@override
|
||||
String format(double amount) {
|
||||
final rounded = amount.round();
|
||||
final parts = rounded.toString().split('').reversed.join();
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (i > 0 && i % 3 == 0) {
|
||||
buffer.write(',');
|
||||
}
|
||||
buffer.write(parts[i]);
|
||||
}
|
||||
|
||||
final formatted = buffer.toString().split('').reversed.join();
|
||||
return '₫$formatted';
|
||||
}
|
||||
}
|
||||
308
lib/core/utils/qr_generator.dart
Normal file
308
lib/core/utils/qr_generator.dart
Normal file
@@ -0,0 +1,308 @@
|
||||
/// QR Code Generator Utility
|
||||
///
|
||||
/// Provides QR code generation functionality for member cards,
|
||||
/// referral codes, and other QR code use cases.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
/// QR Code Generator
|
||||
class QRGenerator {
|
||||
QRGenerator._();
|
||||
|
||||
/// Generate QR code widget for member ID
|
||||
///
|
||||
/// Used in member cards to display user's member ID as QR code
|
||||
static Widget generateMemberQR({
|
||||
required String memberId,
|
||||
double size = 80.0,
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
int version = QrVersions.auto,
|
||||
int errorCorrectionLevel = QrErrorCorrectLevel.M,
|
||||
}) {
|
||||
return QrImageView(
|
||||
data: 'MEMBER:$memberId',
|
||||
version: version,
|
||||
size: size,
|
||||
errorCorrectionLevel: errorCorrectionLevel,
|
||||
backgroundColor: backgroundColor ?? Colors.white,
|
||||
eyeStyle: QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
dataModuleStyle: QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
gapless: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate QR code widget for referral code
|
||||
///
|
||||
/// Used to share referral codes via QR scanning
|
||||
static Widget generateReferralQR({
|
||||
required String referralCode,
|
||||
double size = 200.0,
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
return QrImageView(
|
||||
data: 'REFERRAL:$referralCode',
|
||||
version: QrVersions.auto,
|
||||
size: size,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.H,
|
||||
backgroundColor: backgroundColor ?? Colors.white,
|
||||
eyeStyle: QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
dataModuleStyle: QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
gapless: true,
|
||||
embeddedImageStyle: const QrEmbeddedImageStyle(
|
||||
size: Size(48, 48),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate QR code widget for order tracking
|
||||
///
|
||||
/// Used to display order number as QR code for easy tracking
|
||||
static Widget generateOrderQR({
|
||||
required String orderNumber,
|
||||
double size = 150.0,
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
return QrImageView(
|
||||
data: 'ORDER:$orderNumber',
|
||||
version: QrVersions.auto,
|
||||
size: size,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.M,
|
||||
backgroundColor: backgroundColor ?? Colors.white,
|
||||
eyeStyle: QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
dataModuleStyle: QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
gapless: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate QR code widget for product info
|
||||
///
|
||||
/// Used to encode product SKU or URL for quick access
|
||||
static Widget generateProductQR({
|
||||
required String productId,
|
||||
double size = 120.0,
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
return QrImageView(
|
||||
data: 'PRODUCT:$productId',
|
||||
version: QrVersions.auto,
|
||||
size: size,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.M,
|
||||
backgroundColor: backgroundColor ?? Colors.white,
|
||||
eyeStyle: QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
dataModuleStyle: QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
padding: const EdgeInsets.all(10),
|
||||
gapless: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate QR code widget with custom data
|
||||
///
|
||||
/// Generic QR code generator for any string data
|
||||
static Widget generateCustomQR({
|
||||
required String data,
|
||||
double size = 200.0,
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
int errorCorrectionLevel = QrErrorCorrectLevel.M,
|
||||
EdgeInsets padding = const EdgeInsets.all(16),
|
||||
bool gapless = true,
|
||||
}) {
|
||||
return QrImageView(
|
||||
data: data,
|
||||
version: QrVersions.auto,
|
||||
size: size,
|
||||
errorCorrectionLevel: errorCorrectionLevel,
|
||||
backgroundColor: backgroundColor ?? Colors.white,
|
||||
eyeStyle: QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
dataModuleStyle: QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
padding: padding,
|
||||
gapless: gapless,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate QR code widget with embedded logo
|
||||
///
|
||||
/// Used for branded QR codes with app logo in center
|
||||
static Widget generateQRWithLogo({
|
||||
required String data,
|
||||
required Widget embeddedImage,
|
||||
double size = 250.0,
|
||||
Color? foregroundColor,
|
||||
Color? backgroundColor,
|
||||
Size embeddedImageSize = const Size(64, 64),
|
||||
}) {
|
||||
return QrImageView(
|
||||
data: data,
|
||||
version: QrVersions.auto,
|
||||
size: size,
|
||||
errorCorrectionLevel: QrErrorCorrectLevel.H, // High correction for logo
|
||||
backgroundColor: backgroundColor ?? Colors.white,
|
||||
eyeStyle: QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
dataModuleStyle: QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: foregroundColor ?? Colors.black,
|
||||
),
|
||||
padding: const EdgeInsets.all(20),
|
||||
gapless: true,
|
||||
embeddedImage: embeddedImage is AssetImage
|
||||
? (embeddedImage as AssetImage).assetName as ImageProvider
|
||||
: null,
|
||||
embeddedImageStyle: QrEmbeddedImageStyle(
|
||||
size: embeddedImageSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse QR code data and extract type and value
|
||||
///
|
||||
/// Returns a map with 'type' and 'value' keys
|
||||
static Map<String, String>? parseQRData(String data) {
|
||||
try {
|
||||
if (data.contains(':')) {
|
||||
final parts = data.split(':');
|
||||
if (parts.length == 2) {
|
||||
return {
|
||||
'type': parts[0].toUpperCase(),
|
||||
'value': parts[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If no type prefix, return as generic data
|
||||
return {
|
||||
'type': 'GENERIC',
|
||||
'value': data,
|
||||
};
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate QR code data format
|
||||
static bool isValidQRData(String data, {String? expectedType}) {
|
||||
if (data.isEmpty) return false;
|
||||
|
||||
final parsed = parseQRData(data);
|
||||
if (parsed == null) return false;
|
||||
|
||||
if (expectedType != null) {
|
||||
return parsed['type'] == expectedType.toUpperCase();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Generate QR data string with type prefix
|
||||
static String generateQRData(String type, String value) {
|
||||
return '${type.toUpperCase()}:$value';
|
||||
}
|
||||
}
|
||||
|
||||
/// QR Code Types
|
||||
class QRCodeType {
|
||||
QRCodeType._();
|
||||
|
||||
static const String member = 'MEMBER';
|
||||
static const String referral = 'REFERRAL';
|
||||
static const String order = 'ORDER';
|
||||
static const String product = 'PRODUCT';
|
||||
static const String payment = 'PAYMENT';
|
||||
static const String url = 'URL';
|
||||
static const String generic = 'GENERIC';
|
||||
}
|
||||
|
||||
/// QR Code Scanner Result
|
||||
class QRScanResult {
|
||||
final String type;
|
||||
final String value;
|
||||
final String rawData;
|
||||
|
||||
const QRScanResult({
|
||||
required this.type,
|
||||
required this.value,
|
||||
required this.rawData,
|
||||
});
|
||||
|
||||
/// Check if scan result is of expected type
|
||||
bool isType(String expectedType) {
|
||||
return type.toUpperCase() == expectedType.toUpperCase();
|
||||
}
|
||||
|
||||
/// Check if result is a member QR code
|
||||
bool get isMember => isType(QRCodeType.member);
|
||||
|
||||
/// Check if result is a referral QR code
|
||||
bool get isReferral => isType(QRCodeType.referral);
|
||||
|
||||
/// Check if result is an order QR code
|
||||
bool get isOrder => isType(QRCodeType.order);
|
||||
|
||||
/// Check if result is a product QR code
|
||||
bool get isProduct => isType(QRCodeType.product);
|
||||
|
||||
/// Check if result is a URL QR code
|
||||
bool get isUrl => isType(QRCodeType.url);
|
||||
|
||||
factory QRScanResult.fromRawData(String rawData) {
|
||||
final parsed = QRGenerator.parseQRData(rawData);
|
||||
|
||||
if (parsed != null) {
|
||||
return QRScanResult(
|
||||
type: parsed['type']!,
|
||||
value: parsed['value']!,
|
||||
rawData: rawData,
|
||||
);
|
||||
}
|
||||
|
||||
return QRScanResult(
|
||||
type: QRCodeType.generic,
|
||||
value: rawData,
|
||||
rawData: rawData,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'QRScanResult(type: $type, value: $value)';
|
||||
}
|
||||
540
lib/core/utils/validators.dart
Normal file
540
lib/core/utils/validators.dart
Normal file
@@ -0,0 +1,540 @@
|
||||
/// Form Validators for Vietnamese Locale
|
||||
///
|
||||
/// Provides validation utilities for forms with Vietnamese-specific
|
||||
/// validations for phone numbers, email, passwords, etc.
|
||||
library;
|
||||
|
||||
/// Form field validators
|
||||
class Validators {
|
||||
Validators._();
|
||||
|
||||
// ========================================================================
|
||||
// Required Field Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate required field
|
||||
static String? required(String? value, {String? fieldName}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return fieldName != null
|
||||
? '$fieldName là bắt buộc'
|
||||
: 'Trường này là bắt buộc';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Phone Number Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate Vietnamese phone number
|
||||
///
|
||||
/// Accepts formats:
|
||||
/// - 0xxx xxx xxx (10 digits starting with 0)
|
||||
/// - +84xxx xxx xxx (starts with +84)
|
||||
/// - 84xxx xxx xxx (starts with 84)
|
||||
static String? phone(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập số điện thoại';
|
||||
}
|
||||
|
||||
final cleaned = value.replaceAll(RegExp(r'\D'), '');
|
||||
|
||||
// Check if starts with valid Vietnamese mobile prefix
|
||||
final vietnamesePattern = RegExp(r'^(0|\+?84)(3|5|7|8|9)[0-9]{8}$');
|
||||
|
||||
if (!vietnamesePattern.hasMatch(value.replaceAll(RegExp(r'[^\d+]'), ''))) {
|
||||
return 'Số điện thoại không hợp lệ';
|
||||
}
|
||||
|
||||
if (cleaned.length < 10 || cleaned.length > 11) {
|
||||
return 'Số điện thoại phải có 10 chữ số';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate phone number (optional)
|
||||
static String? phoneOptional(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null; // Optional, so null is valid
|
||||
}
|
||||
return phone(value);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Email Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate email address
|
||||
static String? email(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập email';
|
||||
}
|
||||
|
||||
final emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
|
||||
if (!emailRegex.hasMatch(value)) {
|
||||
return 'Email không hợp lệ';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate email (optional)
|
||||
static String? emailOptional(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return email(value);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Password Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate password strength
|
||||
///
|
||||
/// Requirements:
|
||||
/// - At least 8 characters
|
||||
/// - At least 1 uppercase letter
|
||||
/// - At least 1 lowercase letter
|
||||
/// - At least 1 number
|
||||
/// - At least 1 special character
|
||||
static String? password(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập mật khẩu';
|
||||
}
|
||||
|
||||
if (value.length < 8) {
|
||||
return 'Mật khẩu phải có ít nhất 8 ký tự';
|
||||
}
|
||||
|
||||
if (!RegExp(r'[A-Z]').hasMatch(value)) {
|
||||
return 'Mật khẩu phải có ít nhất 1 chữ hoa';
|
||||
}
|
||||
|
||||
if (!RegExp(r'[a-z]').hasMatch(value)) {
|
||||
return 'Mật khẩu phải có ít nhất 1 chữ thường';
|
||||
}
|
||||
|
||||
if (!RegExp(r'[0-9]').hasMatch(value)) {
|
||||
return 'Mật khẩu phải có ít nhất 1 số';
|
||||
}
|
||||
|
||||
if (!RegExp(r'[!@#$%^&*(),.?":{}|<>]').hasMatch(value)) {
|
||||
return 'Mật khẩu phải có ít nhất 1 ký tự đặc biệt';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate password confirmation
|
||||
static String? confirmPassword(String? value, String? password) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng xác nhận mật khẩu';
|
||||
}
|
||||
|
||||
if (value != password) {
|
||||
return 'Mật khẩu không khớp';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Simple password validator (minimum length only)
|
||||
static String? passwordSimple(String? value, {int minLength = 6}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập mật khẩu';
|
||||
}
|
||||
|
||||
if (value.length < minLength) {
|
||||
return 'Mật khẩu phải có ít nhất $minLength ký tự';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// OTP Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate OTP code
|
||||
static String? otp(String? value, {int length = 6}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập mã OTP';
|
||||
}
|
||||
|
||||
if (value.length != length) {
|
||||
return 'Mã OTP phải có $length chữ số';
|
||||
}
|
||||
|
||||
if (!RegExp(r'^[0-9]+$').hasMatch(value)) {
|
||||
return 'Mã OTP chỉ được chứa số';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Text Length Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate minimum length
|
||||
static String? minLength(String? value, int min, {String? fieldName}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return fieldName != null
|
||||
? '$fieldName là bắt buộc'
|
||||
: 'Trường này là bắt buộc';
|
||||
}
|
||||
|
||||
if (value.length < min) {
|
||||
return fieldName != null
|
||||
? '$fieldName phải có ít nhất $min ký tự'
|
||||
: 'Phải có ít nhất $min ký tự';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate maximum length
|
||||
static String? maxLength(String? value, int max, {String? fieldName}) {
|
||||
if (value != null && value.length > max) {
|
||||
return fieldName != null
|
||||
? '$fieldName không được vượt quá $max ký tự'
|
||||
: 'Không được vượt quá $max ký tự';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate length range
|
||||
static String? lengthRange(
|
||||
String? value,
|
||||
int min,
|
||||
int max, {
|
||||
String? fieldName,
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return fieldName != null
|
||||
? '$fieldName là bắt buộc'
|
||||
: 'Trường này là bắt buộc';
|
||||
}
|
||||
|
||||
if (value.length < min || value.length > max) {
|
||||
return fieldName != null
|
||||
? '$fieldName phải có từ $min đến $max ký tự'
|
||||
: 'Phải có từ $min đến $max ký tự';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Number Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate number
|
||||
static String? number(String? value, {String? fieldName}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return fieldName != null
|
||||
? '$fieldName là bắt buộc'
|
||||
: 'Trường này là bắt buộc';
|
||||
}
|
||||
|
||||
if (double.tryParse(value) == null) {
|
||||
return fieldName != null
|
||||
? '$fieldName phải là số'
|
||||
: 'Giá trị phải là số';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate integer
|
||||
static String? integer(String? value, {String? fieldName}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return fieldName != null
|
||||
? '$fieldName là bắt buộc'
|
||||
: 'Trường này là bắt buộc';
|
||||
}
|
||||
|
||||
if (int.tryParse(value) == null) {
|
||||
return fieldName != null
|
||||
? '$fieldName phải là số nguyên'
|
||||
: 'Giá trị phải là số nguyên';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate positive number
|
||||
static String? positiveNumber(String? value, {String? fieldName}) {
|
||||
final numberError = number(value, fieldName: fieldName);
|
||||
if (numberError != null) return numberError;
|
||||
|
||||
final num = double.parse(value!);
|
||||
if (num <= 0) {
|
||||
return fieldName != null
|
||||
? '$fieldName phải lớn hơn 0'
|
||||
: 'Giá trị phải lớn hơn 0';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate number range
|
||||
static String? numberRange(
|
||||
String? value,
|
||||
double min,
|
||||
double max, {
|
||||
String? fieldName,
|
||||
}) {
|
||||
final numberError = number(value, fieldName: fieldName);
|
||||
if (numberError != null) return numberError;
|
||||
|
||||
final num = double.parse(value!);
|
||||
if (num < min || num > max) {
|
||||
return fieldName != null
|
||||
? '$fieldName phải từ $min đến $max'
|
||||
: 'Giá trị phải từ $min đến $max';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Date Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate date format (dd/MM/yyyy)
|
||||
static String? date(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập ngày';
|
||||
}
|
||||
|
||||
final dateRegex = RegExp(r'^\d{2}/\d{2}/\d{4}$');
|
||||
if (!dateRegex.hasMatch(value)) {
|
||||
return 'Định dạng ngày không hợp lệ (dd/MM/yyyy)';
|
||||
}
|
||||
|
||||
try {
|
||||
final parts = value.split('/');
|
||||
final day = int.parse(parts[0]);
|
||||
final month = int.parse(parts[1]);
|
||||
final year = int.parse(parts[2]);
|
||||
|
||||
final date = DateTime(year, month, day);
|
||||
|
||||
if (date.day != day || date.month != month || date.year != year) {
|
||||
return 'Ngày không hợp lệ';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Ngày không hợp lệ';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate age (must be at least 18 years old)
|
||||
static String? age(String? value, {int minAge = 18}) {
|
||||
final dateError = date(value);
|
||||
if (dateError != null) return dateError;
|
||||
|
||||
try {
|
||||
final parts = value!.split('/');
|
||||
final birthDate = DateTime(
|
||||
int.parse(parts[2]),
|
||||
int.parse(parts[1]),
|
||||
int.parse(parts[0]),
|
||||
);
|
||||
|
||||
final today = DateTime.now();
|
||||
final age = today.year -
|
||||
birthDate.year -
|
||||
(today.month > birthDate.month ||
|
||||
(today.month == birthDate.month && today.day >= birthDate.day)
|
||||
? 0
|
||||
: 1);
|
||||
|
||||
if (age < minAge) {
|
||||
return 'Bạn phải từ $minAge tuổi trở lên';
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return 'Ngày sinh không hợp lệ';
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Address Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate Vietnamese address
|
||||
static String? address(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập địa chỉ';
|
||||
}
|
||||
|
||||
if (value.length < 10) {
|
||||
return 'Địa chỉ quá ngắn';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Tax ID Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate Vietnamese Tax ID (Mã số thuế)
|
||||
/// Format: 10 or 13 digits
|
||||
static String? taxId(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập mã số thuế';
|
||||
}
|
||||
|
||||
final cleaned = value.replaceAll(RegExp(r'\D'), '');
|
||||
|
||||
if (cleaned.length != 10 && cleaned.length != 13) {
|
||||
return 'Mã số thuế phải có 10 hoặc 13 chữ số';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate tax ID (optional)
|
||||
static String? taxIdOptional(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return taxId(value);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// URL Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate URL
|
||||
static String? url(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập URL';
|
||||
}
|
||||
|
||||
final urlRegex = RegExp(
|
||||
r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$',
|
||||
);
|
||||
|
||||
if (!urlRegex.hasMatch(value)) {
|
||||
return 'URL không hợp lệ';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Combination Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Combine multiple validators
|
||||
static String? Function(String?) combine(
|
||||
List<String? Function(String?)> validators,
|
||||
) {
|
||||
return (String? value) {
|
||||
for (final validator in validators) {
|
||||
final error = validator(value);
|
||||
if (error != null) return error;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Custom Pattern Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate against custom regex pattern
|
||||
static String? pattern(
|
||||
String? value,
|
||||
RegExp pattern,
|
||||
String errorMessage,
|
||||
) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Trường này là bắt buộc';
|
||||
}
|
||||
|
||||
if (!pattern.hasMatch(value)) {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Match Validators
|
||||
// ========================================================================
|
||||
|
||||
/// Validate that value matches another value
|
||||
static String? match(String? value, String? matchValue, String fieldName) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập $fieldName';
|
||||
}
|
||||
|
||||
if (value != matchValue) {
|
||||
return '$fieldName không khớp';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Password strength enum
|
||||
enum PasswordStrength {
|
||||
weak,
|
||||
medium,
|
||||
strong,
|
||||
veryStrong,
|
||||
}
|
||||
|
||||
/// Password strength calculator
|
||||
class PasswordStrengthCalculator {
|
||||
/// Calculate password strength
|
||||
static PasswordStrength calculate(String password) {
|
||||
if (password.isEmpty) return PasswordStrength.weak;
|
||||
|
||||
var score = 0;
|
||||
|
||||
// Length check
|
||||
if (password.length >= 8) score++;
|
||||
if (password.length >= 12) score++;
|
||||
if (password.length >= 16) score++;
|
||||
|
||||
// Character variety check
|
||||
if (RegExp(r'[a-z]').hasMatch(password)) score++;
|
||||
if (RegExp(r'[A-Z]').hasMatch(password)) score++;
|
||||
if (RegExp(r'[0-9]').hasMatch(password)) score++;
|
||||
if (RegExp(r'[!@#$%^&*(),.?":{}|<>]').hasMatch(password)) score++;
|
||||
|
||||
// Return strength based on score
|
||||
if (score <= 2) return PasswordStrength.weak;
|
||||
if (score <= 4) return PasswordStrength.medium;
|
||||
if (score <= 6) return PasswordStrength.strong;
|
||||
return PasswordStrength.veryStrong;
|
||||
}
|
||||
|
||||
/// Get strength label in Vietnamese
|
||||
static String getLabel(PasswordStrength strength) {
|
||||
switch (strength) {
|
||||
case PasswordStrength.weak:
|
||||
return 'Yếu';
|
||||
case PasswordStrength.medium:
|
||||
return 'Trung bình';
|
||||
case PasswordStrength.strong:
|
||||
return 'Mạnh';
|
||||
case PasswordStrength.veryStrong:
|
||||
return 'Rất mạnh';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user