This commit is contained in:
Phuoc Nguyen
2025-10-17 17:22:28 +07:00
parent 2125e85d40
commit 628c81ce13
86 changed files with 31339 additions and 1710 deletions

View 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`

View 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);
}
}

View 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';
}
}

View 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);
}
}

View 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';
}
}

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

View 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';
}
}
}