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

353
lib/app.dart Normal file
View File

@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/core/theme/app_theme.dart';
import 'package:worker/generated/l10n/app_localizations.dart';
/// Root application widget for Worker Mobile App
///
/// This is the main app widget that:
/// - Sets up Material 3 theme configuration
/// - Configures localization for Vietnamese and English
/// - Provides router configuration (to be implemented)
/// - Integrates with Riverpod for state management
/// - Handles app-level error states
class WorkerApp extends ConsumerWidget {
const WorkerApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
// ==================== App Configuration ====================
debugShowCheckedModeBanner: false,
title: 'Worker App',
// ==================== Theme Configuration ====================
// Material 3 theme with brand colors (Primary Blue: #005B9A)
theme: AppTheme.lightTheme(),
darkTheme: AppTheme.darkTheme(),
themeMode: ThemeMode.light, // TODO: Make this configurable from settings
// ==================== Localization Configuration ====================
// Support for Vietnamese (primary) and English (secondary)
localizationsDelegates: const [
// App-specific localizations
AppLocalizations.delegate,
// Material Design localizations
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
// Supported locales
supportedLocales: const [
Locale('vi', 'VN'), // Vietnamese (primary)
Locale('en', 'US'), // English (secondary)
],
// Default locale (Vietnamese)
locale: const Locale('vi', 'VN'), // TODO: Make this configurable from settings
// Locale resolution strategy
localeResolutionCallback: (locale, supportedLocales) {
// Check if the device locale is supported
for (final supportedLocale in supportedLocales) {
if (supportedLocale.languageCode == locale?.languageCode) {
return supportedLocale;
}
}
// If device locale is not supported, default to Vietnamese
return const Locale('vi', 'VN');
},
// ==================== Navigation Configuration ====================
// TODO: Replace with actual router configuration when navigation is implemented
// Options:
// 1. Use go_router for declarative routing
// 2. Use Navigator 2.0 for imperative routing
// 3. Use auto_route for type-safe routing
//
// For now, we use a placeholder home screen
home: const _PlaceholderHomePage(),
// Alternative: Use onGenerateRoute for custom routing
// onGenerateRoute: (settings) {
// return AppRouter.onGenerateRoute(settings);
// },
// ==================== Material App Configuration ====================
// Builder for additional context-dependent widgets
builder: (context, child) {
return _AppBuilder(
child: child ?? const SizedBox.shrink(),
);
},
);
}
}
/// App builder widget
///
/// Wraps the entire app with additional widgets:
/// - Error boundary
/// - Connectivity listener
/// - Global overlays (loading, snackbars)
class _AppBuilder extends ConsumerWidget {
const _AppBuilder({
required this.child,
});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: Add connectivity listener
// final connectivity = ref.watch(connectivityProvider);
// TODO: Add global loading state
// final isLoading = ref.watch(globalLoadingProvider);
return Stack(
children: [
// Main app content
child,
// TODO: Add global loading overlay
// if (isLoading)
// const _GlobalLoadingOverlay(),
// TODO: Add connectivity banner
// if (connectivity == ConnectivityStatus.offline)
// const _OfflineBanner(),
],
);
}
}
/// Placeholder home page
///
/// This is a temporary home screen that will be replaced with the actual
/// home page implementation from features/home/presentation/pages/home_page.dart
///
/// The actual home page will include:
/// - Membership card display (Diamond/Platinum/Gold tiers)
/// - Quick action grid
/// - Bottom navigation bar
/// - Floating action button for chat
class _PlaceholderHomePage extends ConsumerWidget {
const _PlaceholderHomePage();
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Worker App'),
centerTitle: true,
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// App logo placeholder
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(24),
),
child: Icon(
Icons.business_center,
size: 64,
color: theme.colorScheme.onPrimary,
),
),
const SizedBox(height: 32),
// Welcome text
Text(
'Chào mừng đến với Worker App',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Description
Text(
'Ứng dụng dành cho thầu thợ, kiến trúc sư, đại lý và môi giới',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
// Status indicators
const _StatusIndicator(
icon: Icons.check_circle,
color: Colors.green,
label: 'Hive Database: Initialized',
),
const SizedBox(height: 12),
const _StatusIndicator(
icon: Icons.check_circle,
color: Colors.green,
label: 'Riverpod: Active',
),
const SizedBox(height: 12),
const _StatusIndicator(
icon: Icons.check_circle,
color: Colors.green,
label: 'Material 3 Theme: Loaded',
),
const SizedBox(height: 48),
// Next steps card
Card(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
Text(
'Next Steps',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
const _NextStepItem(
number: '1',
text: 'Implement authentication flow',
),
const SizedBox(height: 8),
const _NextStepItem(
number: '2',
text: 'Create home page with membership cards',
),
const SizedBox(height: 8),
const _NextStepItem(
number: '3',
text: 'Set up navigation and routing',
),
const SizedBox(height: 8),
const _NextStepItem(
number: '4',
text: 'Implement feature modules',
),
],
),
),
),
],
),
),
),
// Floating action button (will be used for chat)
floatingActionButton: FloatingActionButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Chat feature coming soon!'),
behavior: SnackBarBehavior.floating,
),
);
},
child: const Icon(Icons.chat_bubble_outline),
),
);
}
}
/// Status indicator widget
class _StatusIndicator extends StatelessWidget {
const _StatusIndicator({
required this.icon,
required this.color,
required this.label,
});
final IconData icon;
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 20),
const SizedBox(width: 8),
Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
),
],
);
}
}
/// Next step item widget
class _NextStepItem extends StatelessWidget {
const _NextStepItem({
required this.number,
required this.text,
});
final String number;
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
number,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyMedium,
),
),
],
);
}
}

View File

@@ -0,0 +1,408 @@
/// API-related constants for the Worker app
///
/// This file contains all API endpoints, timeouts, and network-related configurations.
/// Base URLs should be configured per environment (dev, staging, production).
class ApiConstants {
// Private constructor to prevent instantiation
ApiConstants._();
// ============================================================================
// Base URLs
// ============================================================================
/// Base URL for development environment
static const String devBaseUrl = 'https://dev-api.worker.example.com';
/// Base URL for staging environment
static const String stagingBaseUrl = 'https://staging-api.worker.example.com';
/// Base URL for production environment
static const String prodBaseUrl = 'https://api.worker.example.com';
/// Current base URL (should be configured based on build flavor)
static const String baseUrl = devBaseUrl; // TODO: Configure with flavors
/// API version prefix
static const String apiVersion = '/v1';
/// Full API base URL with version
static String get apiBaseUrl => '$baseUrl$apiVersion';
// ============================================================================
// Timeout Configurations
// ============================================================================
/// Connection timeout in milliseconds (30 seconds)
static const Duration connectionTimeout = Duration(milliseconds: 30000);
/// Receive timeout in milliseconds (30 seconds)
static const Duration receiveTimeout = Duration(milliseconds: 30000);
/// Send timeout in milliseconds (30 seconds)
static const Duration sendTimeout = Duration(milliseconds: 30000);
// ============================================================================
// Retry Configurations
// ============================================================================
/// Maximum number of retry attempts for failed requests
static const int maxRetryAttempts = 3;
/// Initial retry delay in milliseconds
static const Duration initialRetryDelay = Duration(milliseconds: 1000);
/// Maximum retry delay in milliseconds
static const Duration maxRetryDelay = Duration(milliseconds: 5000);
/// Retry delay multiplier for exponential backoff
static const double retryDelayMultiplier = 2.0;
// ============================================================================
// Cache Configurations
// ============================================================================
/// Default cache duration (1 hour)
static const Duration defaultCacheDuration = Duration(hours: 1);
/// Products cache duration (24 hours)
static const Duration productsCacheDuration = Duration(hours: 24);
/// Profile cache duration (1 hour)
static const Duration profileCacheDuration = Duration(hours: 1);
/// Categories cache duration (48 hours)
static const Duration categoriesCacheDuration = Duration(hours: 48);
/// Maximum cache size in bytes (50 MB)
static const int maxCacheSize = 50 * 1024 * 1024;
// ============================================================================
// Request Headers
// ============================================================================
/// Content-Type header for JSON requests
static const String contentTypeJson = 'application/json';
/// Accept header for JSON responses
static const String acceptJson = 'application/json';
/// Accept-Language header for Vietnamese
static const String acceptLanguageVi = 'vi';
/// Accept-Language header for English
static const String acceptLanguageEn = 'en';
// ============================================================================
// Authentication Endpoints
// ============================================================================
/// Request OTP for phone number login
/// POST /auth/request-otp
/// Body: { "phone": "+84912345678" }
static const String requestOtp = '/auth/request-otp';
/// Verify OTP code
/// POST /auth/verify-otp
/// Body: { "phone": "+84912345678", "otp": "123456" }
static const String verifyOtp = '/auth/verify-otp';
/// Register new user
/// POST /auth/register
/// Body: { "name": "...", "phone": "...", "email": "...", "userType": "..." }
static const String register = '/auth/register';
/// Refresh access token
/// POST /auth/refresh-token
/// Headers: { "Authorization": "Bearer {refreshToken}" }
static const String refreshToken = '/auth/refresh-token';
/// Logout user
/// POST /auth/logout
static const String logout = '/auth/logout';
/// Get current user profile
/// GET /auth/me
static const String getCurrentUser = '/auth/me';
// ============================================================================
// Loyalty Program Endpoints
// ============================================================================
/// Get loyalty points and tier information
/// GET /loyalty/points
static const String getLoyaltyPoints = '/loyalty/points';
/// Get loyalty points transaction history
/// GET /loyalty/transactions?page={page}&limit={limit}
static const String getPointsHistory = '/loyalty/transactions';
/// Get available rewards for redemption
/// GET /loyalty/rewards?category={category}
static const String getRewards = '/loyalty/rewards';
/// Redeem a reward
/// POST /loyalty/rewards/{rewardId}/redeem
static const String redeemReward = '/loyalty/rewards';
/// Get user's redeemed gifts
/// GET /loyalty/gifts?status={active|used|expired}
static const String getGifts = '/loyalty/gifts';
/// Get referral information
/// GET /loyalty/referral
static const String getReferralInfo = '/loyalty/referral';
/// Share referral link
/// POST /loyalty/referral/share
/// Body: { "method": "whatsapp|telegram|sms" }
static const String shareReferral = '/loyalty/referral/share';
// ============================================================================
// Product Endpoints
// ============================================================================
/// Get all products with pagination
/// GET /products?page={page}&limit={limit}&category={categoryId}
static const String getProducts = '/products';
/// Get product details by ID
/// GET /products/{productId}
static const String getProductDetails = '/products';
/// Search products
/// GET /products/search?q={query}&page={page}&limit={limit}
static const String searchProducts = '/products/search';
/// Get product categories
/// GET /categories
static const String getCategories = '/categories';
/// Get products by category
/// GET /categories/{categoryId}/products
static const String getProductsByCategory = '/categories';
// ============================================================================
// Order Endpoints
// ============================================================================
/// Create new order
/// POST /orders
/// Body: { "items": [...], "deliveryAddress": {...}, "paymentMethod": "..." }
static const String createOrder = '/orders';
/// Get user's orders
/// GET /orders?status={status}&page={page}&limit={limit}
static const String getOrders = '/orders';
/// Get order details by ID
/// GET /orders/{orderId}
static const String getOrderDetails = '/orders';
/// Cancel order
/// POST /orders/{orderId}/cancel
static const String cancelOrder = '/orders';
/// Get payment transactions
/// GET /payments?page={page}&limit={limit}
static const String getPayments = '/payments';
/// Get payment details
/// GET /payments/{paymentId}
static const String getPaymentDetails = '/payments';
// ============================================================================
// Project Endpoints
// ============================================================================
/// Create new project
/// POST /projects
static const String createProject = '/projects';
/// Get user's projects
/// GET /projects?status={status}&page={page}&limit={limit}
static const String getProjects = '/projects';
/// Get project details by ID
/// GET /projects/{projectId}
static const String getProjectDetails = '/projects';
/// Update project
/// PUT /projects/{projectId}
static const String updateProject = '/projects';
/// Update project progress
/// PATCH /projects/{projectId}/progress
/// Body: { "progress": 75 }
static const String updateProjectProgress = '/projects';
/// Delete project
/// DELETE /projects/{projectId}
static const String deleteProject = '/projects';
// ============================================================================
// Quote Endpoints
// ============================================================================
/// Create new quote
/// POST /quotes
static const String createQuote = '/quotes';
/// Get user's quotes
/// GET /quotes?status={status}&page={page}&limit={limit}
static const String getQuotes = '/quotes';
/// Get quote details by ID
/// GET /quotes/{quoteId}
static const String getQuoteDetails = '/quotes';
/// Update quote
/// PUT /quotes/{quoteId}
static const String updateQuote = '/quotes';
/// Send quote to client
/// POST /quotes/{quoteId}/send
/// Body: { "email": "client@example.com" }
static const String sendQuote = '/quotes';
/// Convert quote to order
/// POST /quotes/{quoteId}/convert
static const String convertQuoteToOrder = '/quotes';
// ============================================================================
// Chat Endpoints
// ============================================================================
/// WebSocket endpoint for real-time chat
static const String chatWebSocket = '/ws/chat';
/// Get chat messages
/// GET /chat/messages?roomId={roomId}&before={messageId}&limit={limit}
static const String getChatMessages = '/chat/messages';
/// Send chat message
/// POST /chat/messages
/// Body: { "roomId": "...", "text": "...", "attachments": [...] }
static const String sendChatMessage = '/chat/messages';
/// Mark messages as read
/// POST /chat/messages/read
/// Body: { "messageIds": [...] }
static const String markMessagesAsRead = '/chat/messages/read';
// ============================================================================
// Account & Profile Endpoints
// ============================================================================
/// Get user profile
/// GET /profile
static const String getProfile = '/profile';
/// Update user profile
/// PUT /profile
static const String updateProfile = '/profile';
/// Upload avatar
/// POST /profile/avatar
/// Form-data: { "avatar": File }
static const String uploadAvatar = '/profile/avatar';
/// Change password
/// POST /profile/change-password
/// Body: { "currentPassword": "...", "newPassword": "..." }
static const String changePassword = '/profile/change-password';
/// Get user addresses
/// GET /addresses
static const String getAddresses = '/addresses';
/// Add new address
/// POST /addresses
static const String addAddress = '/addresses';
/// Update address
/// PUT /addresses/{addressId}
static const String updateAddress = '/addresses';
/// Delete address
/// DELETE /addresses/{addressId}
static const String deleteAddress = '/addresses';
/// Set default address
/// POST /addresses/{addressId}/set-default
static const String setDefaultAddress = '/addresses';
// ============================================================================
// Promotion Endpoints
// ============================================================================
/// Get active promotions
/// GET /promotions?category={category}
static const String getPromotions = '/promotions';
/// Get promotion details
/// GET /promotions/{promotionId}
static const String getPromotionDetails = '/promotions';
/// Claim promotion
/// POST /promotions/{promotionId}/claim
static const String claimPromotion = '/promotions';
// ============================================================================
// Notification Endpoints
// ============================================================================
/// Get notifications
/// GET /notifications?type={type}&page={page}&limit={limit}
static const String getNotifications = '/notifications';
/// Mark notification as read
/// POST /notifications/{notificationId}/read
static const String markNotificationAsRead = '/notifications';
/// Mark all notifications as read
/// POST /notifications/read-all
static const String markAllNotificationsAsRead = '/notifications/read-all';
/// Clear all notifications
/// DELETE /notifications
static const String clearAllNotifications = '/notifications';
/// Register FCM token for push notifications
/// POST /notifications/fcm-token
/// Body: { "token": "..." }
static const String registerFcmToken = '/notifications/fcm-token';
// ============================================================================
// Helper Methods
// ============================================================================
/// Build full URL for endpoint
///
/// Example:
/// ```dart
/// final url = ApiConstants.buildUrl('/products', {'page': '1', 'limit': '20'});
/// // Returns: https://api.worker.example.com/v1/products?page=1&limit=20
/// ```
static String buildUrl(String endpoint, [Map<String, String>? queryParams]) {
final uri = Uri.parse('$apiBaseUrl$endpoint');
if (queryParams != null && queryParams.isNotEmpty) {
return uri.replace(queryParameters: queryParams).toString();
}
return uri.toString();
}
/// Build URL with path parameters
///
/// Example:
/// ```dart
/// final url = ApiConstants.buildUrlWithParams('/products/{id}', {'id': '123'});
/// // Returns: https://api.worker.example.com/v1/products/123
/// ```
static String buildUrlWithParams(String endpoint, Map<String, String> params) {
String url = endpoint;
params.forEach((key, value) {
url = url.replaceAll('{$key}', value);
});
return '$apiBaseUrl$url';
}
}

View File

@@ -0,0 +1,521 @@
/// Application-level constants and configurations
///
/// This file contains app metadata, loyalty tier definitions, pagination settings,
/// and other application-wide configuration values.
library;
// ============================================================================
// Loyalty Member Tiers
// ============================================================================
/// Membership tier levels in the loyalty program
///
/// Ordered from lowest to highest:
/// - [MemberTier.gold]: Entry level (0-999 points)
/// - [MemberTier.platinum]: Mid level (1000-4999 points)
/// - [MemberTier.diamond]: Premium level (5000+ points)
enum MemberTier {
/// Gold tier - Entry level membership
/// Requirements: 0-999 points
/// Benefits: 1x points multiplier, basic discounts
gold,
/// Platinum tier - Mid level membership
/// Requirements: 1000-4999 points
/// Benefits: 1.5x points multiplier, priority support, special offers
platinum,
/// Diamond tier - Premium membership
/// Requirements: 5000+ points
/// Benefits: 2x points multiplier, exclusive rewards, VIP support, early access
diamond,
}
/// Extension methods for MemberTier enum
extension MemberTierExtension on MemberTier {
/// Get display name for the tier
String get displayName {
switch (this) {
case MemberTier.gold:
return 'Gold';
case MemberTier.platinum:
return 'Platinum';
case MemberTier.diamond:
return 'Diamond';
}
}
/// Get Vietnamese display name
String get displayNameVi {
switch (this) {
case MemberTier.gold:
return 'Vàng';
case MemberTier.platinum:
return 'Bạc';
case MemberTier.diamond:
return 'Kim Cương';
}
}
/// Get points multiplier for earning rewards
double get pointsMultiplier {
switch (this) {
case MemberTier.gold:
return 1.0;
case MemberTier.platinum:
return 1.5;
case MemberTier.diamond:
return 2.0;
}
}
/// Get minimum points required for this tier
int get minPoints {
switch (this) {
case MemberTier.gold:
return 0;
case MemberTier.platinum:
return 1000;
case MemberTier.diamond:
return 5000;
}
}
/// Get maximum points for this tier (null for diamond = unlimited)
int? get maxPoints {
switch (this) {
case MemberTier.gold:
return 999;
case MemberTier.platinum:
return 4999;
case MemberTier.diamond:
return null; // Unlimited
}
}
/// Get next tier (null if already at highest tier)
MemberTier? get nextTier {
switch (this) {
case MemberTier.gold:
return MemberTier.platinum;
case MemberTier.platinum:
return MemberTier.diamond;
case MemberTier.diamond:
return null; // Already at top
}
}
/// Get tier from points value
static MemberTier fromPoints(int points) {
if (points >= MemberTier.diamond.minPoints) {
return MemberTier.diamond;
} else if (points >= MemberTier.platinum.minPoints) {
return MemberTier.platinum;
} else {
return MemberTier.gold;
}
}
}
// ============================================================================
// User Types
// ============================================================================
/// Types of users in the Worker app
enum UserType {
/// Contractor - Construction project managers (Thầu thợ)
contractor,
/// Architect - Design professionals (Kiến trúc sư)
architect,
/// Distributor - Product resellers (Đại lý phân phối)
distributor,
/// Broker - Real estate and construction brokers (Môi giới)
broker,
}
/// Extension methods for UserType enum
extension UserTypeExtension on UserType {
/// Get display name
String get displayName {
switch (this) {
case UserType.contractor:
return 'Contractor';
case UserType.architect:
return 'Architect';
case UserType.distributor:
return 'Distributor';
case UserType.broker:
return 'Broker';
}
}
/// Get Vietnamese display name
String get displayNameVi {
switch (this) {
case UserType.contractor:
return 'Thầu thợ';
case UserType.architect:
return 'Kiến trúc sư';
case UserType.distributor:
return 'Đại lý phân phối';
case UserType.broker:
return 'Môi giới';
}
}
}
// ============================================================================
// Order Status
// ============================================================================
/// Order lifecycle status
enum OrderStatus {
/// Order placed, awaiting processing
pending,
/// Order is being prepared
processing,
/// Order is out for delivery
shipping,
/// Order delivered successfully
completed,
/// Order cancelled by user or system
cancelled,
}
/// Extension methods for OrderStatus enum
extension OrderStatusExtension on OrderStatus {
String get displayName {
switch (this) {
case OrderStatus.pending:
return 'Pending';
case OrderStatus.processing:
return 'Processing';
case OrderStatus.shipping:
return 'Shipping';
case OrderStatus.completed:
return 'Completed';
case OrderStatus.cancelled:
return 'Cancelled';
}
}
String get displayNameVi {
switch (this) {
case OrderStatus.pending:
return 'Chờ xử lý';
case OrderStatus.processing:
return 'Đang xử lý';
case OrderStatus.shipping:
return 'Đang giao';
case OrderStatus.completed:
return 'Hoàn thành';
case OrderStatus.cancelled:
return 'Đã hủy';
}
}
}
// ============================================================================
// Project Status
// ============================================================================
/// Construction project lifecycle status
enum ProjectStatus {
/// Project in planning phase
planning,
/// Project actively in progress
inProgress,
/// Project completed
completed,
/// Project on hold
onHold,
/// Project cancelled
cancelled,
}
extension ProjectStatusExtension on ProjectStatus {
String get displayName {
switch (this) {
case ProjectStatus.planning:
return 'Planning';
case ProjectStatus.inProgress:
return 'In Progress';
case ProjectStatus.completed:
return 'Completed';
case ProjectStatus.onHold:
return 'On Hold';
case ProjectStatus.cancelled:
return 'Cancelled';
}
}
String get displayNameVi {
switch (this) {
case ProjectStatus.planning:
return 'Lên kế hoạch';
case ProjectStatus.inProgress:
return 'Đang thực hiện';
case ProjectStatus.completed:
return 'Hoàn thành';
case ProjectStatus.onHold:
return 'Tạm dừng';
case ProjectStatus.cancelled:
return 'Đã hủy';
}
}
}
// ============================================================================
// Project Types
// ============================================================================
/// Types of construction projects
enum ProjectType {
/// Residential construction
residential,
/// Commercial construction
commercial,
/// Industrial construction
industrial,
}
extension ProjectTypeExtension on ProjectType {
String get displayName {
switch (this) {
case ProjectType.residential:
return 'Residential';
case ProjectType.commercial:
return 'Commercial';
case ProjectType.industrial:
return 'Industrial';
}
}
String get displayNameVi {
switch (this) {
case ProjectType.residential:
return 'Dân dụng';
case ProjectType.commercial:
return 'Thương mại';
case ProjectType.industrial:
return 'Công nghiệp';
}
}
}
// ============================================================================
// App Metadata
// ============================================================================
/// Application constants
class AppConstants {
// Private constructor to prevent instantiation
AppConstants._();
/// Application name
static const String appName = 'Worker';
/// Full application name
static const String appFullName = 'Worker - EuroTile & Vasta Stone';
/// Application version
static const String appVersion = '1.0.0';
/// Build number
static const int buildNumber = 1;
/// Company name
static const String companyName = 'EuroTile & Vasta Stone';
/// Support email
static const String supportEmail = 'support@worker.example.com';
/// Support phone number (Vietnamese format)
static const String supportPhone = '1900 xxxx';
/// Website URL
static const String websiteUrl = 'https://worker.example.com';
// ============================================================================
// Pagination Settings
// ============================================================================
/// Default page size for paginated lists
static const int defaultPageSize = 20;
/// Products page size
static const int productsPageSize = 20;
/// Orders page size
static const int ordersPageSize = 10;
/// Projects page size
static const int projectsPageSize = 15;
/// Notifications page size
static const int notificationsPageSize = 25;
/// Points history page size
static const int pointsHistoryPageSize = 20;
/// Maximum items to load at once
static const int maxPageSize = 100;
// ============================================================================
// Cache Settings
// ============================================================================
/// Cache duration for products (in hours)
static const int productsCacheDuration = 24;
/// Cache duration for user profile (in hours)
static const int profileCacheDuration = 1;
/// Cache duration for categories (in hours)
static const int categoriesCacheDuration = 48;
/// Maximum cache size (in MB)
static const int maxCacheSize = 100;
// ============================================================================
// OTP Settings
// ============================================================================
/// OTP code length (6 digits)
static const int otpLength = 6;
/// OTP resend cooldown (in seconds)
static const int otpResendCooldown = 60;
/// OTP validity duration (in minutes)
static const int otpValidityMinutes = 5;
// ============================================================================
// Referral Settings
// ============================================================================
/// Points earned per successful referral
static const int pointsPerReferral = 100;
/// Points earned by the referred user on signup
static const int welcomeBonusPoints = 50;
// ============================================================================
// Order Settings
// ============================================================================
/// Minimum order amount (in VND)
static const double minOrderAmount = 100000; // 100,000 VND
/// Free shipping threshold (in VND)
static const double freeShippingThreshold = 1000000; // 1,000,000 VND
/// Standard shipping fee (in VND)
static const double standardShippingFee = 30000; // 30,000 VND
/// Maximum items per order
static const int maxItemsPerOrder = 50;
// ============================================================================
// Image Settings
// ============================================================================
/// Maximum avatar size (in MB)
static const int maxAvatarSize = 5;
/// Maximum product image size (in MB)
static const int maxProductImageSize = 3;
/// Supported image formats
static const List<String> supportedImageFormats = ['jpg', 'jpeg', 'png', 'webp'];
/// Image quality for compression (0-100)
static const int imageQuality = 85;
// ============================================================================
// Search Settings
// ============================================================================
/// Minimum search query length
static const int minSearchLength = 2;
/// Search debounce delay (in milliseconds)
static const int searchDebounceMs = 500;
/// Maximum search results
static const int maxSearchResults = 50;
// ============================================================================
// Date & Time Settings
// ============================================================================
/// Default date format (Vietnamese: dd/MM/yyyy)
static const String dateFormat = 'dd/MM/yyyy';
/// Date time format
static const String dateTimeFormat = 'dd/MM/yyyy HH:mm';
/// Time format (24-hour)
static const String timeFormat = 'HH:mm';
/// Full date time format
static const String fullDateTimeFormat = 'EEEE, dd MMMM yyyy HH:mm';
// ============================================================================
// Validation Settings
// ============================================================================
/// Minimum password length
static const int minPasswordLength = 8;
/// Maximum password length
static const int maxPasswordLength = 50;
/// Minimum name length
static const int minNameLength = 2;
/// Maximum name length
static const int maxNameLength = 100;
/// Vietnamese phone number regex pattern
/// Matches: 0912345678, +84912345678, 84912345678
static const String phoneRegexPattern = r'^(0|\+84|84)[3|5|7|8|9][0-9]{8}$';
/// Email regex pattern
static const String emailRegexPattern = r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$';
// ============================================================================
// Feature Flags
// ============================================================================
/// Enable dark mode
static const bool enableDarkMode = true;
/// Enable biometric authentication
static const bool enableBiometric = true;
/// Enable push notifications
static const bool enablePushNotifications = true;
/// Enable offline mode
static const bool enableOfflineMode = true;
/// Enable analytics
static const bool enableAnalytics = true;
/// Enable crash reporting
static const bool enableCrashReporting = true;
}

View File

@@ -0,0 +1,211 @@
/// Storage constants for Hive CE (Community Edition) database
///
/// This file contains all box names, keys, and type IDs used throughout the app
/// for local data persistence and offline-first functionality.
library;
/// Hive Box Names
///
/// These are the names of Hive boxes used in the application.
/// Each box stores a specific type of data for organized storage.
class HiveBoxNames {
// Private constructor to prevent instantiation
HiveBoxNames._();
/// User authentication and profile data
static const String userBox = 'user_box';
/// Product catalog and details cache
static const String productBox = 'product_box';
/// Shopping cart items
static const String cartBox = 'cart_box';
/// Order history and details
static const String orderBox = 'order_box';
/// Construction projects
static const String projectBox = 'project_box';
/// Loyalty program transactions and points history
static const String loyaltyBox = 'loyalty_box';
/// Rewards and gifts catalog
static const String rewardsBox = 'rewards_box';
/// User settings and preferences
static const String settingsBox = 'settings_box';
/// API response cache for offline access
static const String cacheBox = 'cache_box';
/// Data sync state tracking
static const String syncStateBox = 'sync_state_box';
/// Notifications
static const String notificationBox = 'notification_box';
/// Address book
static const String addressBox = 'address_box';
/// Offline request queue for failed API calls
static const String offlineQueueBox = 'offline_queue_box';
/// Get all box names for initialization
static List<String> get allBoxes => [
userBox,
productBox,
cartBox,
orderBox,
projectBox,
loyaltyBox,
rewardsBox,
settingsBox,
cacheBox,
syncStateBox,
notificationBox,
addressBox,
offlineQueueBox,
];
}
/// Hive Type Adapter IDs
///
/// Type IDs must be unique across the application.
/// Range 0-223 is reserved for user-defined types.
/// IMPORTANT: Never change these IDs once assigned, as it will break existing data.
class HiveTypeIds {
// Private constructor to prevent instantiation
HiveTypeIds._();
// Core Models (0-9)
static const int user = 0;
static const int product = 1;
static const int cartItem = 2;
static const int order = 3;
static const int project = 4;
static const int loyaltyTransaction = 5;
// Extended Models (10-19)
static const int orderItem = 10;
static const int address = 11;
static const int category = 12;
static const int reward = 13;
static const int gift = 14;
static const int notification = 15;
static const int quote = 16;
static const int payment = 17;
static const int promotion = 18;
static const int referral = 19;
// Enums (20-29)
static const int memberTier = 20;
static const int userType = 21;
static const int orderStatus = 22;
static const int projectStatus = 23;
static const int projectType = 24;
static const int transactionType = 25;
static const int giftStatus = 26;
static const int paymentStatus = 27;
static const int notificationType = 28;
static const int paymentMethod = 29;
// Cache & Sync Models (30-39)
static const int cachedData = 30;
static const int syncState = 31;
static const int offlineRequest = 32;
}
/// Hive Storage Keys
///
/// Keys used to store and retrieve data from Hive boxes.
class HiveKeys {
// Private constructor to prevent instantiation
HiveKeys._();
// User Box Keys
static const String currentUser = 'current_user';
static const String authToken = 'auth_token';
static const String refreshToken = 'refresh_token';
static const String isLoggedIn = 'is_logged_in';
// Settings Box Keys
static const String languageCode = 'language_code';
static const String themeMode = 'theme_mode';
static const String notificationsEnabled = 'notifications_enabled';
static const String lastSyncTime = 'last_sync_time';
static const String schemaVersion = 'schema_version';
static const String encryptionEnabled = 'encryption_enabled';
// Cache Box Keys
static const String productsCacheKey = 'products_cache';
static const String categoriesCacheKey = 'categories_cache';
static const String promotionsCacheKey = 'promotions_cache';
static const String loyaltyPointsCacheKey = 'loyalty_points_cache';
static const String rewardsCacheKey = 'rewards_cache';
// Sync State Box Keys
static const String productsSyncTime = 'products_sync_time';
static const String ordersSyncTime = 'orders_sync_time';
static const String projectsSyncTime = 'projects_sync_time';
static const String loyaltySyncTime = 'loyalty_sync_time';
static const String lastFullSyncTime = 'last_full_sync_time';
// App State Keys
static const String firstLaunch = 'first_launch';
static const String onboardingCompleted = 'onboarding_completed';
static const String appVersion = 'app_version';
}
/// Cache Duration Constants
///
/// Default cache expiration durations for different data types.
class CacheDuration {
// Private constructor to prevent instantiation
CacheDuration._();
/// Product data cache (6 hours)
static const Duration products = Duration(hours: 6);
/// Category data cache (24 hours)
static const Duration categories = Duration(hours: 24);
/// Loyalty points cache (1 hour)
static const Duration loyaltyPoints = Duration(hours: 1);
/// Rewards cache (12 hours)
static const Duration rewards = Duration(hours: 12);
/// Promotions cache (2 hours)
static const Duration promotions = Duration(hours: 2);
/// User profile cache (30 minutes)
static const Duration userProfile = Duration(minutes: 30);
/// Order history cache (5 minutes)
static const Duration orderHistory = Duration(minutes: 5);
/// Projects cache (10 minutes)
static const Duration projects = Duration(minutes: 10);
}
/// Database Configuration
class HiveDatabaseConfig {
// Private constructor to prevent instantiation
HiveDatabaseConfig._();
/// Current schema version for migrations
static const int currentSchemaVersion = 1;
/// Maximum cache size in MB
static const int maxCacheSizeMB = 100;
/// Maximum number of items in offline queue
static const int maxOfflineQueueSize = 100;
/// Enable encryption for sensitive data
static const bool enableEncryption = false; // Set to true in production
/// Compaction threshold (compact when box size grows by this percentage)
static const double compactionThreshold = 0.3; // 30%
}

View File

@@ -0,0 +1,467 @@
/// UI Constants for the Worker App
///
/// Contains spacing, sizes, border radius, elevation values, and other
/// UI-related constants used throughout the app.
library;
import 'package:flutter/material.dart';
/// Spacing constants following Material Design 8dp grid system
class AppSpacing {
AppSpacing._();
/// Extra small spacing: 4dp
static const double xs = 4.0;
/// Small spacing: 8dp
static const double sm = 8.0;
/// Medium spacing: 16dp
static const double md = 16.0;
/// Large spacing: 24dp
static const double lg = 24.0;
/// Extra large spacing: 32dp
static const double xl = 32.0;
/// Extra extra large spacing: 48dp
static const double xxl = 48.0;
}
/// Border radius constants
class AppRadius {
AppRadius._();
/// Small radius: 4dp
static const double sm = 4.0;
/// Medium radius: 8dp
static const double md = 8.0;
/// Large radius: 12dp
static const double lg = 12.0;
/// Extra large radius: 16dp
static const double xl = 16.0;
/// Card radius: 12dp
static const double card = 12.0;
/// Button radius: 8dp
static const double button = 8.0;
/// Input field radius: 8dp
static const double input = 8.0;
/// Member card radius: 16dp
static const double memberCard = 16.0;
/// Circular radius for avatars and badges
static const double circular = 9999.0;
}
/// Elevation constants for Material Design
class AppElevation {
AppElevation._();
/// No elevation
static const double none = 0.0;
/// Low elevation: 2dp
static const double low = 2.0;
/// Medium elevation: 4dp
static const double medium = 4.0;
/// High elevation: 8dp
static const double high = 8.0;
/// Card elevation: 2dp
static const double card = 2.0;
/// Button elevation: 2dp
static const double button = 2.0;
/// FAB elevation: 6dp
static const double fab = 6.0;
/// Member card elevation: 8dp
static const double memberCard = 8.0;
}
/// Icon size constants
class AppIconSize {
AppIconSize._();
/// Extra small icon: 16dp
static const double xs = 16.0;
/// Small icon: 20dp
static const double sm = 20.0;
/// Medium icon: 24dp
static const double md = 24.0;
/// Large icon: 32dp
static const double lg = 32.0;
/// Extra large icon: 48dp
static const double xl = 48.0;
}
/// App bar specifications
class AppBarSpecs {
AppBarSpecs._();
/// Standard app bar height
static const double height = 56.0;
/// App bar elevation
static const double elevation = 0.0;
/// App bar icon size
static const double iconSize = AppIconSize.md;
}
/// Bottom navigation bar specifications
class BottomNavSpecs {
BottomNavSpecs._();
/// Bottom nav bar height
static const double height = 72.0;
/// Icon size for unselected state
static const double iconSize = 24.0;
/// Icon size for selected state
static const double selectedIconSize = 28.0;
/// Label font size
static const double labelFontSize = 12.0;
/// Bottom nav bar elevation
static const double elevation = 8.0;
}
/// Floating Action Button specifications
class FABSpecs {
FABSpecs._();
/// FAB size
static const double size = 56.0;
/// FAB elevation
static const double elevation = 6.0;
/// FAB icon size
static const double iconSize = 24.0;
/// FAB position from bottom-right
static const Offset position = Offset(16, 16);
}
/// Member card specifications
class MemberCardSpecs {
MemberCardSpecs._();
/// Card width (full width)
static const double width = double.infinity;
/// Card height
static const double height = 200.0;
/// Border radius
static const double borderRadius = AppRadius.memberCard;
/// Card elevation
static const double elevation = AppElevation.memberCard;
/// Card padding
static const EdgeInsets padding = EdgeInsets.all(20.0);
/// QR code size
static const double qrSize = 80.0;
/// QR code background size
static const double qrBackgroundSize = 90.0;
/// Points display font size
static const double pointsFontSize = 28.0;
/// Points display font weight
static const FontWeight pointsFontWeight = FontWeight.bold;
/// Member ID font size
static const double memberIdFontSize = 14.0;
/// Member name font size
static const double memberNameFontSize = 18.0;
}
/// Button specifications
class ButtonSpecs {
ButtonSpecs._();
/// Button height
static const double height = 48.0;
/// Button minimum width
static const double minWidth = 120.0;
/// Button border radius
static const double borderRadius = AppRadius.button;
/// Button elevation
static const double elevation = AppElevation.button;
/// Button padding
static const EdgeInsets padding = EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
);
/// Button font size
static const double fontSize = 16.0;
/// Button font weight
static const FontWeight fontWeight = FontWeight.w600;
}
/// Input field specifications
class InputFieldSpecs {
InputFieldSpecs._();
/// Input field height
static const double height = 56.0;
/// Input field border radius
static const double borderRadius = AppRadius.input;
/// Input field content padding
static const EdgeInsets contentPadding = EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
);
/// Input field font size
static const double fontSize = 16.0;
/// Label font size
static const double labelFontSize = 14.0;
/// Hint font size
static const double hintFontSize = 14.0;
}
/// Card specifications
class CardSpecs {
CardSpecs._();
/// Card border radius
static const double borderRadius = AppRadius.card;
/// Card elevation
static const double elevation = AppElevation.card;
/// Card padding
static const EdgeInsets padding = EdgeInsets.all(AppSpacing.md);
/// Card margin
static const EdgeInsets margin = EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
);
}
/// Product card specifications
class ProductCardSpecs {
ProductCardSpecs._();
/// Product image aspect ratio (width / height)
static const double imageAspectRatio = 1.0;
/// Product card border radius
static const double borderRadius = AppRadius.card;
/// Product card elevation
static const double elevation = AppElevation.card;
/// Product card padding
static const EdgeInsets padding = EdgeInsets.all(AppSpacing.sm);
/// Product name max lines
static const int nameMaxLines = 2;
/// Product name font size
static const double nameFontSize = 14.0;
/// Product price font size
static const double priceFontSize = 16.0;
/// Product price font weight
static const FontWeight priceFontWeight = FontWeight.bold;
}
/// Order card specifications
class OrderCardSpecs {
OrderCardSpecs._();
/// Order number font size
static const double orderNumberFontSize = 16.0;
/// Order number font weight
static const FontWeight orderNumberFontWeight = FontWeight.w600;
/// Order date font size
static const double dateFontSize = 12.0;
/// Order total font size
static const double totalFontSize = 18.0;
/// Order total font weight
static const FontWeight totalFontWeight = FontWeight.bold;
}
/// Status badge specifications
class StatusBadgeSpecs {
StatusBadgeSpecs._();
/// Badge height
static const double height = 24.0;
/// Badge border radius
static const double borderRadius = 12.0;
/// Badge padding
static const EdgeInsets padding = EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 2.0,
);
/// Badge font size
static const double fontSize = 11.0;
/// Badge font weight
static const FontWeight fontWeight = FontWeight.w600;
}
/// Avatar specifications
class AvatarSpecs {
AvatarSpecs._();
/// Small avatar size
static const double sm = 32.0;
/// Medium avatar size
static const double md = 48.0;
/// Large avatar size
static const double lg = 64.0;
/// Extra large avatar size
static const double xl = 96.0;
}
/// Animation durations
class AppDuration {
AppDuration._();
/// Short animation: 200ms
static const Duration short = Duration(milliseconds: 200);
/// Medium animation: 300ms
static const Duration medium = Duration(milliseconds: 300);
/// Long animation: 500ms
static const Duration long = Duration(milliseconds: 500);
/// Page transition duration
static const Duration pageTransition = medium;
/// Fade in duration
static const Duration fadeIn = medium;
/// Shimmer animation duration
static const Duration shimmer = Duration(milliseconds: 1500);
}
/// Grid specifications
class GridSpecs {
GridSpecs._();
/// Product grid cross axis count (columns)
static const int productGridColumns = 2;
/// Product grid cross axis spacing
static const double productGridCrossSpacing = AppSpacing.md;
/// Product grid main axis spacing
static const double productGridMainSpacing = AppSpacing.md;
/// Quick action grid cross axis count
static const int quickActionColumns = 3;
/// Quick action grid cross axis spacing
static const double quickActionCrossSpacing = AppSpacing.md;
/// Quick action grid main axis spacing
static const double quickActionMainSpacing = AppSpacing.md;
}
/// List specifications
class ListSpecs {
ListSpecs._();
/// List item padding
static const EdgeInsets itemPadding = EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
);
/// List item divider height
static const double dividerHeight = 1.0;
/// List item divider indent
static const double dividerIndent = AppSpacing.md;
}
/// Image specifications
class ImageSpecs {
ImageSpecs._();
/// Product image cache width
static const int productImageCacheWidth = 400;
/// Product image cache height
static const int productImageCacheHeight = 400;
/// Avatar image cache width
static const int avatarImageCacheWidth = 200;
/// Avatar image cache height
static const int avatarImageCacheHeight = 200;
/// Banner image cache width
static const int bannerImageCacheWidth = 800;
/// Banner image cache height
static const int bannerImageCacheHeight = 400;
}
/// Screen breakpoints for responsive design
class Breakpoints {
Breakpoints._();
/// Small screen (phone)
static const double sm = 600.0;
/// Medium screen (tablet)
static const double md = 960.0;
/// Large screen (desktop)
static const double lg = 1280.0;
/// Extra large screen
static const double xl = 1920.0;
}

View File

@@ -0,0 +1,119 @@
# Hive CE Quick Start Guide
## 1. Initialize in main.dart
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/database/hive_initializer.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive
await HiveInitializer.initialize(verbose: true);
runApp(const ProviderScope(child: MyApp()));
}
```
## 2. Save & Retrieve Data
```dart
import 'package:worker/core/database/database.dart';
final dbManager = DatabaseManager();
// Save
await dbManager.save(
boxName: HiveBoxNames.productBox,
key: 'product_123',
value: product,
);
// Get
final product = dbManager.get(
boxName: HiveBoxNames.productBox,
key: 'product_123',
);
```
## 3. Cache with Expiration
```dart
// Save to cache
await dbManager.saveToCache(
key: HiveKeys.productsCacheKey,
data: products,
);
// Get from cache
final cached = dbManager.getFromCache<List<Product>>(
key: HiveKeys.productsCacheKey,
maxAge: CacheDuration.products, // 6 hours
);
if (cached == null) {
// Cache expired - fetch fresh data
}
```
## 4. Create New Model
```dart
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
part 'product_model.g.dart';
@HiveType(typeId: HiveTypeIds.product)
class ProductModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
ProductModel({required this.id, required this.name});
}
```
Then run:
```bash
dart run build_runner build --delete-conflicting-outputs
```
## 5. Logout (Clear User Data)
```dart
await HiveInitializer.logout();
```
## Available Boxes
- `HiveBoxNames.userBox` - User profile
- `HiveBoxNames.productBox` - Products
- `HiveBoxNames.cartBox` - Cart items
- `HiveBoxNames.orderBox` - Orders
- `HiveBoxNames.projectBox` - Projects
- `HiveBoxNames.loyaltyBox` - Loyalty data
- `HiveBoxNames.settingsBox` - Settings
- `HiveBoxNames.cacheBox` - API cache
- `HiveBoxNames.notificationBox` - Notifications
See `/lib/core/constants/storage_constants.dart` for complete list.
## Cache Durations
Pre-configured expiration times:
- `CacheDuration.products` - 6 hours
- `CacheDuration.categories` - 24 hours
- `CacheDuration.loyaltyPoints` - 1 hour
- `CacheDuration.rewards` - 12 hours
- `CacheDuration.promotions` - 2 hours
## Need More Info?
- Full Documentation: `/lib/core/database/README.md`
- Setup Summary: `/HIVE_SETUP.md`
- Storage Constants: `/lib/core/constants/storage_constants.dart`

478
lib/core/database/README.md Normal file
View File

@@ -0,0 +1,478 @@
# Hive CE Database Setup
This directory contains the Hive CE (Community Edition) database configuration and services for the Worker Flutter app.
## Overview
The app uses Hive CE for offline-first local data persistence. Hive is a lightweight, fast NoSQL database written in pure Dart, perfect for Flutter applications.
## Key Features
- **Offline-First**: All data is stored locally and synced with the backend
- **Fast Performance**: Hive is optimized for speed with minimal overhead
- **Type-Safe**: Uses type adapters for strong typing
- **Encryption Support**: Optional AES encryption for sensitive data
- **Auto-Compaction**: Automatic database maintenance and cleanup
- **Migration Support**: Built-in schema versioning and migrations
## File Structure
```
lib/core/database/
├── README.md # This file
├── hive_service.dart # Main Hive initialization and lifecycle management
├── database_manager.dart # High-level database operations
└── models/
├── cached_data.dart # Generic cache wrapper model
└── enums.dart # All enum type adapters
```
## Setup Instructions
### 1. Install Dependencies
The required packages are already in `pubspec.yaml`:
```yaml
dependencies:
hive_ce: ^2.6.0
hive_ce_flutter: ^2.1.0
dev_dependencies:
hive_ce_generator: ^1.6.0
build_runner: ^2.4.11
```
Run:
```bash
flutter pub get
```
### 2. Generate Type Adapters
After creating Hive models with `@HiveType` annotations, run:
```bash
dart run build_runner build --delete-conflicting-outputs
```
Or for continuous watching during development:
```bash
dart run build_runner watch --delete-conflicting-outputs
```
### 3. Initialize Hive in main.dart
```dart
import 'package:flutter/material.dart';
import 'core/database/hive_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Hive
final hiveService = HiveService();
await hiveService.initialize();
runApp(const MyApp());
}
```
## Creating Hive Models
### Basic Model Example
```dart
import 'package:hive_ce/hive.dart';
import '../../constants/storage_constants.dart';
part 'user_model.g.dart'; // Generated file
@HiveType(typeId: HiveTypeIds.user)
class UserModel extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String name;
@HiveField(2)
final String email;
UserModel({
required this.id,
required this.name,
required this.email,
});
}
```
### Enum Example
```dart
@HiveType(typeId: HiveTypeIds.memberTier)
enum MemberTier {
@HiveField(0)
gold,
@HiveField(1)
platinum,
@HiveField(2)
diamond,
}
```
### Important Rules
1. **Type IDs must be unique** across the entire app (0-223 for user types)
2. **Never change field numbers** once assigned - it will break existing data
3. **Use `part` directive** to include generated adapter file
4. **Extend HiveObject** for model classes (optional but recommended for auto-save)
5. **Register adapters** before opening boxes (handled by HiveService)
## Box Management
### Available Boxes
The app uses these pre-configured boxes (see `storage_constants.dart`):
- `user_box` - User profile and auth data (encrypted)
- `product_box` - Product catalog cache
- `cart_box` - Shopping cart items (encrypted)
- `order_box` - Order history (encrypted)
- `project_box` - Construction projects (encrypted)
- `loyalty_box` - Loyalty transactions (encrypted)
- `rewards_box` - Rewards catalog
- `settings_box` - App settings
- `cache_box` - Generic API cache
- `sync_state_box` - Sync timestamps
- `notification_box` - Notifications
- `address_box` - Delivery addresses (encrypted)
- `offline_queue_box` - Failed API requests queue (encrypted)
### Using Boxes
```dart
// Using DatabaseManager (recommended)
final dbManager = DatabaseManager();
// Save data
await dbManager.save(
boxName: HiveBoxNames.productBox,
key: 'product_123',
value: product,
);
// Get data
final product = dbManager.get(
boxName: HiveBoxNames.productBox,
key: 'product_123',
);
// Get all
final products = dbManager.getAll(boxName: HiveBoxNames.productBox);
```
## Caching Strategy
### Save to Cache
```dart
final dbManager = DatabaseManager();
await dbManager.saveToCache(
key: HiveKeys.productsCacheKey,
data: products,
);
```
### Get from Cache
```dart
final products = dbManager.getFromCache<List<Product>>(
key: HiveKeys.productsCacheKey,
maxAge: CacheDuration.products, // 6 hours
);
if (products == null) {
// Cache miss or expired - fetch from API
final freshProducts = await api.getProducts();
await dbManager.saveToCache(
key: HiveKeys.productsCacheKey,
data: freshProducts,
);
}
```
### Check Cache Validity
```dart
final isValid = dbManager.isCacheValid(
key: HiveKeys.productsCacheKey,
maxAge: CacheDuration.products,
);
if (!isValid) {
// Refresh cache
}
```
## Offline Queue
Handle failed API requests when offline:
```dart
// Add to queue when API call fails
await dbManager.addToOfflineQueue({
'endpoint': '/api/orders',
'method': 'POST',
'body': orderData,
});
// Process queue when back online
final queue = dbManager.getOfflineQueue();
for (var i = 0; i < queue.length; i++) {
try {
await api.request(queue[i]);
await dbManager.removeFromOfflineQueue(i);
} catch (e) {
// Keep in queue for next retry
}
}
```
## Data Synchronization
Track sync state for different data types:
```dart
// Update sync timestamp
await dbManager.updateSyncTime(HiveKeys.productsSyncTime);
// Get last sync time
final lastSync = dbManager.getLastSyncTime(HiveKeys.productsSyncTime);
// Check if needs sync
final needsSync = dbManager.needsSync(
dataType: HiveKeys.productsSyncTime,
syncInterval: Duration(hours: 6),
);
```
## Encryption
Enable encryption for sensitive data in `storage_constants.dart`:
```dart
class HiveDatabaseConfig {
static const bool enableEncryption = true;
}
```
Generate and store encryption key securely:
```dart
// Generate key
final encryptionKey = HiveService.generateEncryptionKey();
// Store securely using flutter_secure_storage
final secureStorage = FlutterSecureStorage();
await secureStorage.write(
key: 'hive_encryption_key',
value: base64Encode(encryptionKey),
);
// Initialize with key
final storedKey = await secureStorage.read(key: 'hive_encryption_key');
await hiveService.initialize(
encryptionKey: base64Decode(storedKey!),
);
```
## Migrations
Handle schema changes:
```dart
// In hive_service.dart, add migration logic:
Future<void> _migrateToVersion(int version) async {
switch (version) {
case 2:
await _migrateV1ToV2();
break;
}
}
Future<void> _migrateV1ToV2() async {
// Example: Add new field to existing data
final userBox = Hive.box(HiveBoxNames.userBox);
for (var key in userBox.keys) {
final user = userBox.get(key);
// Update user data structure
await userBox.put(key, updatedUser);
}
}
```
## Database Maintenance
### Clear Expired Cache
```dart
await dbManager.clearExpiredCache();
```
### Compact Boxes
```dart
final hiveService = HiveService();
// Compaction happens automatically during initialization
```
### Clear User Data (Logout)
```dart
await hiveService.clearUserData();
```
### Clear All Data
```dart
await hiveService.clearAllData();
```
### Get Statistics
```dart
final stats = dbManager.getStatistics();
dbManager.printStatistics();
```
## Best Practices
1. **Always initialize Hive before using any boxes**
2. **Use DatabaseManager for common operations**
3. **Cache frequently accessed data**
4. **Set appropriate cache expiration times**
5. **Handle errors gracefully** - Hive operations can fail
6. **Use transactions for multiple related updates**
7. **Compact boxes periodically** for optimal performance
8. **Never store large files in Hive** - use file system instead
9. **Test migrations thoroughly** before release
10. **Monitor database size** in production
## Debugging
### Print Box Contents
```dart
final box = Hive.box(HiveBoxNames.productBox);
print('Box length: ${box.length}');
print('Keys: ${box.keys}');
print('Values: ${box.values}');
```
### Check Box Location
```dart
print('Hive path: ${Hive.box(HiveBoxNames.settingsBox).path}');
```
### View Statistics
```dart
DatabaseManager().printStatistics();
```
## Troubleshooting
### "Box not found" Error
- Ensure Hive is initialized before accessing boxes
- Check that box name is correct
### "TypeAdapter not registered" Error
- Run `build_runner` to generate adapters
- Ensure adapter is registered in `HiveService._registerTypeAdapters()`
### "Cannot write null values" Error
- Make fields nullable with `?` or provide default values
- Check that HiveField annotations are correct
### Data Corruption
- Enable backup/restore functionality
- Implement data validation before saving
- Use try-catch blocks around Hive operations
## Testing
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
void main() {
setUp(() async {
await Hive.initFlutter();
// Register test adapters
});
tearDown(() async {
await Hive.deleteFromDisk();
});
test('Save and retrieve user', () async {
final box = await Hive.openBox('test_box');
await box.put('user', UserModel(id: '1', name: 'Test'));
final user = box.get('user');
expect(user.name, 'Test');
});
}
```
## Resources
- [Hive CE Documentation](https://github.com/IO-Design-Team/hive_ce)
- [Original Hive Documentation](https://docs.hivedb.dev/)
- [Flutter Offline-First Best Practices](https://flutter.dev/docs/cookbook/persistence)
## Type Adapter Registry
### Registered Type IDs (0-223)
| Type ID | Model | Status |
|---------|-------|--------|
| 0 | UserModel | TODO |
| 1 | ProductModel | TODO |
| 2 | CartItemModel | TODO |
| 3 | OrderModel | TODO |
| 4 | ProjectModel | TODO |
| 5 | LoyaltyTransactionModel | TODO |
| 10 | OrderItemModel | TODO |
| 11 | AddressModel | TODO |
| 12 | CategoryModel | TODO |
| 13 | RewardModel | TODO |
| 14 | GiftModel | TODO |
| 15 | NotificationModel | TODO |
| 16 | QuoteModel | TODO |
| 17 | PaymentModel | TODO |
| 18 | PromotionModel | TODO |
| 19 | ReferralModel | TODO |
| 20 | MemberTier (enum) | Created |
| 21 | UserType (enum) | Created |
| 22 | OrderStatus (enum) | Created |
| 23 | ProjectStatus (enum) | Created |
| 24 | ProjectType (enum) | Created |
| 25 | TransactionType (enum) | Created |
| 26 | GiftStatus (enum) | Created |
| 27 | PaymentStatus (enum) | Created |
| 28 | NotificationType (enum) | Created |
| 29 | PaymentMethod (enum) | Created |
| 30 | CachedData | Created |
| 31 | SyncState | TODO |
| 32 | OfflineRequest | TODO |
**IMPORTANT**: Never reuse or change these type IDs once assigned!

View File

@@ -0,0 +1,25 @@
/// Hive CE Database Export
///
/// This file provides a convenient way to import all database-related
/// classes and utilities in a single import.
///
/// Usage:
/// ```dart
/// import 'package:worker/core/database/database.dart';
/// ```
library;
// Constants
export 'package:worker/core/constants/storage_constants.dart';
// Services
export 'package:worker/core/database/database_manager.dart';
export 'package:worker/core/database/hive_initializer.dart';
export 'package:worker/core/database/hive_service.dart';
// Models
export 'package:worker/core/database/models/cached_data.dart';
export 'package:worker/core/database/models/enums.dart';
// Auto-generated registrar
export 'package:worker/hive_registrar.g.dart';

View File

@@ -0,0 +1,411 @@
import 'package:flutter/foundation.dart';
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/core/database/hive_service.dart';
/// Database Manager for common Hive operations
///
/// Provides high-level database operations and utilities for working
/// with Hive boxes across the application.
///
/// Features:
/// - CRUD operations with error handling
/// - Cache management with expiration
/// - Bulk operations
/// - Data validation
/// - Sync state tracking
class DatabaseManager {
DatabaseManager({HiveService? hiveService})
: _hiveService = hiveService ?? HiveService();
final HiveService _hiveService;
/// Get a box safely
Box<T> _getBox<T>(String boxName) {
if (!_hiveService.isBoxOpen(boxName)) {
throw HiveError('Box $boxName is not open. Initialize HiveService first.');
}
return _hiveService.getBox<T>(boxName);
}
// ==================== Generic CRUD Operations ====================
/// Save a value to a box
Future<void> save<T>({
required String boxName,
required String key,
required T value,
}) async {
try {
final box = _getBox<T>(boxName);
await box.put(key, value);
debugPrint('DatabaseManager: Saved $key to $boxName');
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error saving $key to $boxName: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Get a value from a box
T? get<T>({
required String boxName,
required String key,
T? defaultValue,
}) {
try {
final box = _getBox<T>(boxName);
return box.get(key, defaultValue: defaultValue);
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error getting $key from $boxName: $e');
debugPrint('StackTrace: $stackTrace');
return defaultValue;
}
}
/// Delete a value from a box
Future<void> delete({
required String boxName,
required String key,
}) async {
try {
final box = _getBox<dynamic>(boxName);
await box.delete(key);
debugPrint('DatabaseManager: Deleted $key from $boxName');
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error deleting $key from $boxName: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Check if a key exists in a box
bool exists({
required String boxName,
required String key,
}) {
try {
final box = _getBox<dynamic>(boxName);
return box.containsKey(key);
} catch (e) {
debugPrint('DatabaseManager: Error checking $key in $boxName: $e');
return false;
}
}
/// Get all values from a box
List<T> getAll<T>({required String boxName}) {
try {
final box = _getBox<T>(boxName);
return box.values.toList();
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error getting all from $boxName: $e');
debugPrint('StackTrace: $stackTrace');
return [];
}
}
/// Save multiple values to a box
Future<void> saveAll<T>({
required String boxName,
required Map<String, T> entries,
}) async {
try {
final box = _getBox<T>(boxName);
await box.putAll(entries);
debugPrint('DatabaseManager: Saved ${entries.length} items to $boxName');
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error saving all to $boxName: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Clear all data from a box
Future<void> clearBox({required String boxName}) async {
try {
final box = _getBox<dynamic>(boxName);
await box.clear();
debugPrint('DatabaseManager: Cleared $boxName');
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error clearing $boxName: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
// ==================== Cache Operations ====================
/// Save data to cache with timestamp
Future<void> saveToCache<T>({
required String key,
required T data,
}) async {
try {
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
await cacheBox.put(key, {
'data': data,
'timestamp': DateTime.now().toIso8601String(),
});
debugPrint('DatabaseManager: Cached $key');
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error caching $key: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Get data from cache
///
/// Returns null if cache is expired or doesn't exist
T? getFromCache<T>({
required String key,
Duration? maxAge,
}) {
try {
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
if (cachedData == null) {
debugPrint('DatabaseManager: Cache miss for $key');
return null;
}
// Check if cache is expired
if (maxAge != null) {
final timestamp = DateTime.parse(cachedData['timestamp'] as String);
final age = DateTime.now().difference(timestamp);
if (age > maxAge) {
debugPrint('DatabaseManager: Cache expired for $key (age: $age)');
return null;
}
}
debugPrint('DatabaseManager: Cache hit for $key');
return cachedData['data'] as T?;
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error getting cache $key: $e');
debugPrint('StackTrace: $stackTrace');
return null;
}
}
/// Check if cache is valid (exists and not expired)
bool isCacheValid({
required String key,
Duration? maxAge,
}) {
try {
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
if (cachedData == null) return false;
if (maxAge != null) {
final timestamp = DateTime.parse(cachedData['timestamp'] as String);
final age = DateTime.now().difference(timestamp);
return age <= maxAge;
}
return true;
} catch (e) {
debugPrint('DatabaseManager: Error checking cache validity $key: $e');
return false;
}
}
/// Clear expired cache entries
Future<void> clearExpiredCache() async {
try {
final cacheBox = _getBox<dynamic>(HiveBoxNames.cacheBox);
final keysToDelete = <String>[];
for (final key in cacheBox.keys) {
final cachedData = cacheBox.get(key) as Map<dynamic, dynamic>?;
if (cachedData != null) {
try {
final timestamp = DateTime.parse(cachedData['timestamp'] as String);
final age = DateTime.now().difference(timestamp);
// Use default max age of 24 hours
if (age > const Duration(hours: 24)) {
keysToDelete.add(key as String);
}
} catch (e) {
// Invalid cache entry, mark for deletion
keysToDelete.add(key as String);
}
}
}
for (final key in keysToDelete) {
await cacheBox.delete(key);
}
debugPrint('DatabaseManager: Cleared ${keysToDelete.length} expired cache entries');
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error clearing expired cache: $e');
debugPrint('StackTrace: $stackTrace');
}
}
// ==================== Sync State Operations ====================
/// Update sync timestamp for a data type
Future<void> updateSyncTime(String dataType) async {
try {
final syncBox = _getBox<dynamic>(HiveBoxNames.syncStateBox);
await syncBox.put(dataType, DateTime.now().toIso8601String());
debugPrint('DatabaseManager: Updated sync time for $dataType');
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error updating sync time for $dataType: $e');
debugPrint('StackTrace: $stackTrace');
}
}
/// Get last sync time for a data type
DateTime? getLastSyncTime(String dataType) {
try {
final syncBox = _getBox<dynamic>(HiveBoxNames.syncStateBox);
final timestamp = syncBox.get(dataType);
if (timestamp == null) return null;
return DateTime.parse(timestamp as String);
} catch (e) {
debugPrint('DatabaseManager: Error getting sync time for $dataType: $e');
return null;
}
}
/// Check if data needs sync
bool needsSync({
required String dataType,
required Duration syncInterval,
}) {
final lastSync = getLastSyncTime(dataType);
if (lastSync == null) return true;
final timeSinceSync = DateTime.now().difference(lastSync);
return timeSinceSync > syncInterval;
}
// ==================== Settings Operations ====================
/// Save a setting
Future<void> saveSetting<T>({
required String key,
required T value,
}) async {
await save(
boxName: HiveBoxNames.settingsBox,
key: key,
value: value,
);
}
/// Get a setting
T? getSetting<T>({
required String key,
T? defaultValue,
}) {
return get(
boxName: HiveBoxNames.settingsBox,
key: key,
defaultValue: defaultValue,
);
}
// ==================== Offline Queue Operations ====================
/// Add request to offline queue
Future<void> addToOfflineQueue(Map<String, dynamic> request) async {
try {
final queueBox = _getBox<dynamic>(HiveBoxNames.offlineQueueBox);
// Check queue size limit
if (queueBox.length >= HiveDatabaseConfig.maxOfflineQueueSize) {
debugPrint('DatabaseManager: Offline queue is full, removing oldest item');
await queueBox.deleteAt(0);
}
await queueBox.add({
...request,
'timestamp': DateTime.now().toIso8601String(),
});
debugPrint('DatabaseManager: Added request to offline queue');
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error adding to offline queue: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Get all offline queue items
List<Map<String, dynamic>> getOfflineQueue() {
try {
final queueBox = _getBox<dynamic>(HiveBoxNames.offlineQueueBox);
return queueBox.values
.map((e) => Map<String, dynamic>.from(e as Map<dynamic, dynamic>))
.toList();
} catch (e) {
debugPrint('DatabaseManager: Error getting offline queue: $e');
return [];
}
}
/// Remove item from offline queue
Future<void> removeFromOfflineQueue(int index) async {
try {
final queueBox = _getBox<dynamic>(HiveBoxNames.offlineQueueBox);
await queueBox.deleteAt(index);
debugPrint('DatabaseManager: Removed item $index from offline queue');
} catch (e, stackTrace) {
debugPrint('DatabaseManager: Error removing from offline queue: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Clear offline queue
Future<void> clearOfflineQueue() async {
await clearBox(boxName: HiveBoxNames.offlineQueueBox);
}
// ==================== Statistics ====================
/// Get database statistics
Map<String, dynamic> getStatistics() {
final stats = <String, dynamic>{};
for (final boxName in HiveBoxNames.allBoxes) {
try {
if (_hiveService.isBoxOpen(boxName)) {
final box = _getBox<dynamic>(boxName);
stats[boxName] = {
'count': box.length,
'keys': box.keys.length,
};
}
} catch (e) {
stats[boxName] = {'error': e.toString()};
}
}
return stats;
}
/// Print database statistics
void printStatistics() {
final stats = getStatistics();
debugPrint('=== Hive Database Statistics ===');
stats.forEach((boxName, data) {
debugPrint('$boxName: $data');
});
debugPrint('================================');
}
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter/foundation.dart';
import 'package:worker/core/database/database_manager.dart';
import 'package:worker/core/database/hive_service.dart';
/// Hive Database Initializer
///
/// Provides a simple API for initializing the Hive database
/// in the main.dart file.
///
/// Example usage:
/// ```dart
/// void main() async {
/// WidgetsFlutterBinding.ensureInitialized();
///
/// // Initialize Hive
/// await HiveInitializer.initialize();
///
/// runApp(const MyApp());
/// }
/// ```
class HiveInitializer {
/// Initialize Hive database
///
/// This method should be called once during app startup.
/// It initializes Hive, registers adapters, and opens boxes.
///
/// [enableEncryption] - Enable AES encryption for sensitive boxes
/// [encryptionKey] - Optional custom encryption key (256-bit)
/// [verbose] - Enable verbose logging for debugging
static Future<void> initialize({
bool enableEncryption = false,
List<int>? encryptionKey,
bool verbose = false,
}) async {
try {
if (verbose) {
debugPrint('HiveInitializer: Starting initialization...');
}
// Get HiveService instance
final hiveService = HiveService();
// Initialize Hive
await hiveService.initialize(
encryptionKey: enableEncryption ? encryptionKey : null,
);
// Perform initial maintenance
if (verbose) {
debugPrint('HiveInitializer: Performing initial maintenance...');
}
final dbManager = DatabaseManager();
// Clear expired cache on app start
await dbManager.clearExpiredCache();
// Print statistics in debug mode
if (verbose && kDebugMode) {
dbManager.printStatistics();
}
if (verbose) {
debugPrint('HiveInitializer: Initialization complete');
}
} catch (e, stackTrace) {
debugPrint('HiveInitializer: Initialization failed: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Close Hive database
///
/// Should be called when app is terminating.
/// Usually not needed for normal app lifecycle.
static Future<void> close() async {
final hiveService = HiveService();
await hiveService.close();
}
/// Reset database (clear all data)
///
/// WARNING: This will delete all local data!
/// Use only for logout or app reset functionality.
static Future<void> reset() async {
final hiveService = HiveService();
await hiveService.clearAllData();
}
/// Clear user data (logout)
///
/// Clears user-specific data while preserving app settings and cache.
static Future<void> logout() async {
final hiveService = HiveService();
await hiveService.clearUserData();
}
/// Get database statistics
///
/// Returns statistics about all Hive boxes.
static Map<String, dynamic> getStatistics() {
final dbManager = DatabaseManager();
return dbManager.getStatistics();
}
/// Print database statistics (debug only)
static void printStatistics() {
if (kDebugMode) {
final dbManager = DatabaseManager();
dbManager.printStatistics();
}
}
}

View File

@@ -0,0 +1,409 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/hive_registrar.g.dart';
/// Hive CE (Community Edition) Database Service
///
/// This service manages the initialization, configuration, and lifecycle
/// of the Hive database for offline-first functionality.
///
/// Features:
/// - Box initialization and registration
/// - Type adapter registration
/// - Encryption support
/// - Database compaction
/// - Migration handling
/// - Error recovery
class HiveService {
HiveService._internal();
// Singleton pattern
factory HiveService() => _instance;
static final HiveService _instance = HiveService._internal();
/// Indicates whether Hive has been initialized
bool _isInitialized = false;
bool get isInitialized => _isInitialized;
/// Encryption cipher (if enabled)
HiveAesCipher? _encryptionCipher;
/// Initialize Hive database
///
/// This should be called once during app startup, before any
/// Hive operations are performed.
///
/// [encryptionKey] - Optional 256-bit encryption key for secure storage
/// If not provided and encryption is enabled, a new key will be generated.
Future<void> initialize({List<int>? encryptionKey}) async {
if (_isInitialized) {
debugPrint('HiveService: Already initialized');
return;
}
try {
debugPrint('HiveService: Initializing Hive CE...');
// Initialize Hive for Flutter
await Hive.initFlutter();
// Setup encryption if enabled
if (HiveDatabaseConfig.enableEncryption) {
_encryptionCipher = HiveAesCipher(
encryptionKey ?? Hive.generateSecureKey(),
);
debugPrint('HiveService: Encryption enabled');
}
// Register all type adapters
await _registerTypeAdapters();
// Open all boxes
await _openBoxes();
// Check and perform migrations if needed
await _performMigrations();
// Perform initial cleanup/compaction if needed
await _performMaintenance();
_isInitialized = true;
debugPrint('HiveService: Initialization complete');
} catch (e, stackTrace) {
debugPrint('HiveService: Initialization failed: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Register all Hive type adapters
///
/// Type adapters must be registered before opening boxes.
/// Uses auto-generated registrar from hive_registrar.g.dart
Future<void> _registerTypeAdapters() async {
debugPrint('HiveService: Registering type adapters...');
// Register all adapters using the auto-generated extension
// This automatically registers:
// - CachedDataAdapter (typeId: 30)
// - All enum adapters (typeIds: 20-29)
Hive.registerAdapters();
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.memberTier) ? "" : ""} MemberTier adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.userType) ? "" : ""} UserType adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.orderStatus) ? "" : ""} OrderStatus adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectStatus) ? "" : ""} ProjectStatus adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.projectType) ? "" : ""} ProjectType adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.transactionType) ? "" : ""} TransactionType adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.giftStatus) ? "" : ""} GiftStatus adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentStatus) ? "" : ""} PaymentStatus adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.notificationType) ? "" : ""} NotificationType adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "" : ""} PaymentMethod adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "" : ""} CachedData adapter');
// TODO: Register actual model type adapters when models are created
// These will be added to the auto-generated registrar when models are created
// Example:
// - UserModel (typeId: 0)
// - ProductModel (typeId: 1)
// - CartItemModel (typeId: 2)
// - OrderModel (typeId: 3)
// - ProjectModel (typeId: 4)
// - LoyaltyTransactionModel (typeId: 5)
// etc.
debugPrint('HiveService: Type adapters registered successfully');
}
/// Open all Hive boxes
///
/// Opens boxes for immediate access. Some boxes use encryption if enabled.
Future<void> _openBoxes() async {
debugPrint('HiveService: Opening boxes...');
try {
// Open non-encrypted boxes
await Future.wait([
// Settings and preferences (non-sensitive)
Hive.openBox<dynamic>(HiveBoxNames.settingsBox),
// Cache boxes (non-sensitive)
Hive.openBox<dynamic>(HiveBoxNames.cacheBox),
Hive.openBox<dynamic>(HiveBoxNames.syncStateBox),
// Product and catalog data (non-sensitive)
Hive.openBox<dynamic>(HiveBoxNames.productBox),
Hive.openBox<dynamic>(HiveBoxNames.rewardsBox),
// Notification box (non-sensitive)
Hive.openBox<dynamic>(HiveBoxNames.notificationBox),
]);
// Open potentially encrypted boxes (sensitive data)
final encryptedBoxes = [
HiveBoxNames.userBox,
HiveBoxNames.cartBox,
HiveBoxNames.orderBox,
HiveBoxNames.projectBox,
HiveBoxNames.loyaltyBox,
HiveBoxNames.addressBox,
HiveBoxNames.offlineQueueBox,
];
for (final boxName in encryptedBoxes) {
await Hive.openBox<dynamic>(
boxName,
encryptionCipher: _encryptionCipher,
);
}
debugPrint('HiveService: All boxes opened successfully');
} catch (e, stackTrace) {
debugPrint('HiveService: Error opening boxes: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Perform database migrations
///
/// Handles schema version upgrades and data migrations.
Future<void> _performMigrations() async {
final settingsBox = Hive.box<dynamic>(HiveBoxNames.settingsBox);
final currentVersion = settingsBox.get(
HiveKeys.schemaVersion,
defaultValue: 0,
) as int;
debugPrint('HiveService: Current schema version: $currentVersion');
debugPrint('HiveService: Target schema version: ${HiveDatabaseConfig.currentSchemaVersion}');
if (currentVersion < HiveDatabaseConfig.currentSchemaVersion) {
debugPrint('HiveService: Performing migrations...');
// Perform migrations sequentially
for (int version = currentVersion + 1;
version <= HiveDatabaseConfig.currentSchemaVersion;
version++) {
await _migrateToVersion(version);
}
// Update schema version
await settingsBox.put(
HiveKeys.schemaVersion,
HiveDatabaseConfig.currentSchemaVersion,
);
debugPrint('HiveService: Migrations complete');
} else {
debugPrint('HiveService: No migrations needed');
}
}
/// Migrate to a specific version
Future<void> _migrateToVersion(int version) async {
debugPrint('HiveService: Migrating to version $version');
switch (version) {
case 1:
// Initial version - no migration needed
break;
// Future migrations will be added here
// case 2:
// await _migrateV1ToV2();
// break;
default:
debugPrint('HiveService: Unknown migration version: $version');
}
}
/// Perform database maintenance
///
/// Includes compaction, cleanup of expired cache, etc.
Future<void> _performMaintenance() async {
debugPrint('HiveService: Performing maintenance...');
try {
// Compact boxes if needed
await _compactBoxes();
// Clear expired cache
await _clearExpiredCache();
// Limit offline queue size
await _limitOfflineQueue();
debugPrint('HiveService: Maintenance complete');
} catch (e, stackTrace) {
debugPrint('HiveService: Maintenance error: $e');
debugPrint('StackTrace: $stackTrace');
// Don't throw - maintenance errors shouldn't prevent app startup
}
}
/// Compact boxes to reduce file size
Future<void> _compactBoxes() async {
for (final boxName in HiveBoxNames.allBoxes) {
try {
if (Hive.isBoxOpen(boxName)) {
final box = Hive.box<dynamic>(boxName);
await box.compact();
debugPrint('HiveService: Compacted box: $boxName');
}
} catch (e) {
debugPrint('HiveService: Error compacting box $boxName: $e');
}
}
}
/// Clear expired cache entries
Future<void> _clearExpiredCache() async {
final cacheBox = Hive.box<dynamic>(HiveBoxNames.cacheBox);
// TODO: Implement cache expiration logic
// This will be implemented when cache models are created
debugPrint('HiveService: Cleared expired cache entries');
}
/// Limit offline queue size
Future<void> _limitOfflineQueue() async {
final queueBox = Hive.box<dynamic>(HiveBoxNames.offlineQueueBox);
if (queueBox.length > HiveDatabaseConfig.maxOfflineQueueSize) {
final itemsToRemove = queueBox.length - HiveDatabaseConfig.maxOfflineQueueSize;
// Remove oldest items
for (int i = 0; i < itemsToRemove; i++) {
await queueBox.deleteAt(0);
}
debugPrint('HiveService: Removed $itemsToRemove old items from offline queue');
}
}
/// Get a box by name
///
/// Returns an already opened box. Throws if box is not open.
Box<T> getBox<T>(String boxName) {
if (!Hive.isBoxOpen(boxName)) {
throw HiveError('Box $boxName is not open');
}
return Hive.box<T>(boxName);
}
/// Check if a box is open
bool isBoxOpen(String boxName) {
return Hive.isBoxOpen(boxName);
}
/// Clear all data from all boxes
///
/// WARNING: This will delete all local data. Use with caution.
Future<void> clearAllData() async {
debugPrint('HiveService: Clearing all data...');
for (final boxName in HiveBoxNames.allBoxes) {
try {
if (Hive.isBoxOpen(boxName)) {
final box = Hive.box<dynamic>(boxName);
await box.clear();
debugPrint('HiveService: Cleared box: $boxName');
}
} catch (e) {
debugPrint('HiveService: Error clearing box $boxName: $e');
}
}
debugPrint('HiveService: All data cleared');
}
/// Clear user-specific data (logout)
///
/// Clears user data while preserving app settings and cache
Future<void> clearUserData() async {
debugPrint('HiveService: Clearing user data...');
final boxesToClear = [
HiveBoxNames.userBox,
HiveBoxNames.cartBox,
HiveBoxNames.orderBox,
HiveBoxNames.projectBox,
HiveBoxNames.loyaltyBox,
HiveBoxNames.addressBox,
HiveBoxNames.notificationBox,
];
for (final boxName in boxesToClear) {
try {
if (Hive.isBoxOpen(boxName)) {
final box = Hive.box<dynamic>(boxName);
await box.clear();
debugPrint('HiveService: Cleared box: $boxName');
}
} catch (e) {
debugPrint('HiveService: Error clearing box $boxName: $e');
}
}
debugPrint('HiveService: User data cleared');
}
/// Close all boxes
///
/// Should be called when app is terminating
Future<void> close() async {
debugPrint('HiveService: Closing all boxes...');
try {
await Hive.close();
_isInitialized = false;
debugPrint('HiveService: All boxes closed');
} catch (e, stackTrace) {
debugPrint('HiveService: Error closing boxes: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Delete all Hive data from disk
///
/// WARNING: This completely removes the database. Use only for testing or reset.
Future<void> deleteFromDisk() async {
debugPrint('HiveService: Deleting database from disk...');
try {
// Close all boxes first
await close();
// Delete Hive directory
final appDocDir = await getApplicationDocumentsDirectory();
final hiveDir = Directory('${appDocDir.path}/hive');
if (await hiveDir.exists()) {
await hiveDir.delete(recursive: true);
debugPrint('HiveService: Database deleted from disk');
}
} catch (e, stackTrace) {
debugPrint('HiveService: Error deleting database: $e');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
/// Generate a secure encryption key
///
/// Returns a 256-bit encryption key for secure box encryption.
/// Store this key securely (e.g., in flutter_secure_storage).
static List<int> generateEncryptionKey() {
return Hive.generateSecureKey();
}
}

View File

@@ -0,0 +1,79 @@
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
part 'cached_data.g.dart';
/// Cached Data Model
///
/// Wrapper for caching API responses with timestamp and expiration.
/// Used for offline-first functionality and reducing API calls.
///
/// Example usage:
/// ```dart
/// final cachedProducts = CachedData(
/// data: products,
/// lastUpdated: DateTime.now(),
/// );
/// ```
@HiveType(typeId: HiveTypeIds.cachedData)
class CachedData extends HiveObject {
CachedData({
required this.data,
required this.lastUpdated,
this.expiresAt,
this.source,
});
/// The cached data (stored as dynamic)
@HiveField(0)
final dynamic data;
/// When the data was last updated
@HiveField(1)
final DateTime lastUpdated;
/// Optional expiration time
@HiveField(2)
final DateTime? expiresAt;
/// Source of the data (e.g., 'api', 'local')
@HiveField(3)
final String? source;
/// Check if cache is expired
bool get isExpired {
if (expiresAt == null) return false;
return DateTime.now().isAfter(expiresAt!);
}
/// Check if cache is fresh (not expired and within max age)
bool isFresh(Duration maxAge) {
if (isExpired) return false;
final age = DateTime.now().difference(lastUpdated);
return age <= maxAge;
}
/// Get age of cached data
Duration get age => DateTime.now().difference(lastUpdated);
/// Create a copy with updated data
CachedData copyWith({
dynamic data,
DateTime? lastUpdated,
DateTime? expiresAt,
String? source,
}) {
return CachedData(
data: data ?? this.data,
lastUpdated: lastUpdated ?? this.lastUpdated,
expiresAt: expiresAt ?? this.expiresAt,
source: source ?? this.source,
);
}
@override
String toString() {
return 'CachedData(lastUpdated: $lastUpdated, expiresAt: $expiresAt, source: $source, isExpired: $isExpired)';
}
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cached_data.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CachedDataAdapter extends TypeAdapter<CachedData> {
@override
final typeId = 30;
@override
CachedData read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return CachedData(
data: fields[0] as dynamic,
lastUpdated: fields[1] as DateTime,
expiresAt: fields[2] as DateTime?,
source: fields[3] as String?,
);
}
@override
void write(BinaryWriter writer, CachedData obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.data)
..writeByte(1)
..write(obj.lastUpdated)
..writeByte(2)
..write(obj.expiresAt)
..writeByte(3)
..write(obj.source);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CachedDataAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,425 @@
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
part 'enums.g.dart';
/// Member Tier Levels
///
/// Represents the loyalty program membership tiers.
/// Higher tiers receive more benefits and rewards.
@HiveType(typeId: HiveTypeIds.memberTier)
enum MemberTier {
/// Gold tier - Entry level membership
@HiveField(0)
gold,
/// Platinum tier - Mid-level membership
@HiveField(1)
platinum,
/// Diamond tier - Premium membership
@HiveField(2)
diamond,
}
/// User Type Categories
///
/// Represents the different types of users in the app.
@HiveType(typeId: HiveTypeIds.userType)
enum UserType {
/// Construction contractor
@HiveField(0)
contractor,
/// Architect or designer
@HiveField(1)
architect,
/// Product distributor
@HiveField(2)
distributor,
/// Real estate broker
@HiveField(3)
broker,
}
/// Order Status
///
/// Represents the current state of an order.
@HiveType(typeId: HiveTypeIds.orderStatus)
enum OrderStatus {
/// Order placed, awaiting confirmation
@HiveField(0)
pending,
/// Order confirmed and being processed
@HiveField(1)
processing,
/// Order is being shipped/delivered
@HiveField(2)
shipping,
/// Order completed successfully
@HiveField(3)
completed,
/// Order cancelled
@HiveField(4)
cancelled,
/// Order refunded
@HiveField(5)
refunded,
}
/// Project Status
///
/// Represents the current state of a construction project.
@HiveType(typeId: HiveTypeIds.projectStatus)
enum ProjectStatus {
/// Project in planning phase
@HiveField(0)
planning,
/// Project actively in progress
@HiveField(1)
inProgress,
/// Project on hold
@HiveField(2)
onHold,
/// Project completed
@HiveField(3)
completed,
/// Project cancelled
@HiveField(4)
cancelled,
}
/// Project Type
///
/// Represents the category of construction project.
@HiveType(typeId: HiveTypeIds.projectType)
enum ProjectType {
/// Residential building project
@HiveField(0)
residential,
/// Commercial building project
@HiveField(1)
commercial,
/// Industrial facility project
@HiveField(2)
industrial,
/// Infrastructure project
@HiveField(3)
infrastructure,
/// Renovation project
@HiveField(4)
renovation,
}
/// Loyalty Transaction Type
///
/// Represents the type of loyalty points transaction.
@HiveType(typeId: HiveTypeIds.transactionType)
enum TransactionType {
/// Points earned from purchase
@HiveField(0)
earnedPurchase,
/// Points earned from referral
@HiveField(1)
earnedReferral,
/// Points earned from promotion
@HiveField(2)
earnedPromotion,
/// Bonus points from admin
@HiveField(3)
earnedBonus,
/// Points redeemed for reward
@HiveField(4)
redeemedReward,
/// Points redeemed for discount
@HiveField(5)
redeemedDiscount,
/// Points adjusted by admin
@HiveField(6)
adjustment,
/// Points expired
@HiveField(7)
expired,
}
/// Gift Status
///
/// Represents the status of a redeemed gift/reward.
@HiveType(typeId: HiveTypeIds.giftStatus)
enum GiftStatus {
/// Gift is active and can be used
@HiveField(0)
active,
/// Gift has been used
@HiveField(1)
used,
/// Gift has expired
@HiveField(2)
expired,
/// Gift is reserved but not activated
@HiveField(3)
reserved,
/// Gift has been cancelled
@HiveField(4)
cancelled,
}
/// Payment Status
///
/// Represents the status of a payment transaction.
@HiveType(typeId: HiveTypeIds.paymentStatus)
enum PaymentStatus {
/// Payment pending
@HiveField(0)
pending,
/// Payment being processed
@HiveField(1)
processing,
/// Payment completed successfully
@HiveField(2)
completed,
/// Payment failed
@HiveField(3)
failed,
/// Payment refunded
@HiveField(4)
refunded,
/// Payment cancelled
@HiveField(5)
cancelled,
}
/// Notification Type
///
/// Represents different categories of notifications.
@HiveType(typeId: HiveTypeIds.notificationType)
enum NotificationType {
/// Order-related notification
@HiveField(0)
order,
/// Promotion or offer notification
@HiveField(1)
promotion,
/// System announcement
@HiveField(2)
system,
/// Loyalty program notification
@HiveField(3)
loyalty,
/// Project-related notification
@HiveField(4)
project,
/// Payment notification
@HiveField(5)
payment,
/// General message
@HiveField(6)
message,
}
/// Payment Method
///
/// Represents available payment methods.
@HiveType(typeId: HiveTypeIds.paymentMethod)
enum PaymentMethod {
/// Cash on delivery
@HiveField(0)
cashOnDelivery,
/// Bank transfer
@HiveField(1)
bankTransfer,
/// Credit/Debit card
@HiveField(2)
card,
/// E-wallet (Momo, ZaloPay, etc.)
@HiveField(3)
eWallet,
/// QR code payment
@HiveField(4)
qrCode,
/// Pay later / Credit
@HiveField(5)
payLater,
}
/// Extension methods for enums
extension MemberTierExtension on MemberTier {
/// Get display name
String get displayName {
switch (this) {
case MemberTier.gold:
return 'Gold';
case MemberTier.platinum:
return 'Platinum';
case MemberTier.diamond:
return 'Diamond';
}
}
/// Get tier level (higher is better)
int get level {
switch (this) {
case MemberTier.gold:
return 1;
case MemberTier.platinum:
return 2;
case MemberTier.diamond:
return 3;
}
}
}
extension UserTypeExtension on UserType {
/// Get display name (Vietnamese)
String get displayName {
switch (this) {
case UserType.contractor:
return 'Thầu thợ';
case UserType.architect:
return 'Kiến trúc sư';
case UserType.distributor:
return 'Đại lý phân phối';
case UserType.broker:
return 'Môi giới';
}
}
}
extension OrderStatusExtension on OrderStatus {
/// Get display name (Vietnamese)
String get displayName {
switch (this) {
case OrderStatus.pending:
return 'Chờ xác nhận';
case OrderStatus.processing:
return 'Đang xử lý';
case OrderStatus.shipping:
return 'Đang giao hàng';
case OrderStatus.completed:
return 'Hoàn thành';
case OrderStatus.cancelled:
return 'Đã hủy';
case OrderStatus.refunded:
return 'Đã hoàn tiền';
}
}
/// Check if order is active
bool get isActive {
return this == OrderStatus.pending ||
this == OrderStatus.processing ||
this == OrderStatus.shipping;
}
/// Check if order is final
bool get isFinal {
return this == OrderStatus.completed ||
this == OrderStatus.cancelled ||
this == OrderStatus.refunded;
}
}
extension ProjectStatusExtension on ProjectStatus {
/// Get display name (Vietnamese)
String get displayName {
switch (this) {
case ProjectStatus.planning:
return 'Lập kế hoạch';
case ProjectStatus.inProgress:
return 'Đang thực hiện';
case ProjectStatus.onHold:
return 'Tạm dừng';
case ProjectStatus.completed:
return 'Hoàn thành';
case ProjectStatus.cancelled:
return 'Đã hủy';
}
}
/// Check if project is active
bool get isActive {
return this == ProjectStatus.planning || this == ProjectStatus.inProgress;
}
}
extension TransactionTypeExtension on TransactionType {
/// Check if transaction is earning points
bool get isEarning {
return this == TransactionType.earnedPurchase ||
this == TransactionType.earnedReferral ||
this == TransactionType.earnedPromotion ||
this == TransactionType.earnedBonus;
}
/// Check if transaction is spending points
bool get isSpending {
return this == TransactionType.redeemedReward ||
this == TransactionType.redeemedDiscount;
}
/// Get display name (Vietnamese)
String get displayName {
switch (this) {
case TransactionType.earnedPurchase:
return 'Mua hàng';
case TransactionType.earnedReferral:
return 'Giới thiệu bạn bè';
case TransactionType.earnedPromotion:
return 'Khuyến mãi';
case TransactionType.earnedBonus:
return 'Thưởng';
case TransactionType.redeemedReward:
return 'Đổi quà';
case TransactionType.redeemedDiscount:
return 'Đổi giảm giá';
case TransactionType.adjustment:
return 'Điều chỉnh';
case TransactionType.expired:
return 'Hết hạn';
}
}
}

View File

@@ -0,0 +1,517 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'enums.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class MemberTierAdapter extends TypeAdapter<MemberTier> {
@override
final typeId = 20;
@override
MemberTier read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return MemberTier.gold;
case 1:
return MemberTier.platinum;
case 2:
return MemberTier.diamond;
default:
return MemberTier.gold;
}
}
@override
void write(BinaryWriter writer, MemberTier obj) {
switch (obj) {
case MemberTier.gold:
writer.writeByte(0);
case MemberTier.platinum:
writer.writeByte(1);
case MemberTier.diamond:
writer.writeByte(2);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MemberTierAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class UserTypeAdapter extends TypeAdapter<UserType> {
@override
final typeId = 21;
@override
UserType read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return UserType.contractor;
case 1:
return UserType.architect;
case 2:
return UserType.distributor;
case 3:
return UserType.broker;
default:
return UserType.contractor;
}
}
@override
void write(BinaryWriter writer, UserType obj) {
switch (obj) {
case UserType.contractor:
writer.writeByte(0);
case UserType.architect:
writer.writeByte(1);
case UserType.distributor:
writer.writeByte(2);
case UserType.broker:
writer.writeByte(3);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserTypeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class OrderStatusAdapter extends TypeAdapter<OrderStatus> {
@override
final typeId = 22;
@override
OrderStatus read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return OrderStatus.pending;
case 1:
return OrderStatus.processing;
case 2:
return OrderStatus.shipping;
case 3:
return OrderStatus.completed;
case 4:
return OrderStatus.cancelled;
case 5:
return OrderStatus.refunded;
default:
return OrderStatus.pending;
}
}
@override
void write(BinaryWriter writer, OrderStatus obj) {
switch (obj) {
case OrderStatus.pending:
writer.writeByte(0);
case OrderStatus.processing:
writer.writeByte(1);
case OrderStatus.shipping:
writer.writeByte(2);
case OrderStatus.completed:
writer.writeByte(3);
case OrderStatus.cancelled:
writer.writeByte(4);
case OrderStatus.refunded:
writer.writeByte(5);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is OrderStatusAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class ProjectStatusAdapter extends TypeAdapter<ProjectStatus> {
@override
final typeId = 23;
@override
ProjectStatus read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return ProjectStatus.planning;
case 1:
return ProjectStatus.inProgress;
case 2:
return ProjectStatus.onHold;
case 3:
return ProjectStatus.completed;
case 4:
return ProjectStatus.cancelled;
default:
return ProjectStatus.planning;
}
}
@override
void write(BinaryWriter writer, ProjectStatus obj) {
switch (obj) {
case ProjectStatus.planning:
writer.writeByte(0);
case ProjectStatus.inProgress:
writer.writeByte(1);
case ProjectStatus.onHold:
writer.writeByte(2);
case ProjectStatus.completed:
writer.writeByte(3);
case ProjectStatus.cancelled:
writer.writeByte(4);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProjectStatusAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class ProjectTypeAdapter extends TypeAdapter<ProjectType> {
@override
final typeId = 24;
@override
ProjectType read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return ProjectType.residential;
case 1:
return ProjectType.commercial;
case 2:
return ProjectType.industrial;
case 3:
return ProjectType.infrastructure;
case 4:
return ProjectType.renovation;
default:
return ProjectType.residential;
}
}
@override
void write(BinaryWriter writer, ProjectType obj) {
switch (obj) {
case ProjectType.residential:
writer.writeByte(0);
case ProjectType.commercial:
writer.writeByte(1);
case ProjectType.industrial:
writer.writeByte(2);
case ProjectType.infrastructure:
writer.writeByte(3);
case ProjectType.renovation:
writer.writeByte(4);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProjectTypeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class TransactionTypeAdapter extends TypeAdapter<TransactionType> {
@override
final typeId = 25;
@override
TransactionType read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return TransactionType.earnedPurchase;
case 1:
return TransactionType.earnedReferral;
case 2:
return TransactionType.earnedPromotion;
case 3:
return TransactionType.earnedBonus;
case 4:
return TransactionType.redeemedReward;
case 5:
return TransactionType.redeemedDiscount;
case 6:
return TransactionType.adjustment;
case 7:
return TransactionType.expired;
default:
return TransactionType.earnedPurchase;
}
}
@override
void write(BinaryWriter writer, TransactionType obj) {
switch (obj) {
case TransactionType.earnedPurchase:
writer.writeByte(0);
case TransactionType.earnedReferral:
writer.writeByte(1);
case TransactionType.earnedPromotion:
writer.writeByte(2);
case TransactionType.earnedBonus:
writer.writeByte(3);
case TransactionType.redeemedReward:
writer.writeByte(4);
case TransactionType.redeemedDiscount:
writer.writeByte(5);
case TransactionType.adjustment:
writer.writeByte(6);
case TransactionType.expired:
writer.writeByte(7);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TransactionTypeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class GiftStatusAdapter extends TypeAdapter<GiftStatus> {
@override
final typeId = 26;
@override
GiftStatus read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return GiftStatus.active;
case 1:
return GiftStatus.used;
case 2:
return GiftStatus.expired;
case 3:
return GiftStatus.reserved;
case 4:
return GiftStatus.cancelled;
default:
return GiftStatus.active;
}
}
@override
void write(BinaryWriter writer, GiftStatus obj) {
switch (obj) {
case GiftStatus.active:
writer.writeByte(0);
case GiftStatus.used:
writer.writeByte(1);
case GiftStatus.expired:
writer.writeByte(2);
case GiftStatus.reserved:
writer.writeByte(3);
case GiftStatus.cancelled:
writer.writeByte(4);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is GiftStatusAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class PaymentStatusAdapter extends TypeAdapter<PaymentStatus> {
@override
final typeId = 27;
@override
PaymentStatus read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return PaymentStatus.pending;
case 1:
return PaymentStatus.processing;
case 2:
return PaymentStatus.completed;
case 3:
return PaymentStatus.failed;
case 4:
return PaymentStatus.refunded;
case 5:
return PaymentStatus.cancelled;
default:
return PaymentStatus.pending;
}
}
@override
void write(BinaryWriter writer, PaymentStatus obj) {
switch (obj) {
case PaymentStatus.pending:
writer.writeByte(0);
case PaymentStatus.processing:
writer.writeByte(1);
case PaymentStatus.completed:
writer.writeByte(2);
case PaymentStatus.failed:
writer.writeByte(3);
case PaymentStatus.refunded:
writer.writeByte(4);
case PaymentStatus.cancelled:
writer.writeByte(5);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PaymentStatusAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class NotificationTypeAdapter extends TypeAdapter<NotificationType> {
@override
final typeId = 28;
@override
NotificationType read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return NotificationType.order;
case 1:
return NotificationType.promotion;
case 2:
return NotificationType.system;
case 3:
return NotificationType.loyalty;
case 4:
return NotificationType.project;
case 5:
return NotificationType.payment;
case 6:
return NotificationType.message;
default:
return NotificationType.order;
}
}
@override
void write(BinaryWriter writer, NotificationType obj) {
switch (obj) {
case NotificationType.order:
writer.writeByte(0);
case NotificationType.promotion:
writer.writeByte(1);
case NotificationType.system:
writer.writeByte(2);
case NotificationType.loyalty:
writer.writeByte(3);
case NotificationType.project:
writer.writeByte(4);
case NotificationType.payment:
writer.writeByte(5);
case NotificationType.message:
writer.writeByte(6);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is NotificationTypeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class PaymentMethodAdapter extends TypeAdapter<PaymentMethod> {
@override
final typeId = 29;
@override
PaymentMethod read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return PaymentMethod.cashOnDelivery;
case 1:
return PaymentMethod.bankTransfer;
case 2:
return PaymentMethod.card;
case 3:
return PaymentMethod.eWallet;
case 4:
return PaymentMethod.qrCode;
case 5:
return PaymentMethod.payLater;
default:
return PaymentMethod.cashOnDelivery;
}
}
@override
void write(BinaryWriter writer, PaymentMethod obj) {
switch (obj) {
case PaymentMethod.cashOnDelivery:
writer.writeByte(0);
case PaymentMethod.bankTransfer:
writer.writeByte(1);
case PaymentMethod.card:
writer.writeByte(2);
case PaymentMethod.eWallet:
writer.writeByte(3);
case PaymentMethod.qrCode:
writer.writeByte(4);
case PaymentMethod.payLater:
writer.writeByte(5);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PaymentMethodAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,351 @@
/// Custom exceptions for the Worker app
///
/// This file defines all custom exception types used throughout the application
/// for better error handling and user feedback.
library;
// ============================================================================
// Network Exceptions
// ============================================================================
/// Base exception for all network-related errors
class NetworkException implements Exception {
const NetworkException(
this.message, {
this.statusCode,
this.data,
});
final String message;
final int? statusCode;
final dynamic data;
@override
String toString() => 'NetworkException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}';
}
/// Exception thrown when there's no internet connection
class NoInternetException extends NetworkException {
const NoInternetException()
: super(
'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.',
);
}
/// Exception thrown when connection times out
class TimeoutException extends NetworkException {
const TimeoutException()
: super(
'Kết nối quá lâu. Vui lòng thử lại.',
statusCode: 408,
);
}
/// Exception thrown when server returns 500+ errors
class ServerException extends NetworkException {
const ServerException([
String message = 'Lỗi máy chủ. Vui lòng thử lại sau.',
int? statusCode,
]) : super(message, statusCode: statusCode);
}
/// Exception thrown when server is unreachable
class ServiceUnavailableException extends ServerException {
const ServiceUnavailableException()
: super(
'Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.',
503,
);
}
// ============================================================================
// Authentication Exceptions
// ============================================================================
/// Base exception for authentication-related errors
class AuthException implements Exception {
const AuthException(
this.message, {
this.statusCode,
});
final String message;
final int? statusCode;
@override
String toString() => 'AuthException: $message';
}
/// Exception thrown when authentication credentials are invalid
class InvalidCredentialsException extends AuthException {
const InvalidCredentialsException()
: super(
'Thông tin đăng nhập không hợp lệ.',
statusCode: 401,
);
}
/// Exception thrown when user is not authenticated
class UnauthorizedException extends AuthException {
const UnauthorizedException([
super.message = 'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.',
]) : super(statusCode: 401);
}
/// Exception thrown when user doesn't have permission
class ForbiddenException extends AuthException {
const ForbiddenException()
: super(
'Bạn không có quyền truy cập tài nguyên này.',
statusCode: 403,
);
}
/// Exception thrown when auth token is expired
class TokenExpiredException extends AuthException {
const TokenExpiredException()
: super(
'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.',
statusCode: 401,
);
}
/// Exception thrown when refresh token is invalid
class InvalidRefreshTokenException extends AuthException {
const InvalidRefreshTokenException()
: super(
'Không thể làm mới phiên đăng nhập. Vui lòng đăng nhập lại.',
statusCode: 401,
);
}
/// Exception thrown when OTP is invalid
class InvalidOTPException extends AuthException {
const InvalidOTPException()
: super(
'Mã OTP không hợp lệ. Vui lòng thử lại.',
statusCode: 400,
);
}
/// Exception thrown when OTP is expired
class OTPExpiredException extends AuthException {
const OTPExpiredException()
: super(
'Mã OTP đã hết hạn. Vui lòng yêu cầu mã mới.',
statusCode: 400,
);
}
// ============================================================================
// Request Validation Exceptions
// ============================================================================
/// Exception thrown when request data is invalid
class ValidationException implements Exception {
const ValidationException(
this.message, {
this.errors,
});
final String message;
final Map<String, List<String>>? errors;
@override
String toString() {
if (errors != null && errors!.isNotEmpty) {
final errorMessages = errors!.entries
.map((e) => '${e.key}: ${e.value.join(", ")}')
.join('; ');
return 'ValidationException: $message - $errorMessages';
}
return 'ValidationException: $message';
}
}
/// Exception thrown when request parameters are invalid
class BadRequestException extends ValidationException {
const BadRequestException([
String message = 'Yêu cầu không hợp lệ.',
Map<String, List<String>>? errors,
]) : super(message, errors: errors);
}
// ============================================================================
// Resource Exceptions
// ============================================================================
/// Exception thrown when requested resource is not found
class NotFoundException implements Exception {
const NotFoundException([
this.message = 'Không tìm thấy tài nguyên.',
this.resourceType,
this.resourceId,
]);
final String message;
final String? resourceType;
final String? resourceId;
@override
String toString() {
if (resourceType != null && resourceId != null) {
return 'NotFoundException: $resourceType with ID $resourceId not found';
}
return 'NotFoundException: $message';
}
}
/// Exception thrown when trying to create a duplicate resource
class ConflictException implements Exception {
const ConflictException([
this.message = 'Tài nguyên đã tồn tại.',
]);
final String message;
@override
String toString() => 'ConflictException: $message';
}
// ============================================================================
// Rate Limiting Exceptions
// ============================================================================
/// Exception thrown when API rate limit is exceeded
class RateLimitException implements Exception {
const RateLimitException([
this.message = 'Bạn đã gửi quá nhiều yêu cầu. Vui lòng thử lại sau.',
this.retryAfter,
]);
final String message;
final int? retryAfter; // seconds
@override
String toString() {
if (retryAfter != null) {
return 'RateLimitException: $message (Retry after: ${retryAfter}s)';
}
return 'RateLimitException: $message';
}
}
// ============================================================================
// Payment Exceptions
// ============================================================================
/// Exception thrown for payment-related errors
class PaymentException implements Exception {
const PaymentException(
this.message, {
this.transactionId,
});
final String message;
final String? transactionId;
@override
String toString() => 'PaymentException: $message';
}
/// Exception thrown when payment fails
class PaymentFailedException extends PaymentException {
const PaymentFailedException([
String message = 'Thanh toán thất bại. Vui lòng thử lại.',
String? transactionId,
]) : super(message, transactionId: transactionId);
}
/// Exception thrown when payment is cancelled
class PaymentCancelledException extends PaymentException {
const PaymentCancelledException()
: super('Thanh toán đã bị hủy.');
}
// ============================================================================
// Cache Exceptions
// ============================================================================
/// Exception thrown for cache-related errors
class CacheException implements Exception {
const CacheException([
this.message = 'Lỗi khi truy cập bộ nhớ đệm.',
]);
final String message;
@override
String toString() => 'CacheException: $message';
}
/// Exception thrown when cache data is corrupted
class CacheCorruptedException extends CacheException {
const CacheCorruptedException()
: super('Dữ liệu bộ nhớ đệm bị hỏng.');
}
// ============================================================================
// Storage Exceptions
// ============================================================================
/// Exception thrown for local storage errors
class StorageException implements Exception {
const StorageException([
this.message = 'Lỗi khi truy cập bộ nhớ cục bộ.',
]);
final String message;
@override
String toString() => 'StorageException: $message';
}
/// Exception thrown when storage is full
class StorageFullException extends StorageException {
const StorageFullException()
: super('Bộ nhớ đã đầy. Vui lòng giải phóng không gian.');
}
// ============================================================================
// Parse Exceptions
// ============================================================================
/// Exception thrown when JSON parsing fails
class ParseException implements Exception {
const ParseException([
this.message = 'Lỗi khi phân tích dữ liệu.',
this.source,
]);
final String message;
final dynamic source;
@override
String toString() => 'ParseException: $message';
}
// ============================================================================
// Unknown Exceptions
// ============================================================================
/// Exception thrown for unexpected errors
class UnknownException implements Exception {
const UnknownException([
this.message = 'Đã xảy ra lỗi không xác định.',
this.originalError,
this.stackTrace,
]);
final String message;
final dynamic originalError;
final StackTrace? stackTrace;
@override
String toString() {
if (originalError != null) {
return 'UnknownException: $message (Original: $originalError)';
}
return 'UnknownException: $message';
}
}

View File

@@ -0,0 +1,262 @@
/// Failure classes for error handling in the Worker app
///
/// Failures represent domain-level errors that can be returned from use cases
/// and repositories. They wrap exceptions and provide user-friendly error messages.
library;
/// Base failure class
sealed class Failure {
const Failure({required this.message});
/// Network-related failure
const factory Failure.network({
required String message,
int? statusCode,
}) = NetworkFailure;
/// Server error failure (5xx errors)
const factory Failure.server({
required String message,
int? statusCode,
}) = ServerFailure;
/// Authentication failure
const factory Failure.authentication({
required String message,
int? statusCode,
}) = AuthenticationFailure;
/// Validation failure
const factory Failure.validation({
required String message,
Map<String, List<String>>? errors,
}) = ValidationFailure;
/// Not found failure (404)
const factory Failure.notFound({
required String message,
}) = NotFoundFailure;
/// Conflict failure (409)
const factory Failure.conflict({
required String message,
}) = ConflictFailure;
/// Rate limit exceeded failure (429)
const factory Failure.rateLimit({
required String message,
int? retryAfter,
}) = RateLimitFailure;
/// Payment failure
const factory Failure.payment({
required String message,
String? transactionId,
}) = PaymentFailure;
/// Cache failure
const factory Failure.cache({
required String message,
}) = CacheFailure;
/// Storage failure
const factory Failure.storage({
required String message,
}) = StorageFailure;
/// Parse failure
const factory Failure.parse({
required String message,
}) = ParseFailure;
/// No internet connection failure
const factory Failure.noInternet() = NoInternetFailure;
/// Timeout failure
const factory Failure.timeout() = TimeoutFailure;
/// Unknown failure
const factory Failure.unknown({
required String message,
}) = UnknownFailure;
final String message;
/// Check if this is a critical failure that requires immediate attention
bool get isCritical {
return switch (this) {
ServerFailure() => true,
AuthenticationFailure() => true,
PaymentFailure() => true,
UnknownFailure() => true,
_ => false,
};
}
/// Check if this failure can be retried
bool get canRetry {
return switch (this) {
NetworkFailure() => true,
ServerFailure(:final statusCode) => statusCode == 503,
AuthenticationFailure(:final statusCode) => statusCode == 401,
RateLimitFailure() => true,
CacheFailure() => true,
NoInternetFailure() => true,
TimeoutFailure() => true,
_ => false,
};
}
/// Get HTTP status code if available
int? get statusCode {
return switch (this) {
NetworkFailure(:final statusCode) => statusCode,
ServerFailure(:final statusCode) => statusCode,
AuthenticationFailure(:final statusCode) => statusCode,
_ => null,
};
}
/// Get user-friendly error message
String getUserMessage() {
return switch (this) {
ValidationFailure(:final message, :final errors) => _formatValidationMessage(message, errors),
RateLimitFailure(:final message, :final retryAfter) => _formatRateLimitMessage(message, retryAfter),
NoInternetFailure() => 'Không có kết nối internet. Vui lòng kiểm tra kết nối của bạn.',
TimeoutFailure() => 'Kết nối quá lâu. Vui lòng thử lại.',
_ => message,
};
}
String _formatValidationMessage(String message, Map<String, List<String>>? errors) {
if (errors != null && errors.isNotEmpty) {
final firstError = errors.values.first.first;
return '$message: $firstError';
}
return message;
}
String _formatRateLimitMessage(String message, int? retryAfter) {
if (retryAfter != null) {
return '$message Thử lại sau $retryAfter giây.';
}
return message;
}
}
/// Network-related failure
final class NetworkFailure extends Failure {
const NetworkFailure({
required super.message,
this.statusCode,
});
@override
final int? statusCode;
}
/// Server error failure (5xx errors)
final class ServerFailure extends Failure {
const ServerFailure({
required super.message,
this.statusCode,
});
@override
final int? statusCode;
}
/// Authentication failure
final class AuthenticationFailure extends Failure {
const AuthenticationFailure({
required super.message,
this.statusCode,
});
@override
final int? statusCode;
}
/// Validation failure
final class ValidationFailure extends Failure {
const ValidationFailure({
required super.message,
this.errors,
});
final Map<String, List<String>>? errors;
}
/// Not found failure (404)
final class NotFoundFailure extends Failure {
const NotFoundFailure({
required super.message,
});
}
/// Conflict failure (409)
final class ConflictFailure extends Failure {
const ConflictFailure({
required super.message,
});
}
/// Rate limit exceeded failure (429)
final class RateLimitFailure extends Failure {
const RateLimitFailure({
required super.message,
this.retryAfter,
});
final int? retryAfter;
}
/// Payment failure
final class PaymentFailure extends Failure {
const PaymentFailure({
required super.message,
this.transactionId,
});
final String? transactionId;
}
/// Cache failure
final class CacheFailure extends Failure {
const CacheFailure({
required super.message,
});
}
/// Storage failure
final class StorageFailure extends Failure {
const StorageFailure({
required super.message,
});
}
/// Parse failure
final class ParseFailure extends Failure {
const ParseFailure({
required super.message,
});
}
/// No internet connection failure
final class NoInternetFailure extends Failure {
const NoInternetFailure()
: super(message: 'Không có kết nối internet');
}
/// Timeout failure
final class TimeoutFailure extends Failure {
const TimeoutFailure()
: super(message: 'Kết nối quá lâu');
}
/// Unknown failure
final class UnknownFailure extends Failure {
const UnknownFailure({
required super.message,
});
}

449
lib/core/network/README.md Normal file
View File

@@ -0,0 +1,449 @@
# API Integration Infrastructure - Worker App
## Overview
Comprehensive HTTP client infrastructure built with **Dio** and **Riverpod 3.0** for the Worker Flutter application. This setup provides robust API integration with authentication, caching, retry logic, error handling, and offline support.
## Architecture
```
lib/core/network/
├── dio_client.dart # Main HTTP client with Riverpod providers
├── api_interceptor.dart # Authentication, logging, and error interceptors
├── network_info.dart # Network connectivity monitoring
├── api_constants.dart # API endpoints and configuration
├── exceptions.dart # Custom exception definitions
└── failures.dart # Domain-level failure types
```
## Key Features
### 1. Dio HTTP Client (`dio_client.dart`)
**DioClient Class**
- Wrapper around Dio with full method support (GET, POST, PUT, PATCH, DELETE)
- File upload with multipart/form-data
- File download with progress tracking
- Cache management utilities
**Riverpod Providers**
- `dioProvider` - Configured Dio instance with all interceptors
- `dioClientProvider` - DioClient wrapper instance
- `cacheStoreProvider` - Hive-based cache storage
- `cacheOptionsProvider` - Cache configuration
**Configuration**
- Base URL: Configurable per environment (dev/staging/prod)
- Timeouts: 30s connection, 30s receive, 30s send
- Headers: JSON content-type, Vietnamese language by default
- Cache: 7-day max-stale, no caching on auth errors (401, 403)
### 2. Interceptors (`api_interceptor.dart`)
#### AuthInterceptor
- **Token Injection**: Automatically adds Bearer token to requests
- **Token Refresh**: Handles 401 errors with automatic token refresh
- **Public Endpoints**: Skips auth for login/OTP/register endpoints
- **Language Header**: Adds Vietnamese language preference
- **Storage**: Uses SharedPreferences for token persistence
#### LoggingInterceptor
- **Request Logging**: Method, URL, headers, body, query parameters
- **Response Logging**: Status code, response data (truncated)
- **Error Logging**: Error type, status code, error data
- **Security**: Sanitizes sensitive fields (password, OTP, tokens)
- **Format**: Beautiful formatted logs with separators
#### ErrorTransformerInterceptor
- **Dio Error Mapping**: Transforms DioException to custom exceptions
- **Status Code Handling**:
- 400 → ValidationException/BadRequestException
- 401 → UnauthorizedException/TokenExpiredException/InvalidOTPException
- 403 → ForbiddenException
- 404 → NotFoundException
- 409 → ConflictException
- 422 → ValidationException with field errors
- 429 → RateLimitException with retry-after
- 5xx → ServerException/ServiceUnavailableException
- **Connection Errors**: Timeout, NoInternet, etc.
#### RetryInterceptor
- **Exponential Backoff**: Configurable delay multiplier
- **Max Retries**: 3 attempts by default
- **Retry Conditions**:
- Connection timeout/errors
- 5xx server errors (except 501)
- 408 Request Timeout
- 429 Too Many Requests
- **Network Check**: Verifies connectivity before retrying
### 3. Network Monitoring (`network_info.dart`)
**NetworkInfo Interface**
- Connection status checking
- Connection type detection (WiFi, Mobile, Ethernet, etc.)
- Real-time connectivity monitoring via Stream
**NetworkStatus Class**
- Connection state (connected/disconnected)
- Connection type
- Timestamp
- Convenience methods (isWiFi, isMobile, isMetered)
**Riverpod Providers**
- `networkInfoProvider` - NetworkInfo implementation
- `isConnectedProvider` - Current connection status
- `connectionTypeProvider` - Current connection type
- `networkStatusStreamProvider` - Stream of status changes
- `NetworkStatusNotifier` - Reactive network status state
### 4. Error Handling
**Exceptions (`exceptions.dart`)**
- NetworkException - Base network error
- NoInternetException - No connectivity
- TimeoutException - Connection timeout
- ServerException - 5xx errors
- ServiceUnavailableException - 503 errors
- AuthException - Authentication errors (401, 403)
- ValidationException - Request validation errors
- NotFoundException - 404 errors
- ConflictException - 409 errors
- RateLimitException - 429 errors
- PaymentException - Payment-related errors
- CacheException - Cache errors
- StorageException - Local storage errors
- ParseException - JSON parsing errors
**Failures (`failures.dart`)**
- Immutable Freezed classes for domain-level errors
- User-friendly Vietnamese error messages
- Properties:
- `message` - Display message
- `isCritical` - Requires immediate attention
- `canRetry` - Can be retried
- `statusCode` - HTTP status if available
### 5. API Constants (`api_constants.dart`)
**Configuration**
- Base URLs (dev, staging, production)
- API version prefix (/v1)
- Timeout durations (30s)
- Retry configuration (3 attempts, exponential backoff)
- Cache durations (24h products, 1h profile, 48h categories)
- Request headers (JSON, Vietnamese language)
**Endpoints**
- Authentication: /auth/request-otp, /auth/verify-otp, /auth/register, etc.
- Loyalty: /loyalty/points, /loyalty/rewards, /loyalty/referral, etc.
- Products: /products, /products/search, /categories, etc.
- Orders: /orders, /payments, etc.
- Projects & Quotes: /projects, /quotes, etc.
- Chat: /chat/messages, /ws/chat (WebSocket)
- Account: /profile, /addresses, etc.
- Promotions & Notifications
## Usage Examples
### Basic GET Request
```dart
// Using DioClient with Riverpod
final dioClient = ref.watch(dioClientProvider);
try {
final response = await dioClient.get(
ApiConstants.getProducts,
queryParameters: {'page': '1', 'limit': '20'},
);
final products = response.data;
} on NoInternetException catch (e) {
// Handle no internet
} on ServerException catch (e) {
// Handle server error
}
```
### POST Request with Authentication
```dart
final dioClient = ref.watch(dioClientProvider);
try {
final response = await dioClient.post(
ApiConstants.createOrder,
data: {
'items': [...],
'deliveryAddress': {...},
'paymentMethod': 'COD',
},
);
final order = Order.fromJson(response.data);
} on ValidationException catch (e) {
// Handle validation errors
print(e.errors); // Map<String, List<String>>
}
```
### File Upload
```dart
final dioClient = ref.watch(dioClientProvider);
final formData = FormData.fromMap({
'name': 'John Doe',
'avatar': await MultipartFile.fromFile(
filePath,
filename: 'avatar.jpg',
),
});
try {
final response = await dioClient.uploadFile(
ApiConstants.uploadAvatar,
formData: formData,
onSendProgress: (sent, total) {
print('Upload progress: ${(sent / total * 100).toStringAsFixed(0)}%');
},
);
} catch (e) {
// Handle error
}
```
### Network Status Monitoring
```dart
// Check current connection status
final isConnected = await ref.watch(isConnectedProvider.future);
if (!isConnected) {
// Show offline message
}
// Listen to connection changes
ref.listen(
networkStatusStreamProvider,
(previous, next) {
next.whenData((status) {
if (status.isConnected) {
// Back online - sync data
} else {
// Offline - show message
}
});
},
);
```
### Cache Management
```dart
final dioClient = ref.watch(dioClientProvider);
// Clear all cache
await dioClient.clearCache();
// Clear specific endpoint cache
await dioClient.clearCacheByPath(ApiConstants.getProducts);
// Force refresh from network
final response = await dioClient.get(
ApiConstants.getProducts,
options: ApiRequestOptions.forceNetwork.toDioOptions(),
);
// Use cache-first strategy
final response = await dioClient.get(
ApiConstants.getCategories,
options: ApiRequestOptions.cached.toDioOptions(),
);
```
### Custom Error Handling
```dart
try {
final response = await dioClient.post(...);
} on ValidationException catch (e) {
// Show field-specific errors
e.errors?.forEach((field, messages) {
print('$field: ${messages.join(", ")}');
});
} on RateLimitException catch (e) {
// Show rate limit message
if (e.retryAfter != null) {
print('Try again in ${e.retryAfter} seconds');
}
} on TokenExpiredException catch (e) {
// Token refresh failed - redirect to login
ref.read(authProvider.notifier).logout();
} catch (e) {
// Generic error
print('Error: $e');
}
```
## Dependencies
```yaml
dependencies:
dio: ^5.4.3+1 # HTTP client
connectivity_plus: ^6.0.3 # Network monitoring
pretty_dio_logger: ^1.3.1 # Request/response logging
dio_cache_interceptor: ^3.5.0 # Response caching
dio_cache_interceptor_hive_store: ^3.2.2 # Hive storage for cache
flutter_riverpod: ^3.0.0 # State management
riverpod_annotation: ^3.0.0 # Code generation
shared_preferences: ^2.2.3 # Token storage
path_provider: ^2.1.3 # Cache directory
freezed_annotation: ^3.0.0 # Immutable models
```
## Configuration
### Environment-Specific Base URLs
Update `ApiConstants.baseUrl` based on build flavor:
```dart
// For dev environment
static const String baseUrl = devBaseUrl;
// For production
static const String baseUrl = prodBaseUrl;
```
### Timeout Configuration
Adjust timeouts in `ApiConstants`:
```dart
static const Duration connectionTimeout = Duration(milliseconds: 30000);
static const Duration receiveTimeout = Duration(milliseconds: 30000);
static const Duration sendTimeout = Duration(milliseconds: 30000);
```
### Retry Configuration
Customize retry behavior in `ApiConstants`:
```dart
static const int maxRetryAttempts = 3;
static const Duration initialRetryDelay = Duration(milliseconds: 1000);
static const Duration maxRetryDelay = Duration(milliseconds: 5000);
static const double retryDelayMultiplier = 2.0;
```
### Cache Configuration
Adjust cache settings in `cacheOptionsProvider`:
```dart
CacheOptions(
store: store,
maxStale: const Duration(days: 7),
hitCacheOnErrorExcept: [401, 403],
priority: CachePriority.high,
allowPostMethod: false,
);
```
## Testing
### Connection Testing
```dart
// Test network connectivity
final networkInfo = ref.watch(networkInfoProvider);
final isConnected = await networkInfo.isConnected;
final connectionType = await networkInfo.connectionType;
print('Connected: $isConnected');
print('Type: ${connectionType.displayNameVi}');
```
### API Endpoint Testing
```dart
// Test authentication endpoint
try {
final response = await dioClient.post(
ApiConstants.requestOtp,
data: {'phone': '+84912345678'},
);
print('OTP sent successfully');
} catch (e) {
print('Failed: $e');
}
```
## Best Practices
1. **Always use DioClient**: Don't create raw Dio instances
2. **Handle specific exceptions**: Catch specific error types for better UX
3. **Check connectivity**: Verify network status before critical requests
4. **Use cache strategically**: Cache static data (categories, products)
5. **Monitor network changes**: Listen to connectivity stream for sync
6. **Clear cache appropriately**: Clear on logout, version updates
7. **Log in debug only**: Disable logging in production
8. **Sanitize sensitive data**: Never log passwords, tokens, OTP codes
9. **Use retry wisely**: Don't retry POST/PUT/DELETE by default
10. **Validate responses**: Check response.data structure before parsing
## Future Enhancements
- [ ] Offline request queue implementation
- [ ] Request deduplication
- [ ] GraphQL support
- [ ] WebSocket integration for real-time chat
- [ ] Certificate pinning for security
- [ ] Request compression (gzip)
- [ ] Multi-part upload progress
- [ ] Background sync when network restored
- [ ] Advanced caching strategies (stale-while-revalidate)
- [ ] Request cancellation tokens
## Troubleshooting
### Issue: Token Refresh Loop
**Solution**: Check refresh token expiry and clear auth data if expired
### Issue: Cache Not Working
**Solution**: Verify CacheStore initialization and directory permissions
### Issue: Network Detection Fails
**Solution**: Add required permissions to AndroidManifest.xml and Info.plist
### Issue: Timeout on Large Files
**Solution**: Increase timeout or use download with progress callback
### Issue: Interceptor Order Matters
**Current Order**:
1. Logging (first - logs everything)
2. Auth (adds tokens)
3. Cache (caches responses)
4. Retry (retries failures)
5. Error Transformer (last - transforms errors)
## Support
For issues or questions about the API integration:
- Check logs for detailed error information
- Verify network connectivity using NetworkInfo
- Review interceptor configuration
- Check API endpoint constants
---
**Generated for Worker App**
Version: 1.0.0
Last Updated: 2025-10-17

View File

@@ -0,0 +1,572 @@
/// API interceptors for request/response handling
///
/// Provides interceptors for:
/// - Authentication token injection
/// - Request/response logging
/// - Error transformation
/// - Token refresh handling
library;
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/errors/exceptions.dart';
part 'api_interceptor.g.dart';
// ============================================================================
// Storage Keys
// ============================================================================
/// Keys for storing auth tokens in SharedPreferences
class AuthStorageKeys {
static const String accessToken = 'auth_access_token';
static const String refreshToken = 'auth_refresh_token';
static const String tokenExpiry = 'auth_token_expiry';
}
// ============================================================================
// Auth Interceptor
// ============================================================================
/// Interceptor for adding authentication tokens to requests
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._prefs, this._dio);
final SharedPreferences _prefs;
final Dio _dio;
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
// Check if this endpoint requires authentication
if (_requiresAuth(options.path)) {
final token = await _getAccessToken();
if (token != null) {
// Add bearer token to headers
options.headers['Authorization'] = 'Bearer $token';
}
}
// Add language header
options.headers['Accept-Language'] = ApiConstants.acceptLanguageVi;
// Add content-type and accept headers if not already set
options.headers['Content-Type'] ??= ApiConstants.contentTypeJson;
options.headers['Accept'] ??= ApiConstants.acceptJson;
handler.next(options);
}
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// Check if error is 401 Unauthorized
if (err.response?.statusCode == 401) {
// Try to refresh token
final refreshed = await _refreshAccessToken();
if (refreshed) {
// Retry the original request with new token
try {
final response = await _retry(err.requestOptions);
handler.resolve(response);
return;
} catch (e) {
// If retry fails, continue with error
}
}
}
handler.next(err);
}
/// Check if endpoint requires authentication
bool _requiresAuth(String path) {
// Public endpoints that don't require auth
final publicEndpoints = [
ApiConstants.requestOtp,
ApiConstants.verifyOtp,
ApiConstants.register,
];
return !publicEndpoints.any((endpoint) => path.contains(endpoint));
}
/// Get access token from storage
Future<String?> _getAccessToken() async {
return _prefs.getString(AuthStorageKeys.accessToken);
}
/// Get refresh token from storage
Future<String?> _getRefreshToken() async {
return _prefs.getString(AuthStorageKeys.refreshToken);
}
/// Check if token is expired
Future<bool> _isTokenExpired() async {
final expiryString = _prefs.getString(AuthStorageKeys.tokenExpiry);
if (expiryString == null) return true;
final expiry = DateTime.tryParse(expiryString);
if (expiry == null) return true;
return DateTime.now().isAfter(expiry);
}
/// Refresh access token using refresh token
Future<bool> _refreshAccessToken() async {
try {
final refreshToken = await _getRefreshToken();
if (refreshToken == null) {
return false;
}
// Call refresh token endpoint
final response = await _dio.post<Map<String, dynamic>>(
'${ApiConstants.apiBaseUrl}${ApiConstants.refreshToken}',
options: Options(
headers: {
'Authorization': 'Bearer $refreshToken',
},
),
);
if (response.statusCode == 200) {
final data = response.data as Map<String, dynamic>;
// Save new tokens
await _prefs.setString(
AuthStorageKeys.accessToken,
data['accessToken'] as String,
);
if (data.containsKey('refreshToken')) {
await _prefs.setString(
AuthStorageKeys.refreshToken,
data['refreshToken'] as String,
);
}
if (data.containsKey('expiresAt')) {
await _prefs.setString(
AuthStorageKeys.tokenExpiry,
data['expiresAt'] as String,
);
}
return true;
}
return false;
} catch (e) {
developer.log(
'Failed to refresh token',
name: 'AuthInterceptor',
error: e,
);
return false;
}
}
/// Retry failed request with new token
Future<Response<dynamic>> _retry(RequestOptions requestOptions) async {
final token = await _getAccessToken();
final options = Options(
method: requestOptions.method,
headers: {
...requestOptions.headers,
'Authorization': 'Bearer $token',
},
);
return _dio.request(
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options,
);
}
}
// ============================================================================
// Logging Interceptor
// ============================================================================
/// Interceptor for logging requests and responses in debug mode
class LoggingInterceptor extends Interceptor {
LoggingInterceptor({
this.enableRequestLogging = true,
this.enableResponseLogging = true,
this.enableErrorLogging = true,
});
final bool enableRequestLogging;
final bool enableResponseLogging;
final bool enableErrorLogging;
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) {
if (enableRequestLogging) {
developer.log(
'╔══════════════════════════════════════════════════════════════',
name: 'HTTP Request',
);
developer.log(
'${options.method} ${options.uri}',
name: 'HTTP Request',
);
developer.log(
'║ Headers: ${_sanitizeHeaders(options.headers)}',
name: 'HTTP Request',
);
if (options.data != null) {
developer.log(
'║ Body: ${_sanitizeBody(options.data)}',
name: 'HTTP Request',
);
}
if (options.queryParameters.isNotEmpty) {
developer.log(
'║ Query Parameters: ${options.queryParameters}',
name: 'HTTP Request',
);
}
developer.log(
'╚══════════════════════════════════════════════════════════════',
name: 'HTTP Request',
);
}
handler.next(options);
}
@override
void onResponse(
Response<dynamic> response,
ResponseInterceptorHandler handler,
) {
if (enableResponseLogging) {
developer.log(
'╔══════════════════════════════════════════════════════════════',
name: 'HTTP Response',
);
developer.log(
'${response.requestOptions.method} ${response.requestOptions.uri}',
name: 'HTTP Response',
);
developer.log(
'║ Status Code: ${response.statusCode}',
name: 'HTTP Response',
);
developer.log(
'║ Data: ${_truncateData(response.data, 500)}',
name: 'HTTP Response',
);
developer.log(
'╚══════════════════════════════════════════════════════════════',
name: 'HTTP Response',
);
}
handler.next(response);
}
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
if (enableErrorLogging) {
developer.log(
'╔══════════════════════════════════════════════════════════════',
name: 'HTTP Error',
);
developer.log(
'${err.requestOptions.method} ${err.requestOptions.uri}',
name: 'HTTP Error',
);
developer.log(
'║ Error Type: ${err.type}',
name: 'HTTP Error',
);
developer.log(
'║ Status Code: ${err.response?.statusCode}',
name: 'HTTP Error',
);
developer.log(
'║ Message: ${err.message}',
name: 'HTTP Error',
);
if (err.response?.data != null) {
developer.log(
'║ Error Data: ${_truncateData(err.response?.data, 500)}',
name: 'HTTP Error',
);
}
developer.log(
'╚══════════════════════════════════════════════════════════════',
name: 'HTTP Error',
);
}
handler.next(err);
}
/// Sanitize headers by hiding sensitive information
Map<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
final sanitized = Map<String, dynamic>.from(headers);
// Hide authorization token
if (sanitized.containsKey('Authorization')) {
sanitized['Authorization'] = '[HIDDEN]';
}
return sanitized;
}
/// Sanitize request body by hiding sensitive fields
dynamic _sanitizeBody(dynamic body) {
if (body is Map) {
final sanitized = Map<dynamic, dynamic>.from(body);
// List of sensitive field names
final sensitiveFields = [
'password',
'otp',
'token',
'accessToken',
'refreshToken',
'secret',
'apiKey',
];
for (final field in sensitiveFields) {
if (sanitized.containsKey(field)) {
sanitized[field] = '[HIDDEN]';
}
}
return sanitized;
}
return body;
}
/// Truncate data for logging to avoid huge logs
String _truncateData(dynamic data, int maxLength) {
final dataStr = data.toString();
if (dataStr.length <= maxLength) {
return dataStr;
}
return '${dataStr.substring(0, maxLength)}... [TRUNCATED]';
}
}
// ============================================================================
// Error Transformer Interceptor
// ============================================================================
/// Interceptor for transforming Dio errors into custom exceptions
class ErrorTransformerInterceptor extends Interceptor {
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) {
Exception exception;
switch (err.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
exception = const TimeoutException();
break;
case DioExceptionType.connectionError:
exception = const NoInternetException();
break;
case DioExceptionType.badResponse:
exception = _handleBadResponse(err.response);
break;
case DioExceptionType.cancel:
exception = NetworkException('Yêu cầu đã bị hủy');
break;
case DioExceptionType.unknown:
exception = NetworkException(
'Lỗi không xác định: ${err.message}',
);
break;
default:
exception = NetworkException(err.message ?? 'Unknown error');
}
handler.reject(
DioException(
requestOptions: err.requestOptions,
response: err.response,
type: err.type,
error: exception,
message: exception.toString(),
),
);
}
/// Handle bad response errors based on status code
Exception _handleBadResponse(Response<dynamic>? response) {
if (response == null) {
return const ServerException();
}
final statusCode = response.statusCode ?? 0;
final data = response.data;
// Extract error message from response
String? message;
if (data is Map<String, dynamic>) {
message = data['message'] as String? ??
data['error'] as String? ??
data['msg'] as String?;
}
switch (statusCode) {
case 400:
if (data is Map<String, dynamic> && data.containsKey('errors')) {
final errors = data['errors'] as Map<String, dynamic>?;
if (errors != null) {
final validationErrors = errors.map(
(key, value) => MapEntry(
key,
value is List
? value.cast<String>()
: [value.toString()],
),
);
return ValidationException(
message ?? 'Dữ liệu không hợp lệ',
errors: validationErrors,
);
}
}
return BadRequestException(message ?? 'Yêu cầu không hợp lệ');
case 401:
if (message?.toLowerCase().contains('token') ?? false) {
return const TokenExpiredException();
}
if (message?.toLowerCase().contains('otp') ?? false) {
return const InvalidOTPException();
}
return UnauthorizedException(message ?? 'Phiên đăng nhập hết hạn');
case 403:
return const ForbiddenException();
case 404:
return NotFoundException(message ?? 'Không tìm thấy tài nguyên');
case 409:
return ConflictException(message ?? 'Tài nguyên đã tồn tại');
case 422:
if (data is Map<String, dynamic> && data.containsKey('errors')) {
final errors = data['errors'] as Map<String, dynamic>?;
if (errors != null) {
final validationErrors = errors.map(
(key, value) => MapEntry(
key,
value is List
? value.cast<String>()
: [value.toString()],
),
);
return ValidationException(
message ?? 'Dữ liệu không hợp lệ',
errors: validationErrors,
);
}
}
return ValidationException(message ?? 'Dữ liệu không hợp lệ');
case 429:
final retryAfter = response.headers.value('retry-after');
final retrySeconds = retryAfter != null ? int.tryParse(retryAfter) : null;
return RateLimitException(message ?? 'Quá nhiều yêu cầu', retrySeconds);
case 500:
case 502:
case 503:
case 504:
if (statusCode == 503) {
return const ServiceUnavailableException();
}
return ServerException(message ?? 'Lỗi máy chủ', statusCode);
default:
return NetworkException(
message ?? 'Lỗi mạng không xác định',
statusCode: statusCode,
data: data,
);
}
}
}
// ============================================================================
// Riverpod Providers
// ============================================================================
/// Provider for SharedPreferences instance
@riverpod
Future<SharedPreferences> sharedPreferences(Ref ref) async {
return await SharedPreferences.getInstance();
}
/// Provider for AuthInterceptor
@riverpod
Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
final prefs = await ref.watch(sharedPreferencesProvider.future);
return AuthInterceptor(prefs, dio);
}
/// Provider for LoggingInterceptor
@riverpod
LoggingInterceptor loggingInterceptor(Ref ref) {
// Only enable logging in debug mode
const bool isDebug = true; // TODO: Replace with kDebugMode from Flutter
return LoggingInterceptor(
enableRequestLogging: isDebug,
enableResponseLogging: isDebug,
enableErrorLogging: isDebug,
);
}
/// Provider for ErrorTransformerInterceptor
@riverpod
ErrorTransformerInterceptor errorTransformerInterceptor(Ref ref) {
return ErrorTransformerInterceptor();
}

View File

@@ -0,0 +1,246 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'api_interceptor.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for SharedPreferences instance
@ProviderFor(sharedPreferences)
const sharedPreferencesProvider = SharedPreferencesProvider._();
/// Provider for SharedPreferences instance
final class SharedPreferencesProvider
extends
$FunctionalProvider<
AsyncValue<SharedPreferences>,
SharedPreferences,
FutureOr<SharedPreferences>
>
with
$FutureModifier<SharedPreferences>,
$FutureProvider<SharedPreferences> {
/// Provider for SharedPreferences instance
const SharedPreferencesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'sharedPreferencesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$sharedPreferencesHash();
@$internal
@override
$FutureProviderElement<SharedPreferences> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SharedPreferences> create(Ref ref) {
return sharedPreferences(ref);
}
}
String _$sharedPreferencesHash() => r'dc403fbb1d968c7d5ab4ae1721a29ffe173701c7';
/// Provider for AuthInterceptor
@ProviderFor(authInterceptor)
const authInterceptorProvider = AuthInterceptorFamily._();
/// Provider for AuthInterceptor
final class AuthInterceptorProvider
extends
$FunctionalProvider<
AsyncValue<AuthInterceptor>,
AuthInterceptor,
FutureOr<AuthInterceptor>
>
with $FutureModifier<AuthInterceptor>, $FutureProvider<AuthInterceptor> {
/// Provider for AuthInterceptor
const AuthInterceptorProvider._({
required AuthInterceptorFamily super.from,
required Dio super.argument,
}) : super(
retry: null,
name: r'authInterceptorProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$authInterceptorHash();
@override
String toString() {
return r'authInterceptorProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<AuthInterceptor> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<AuthInterceptor> create(Ref ref) {
final argument = this.argument as Dio;
return authInterceptor(ref, argument);
}
@override
bool operator ==(Object other) {
return other is AuthInterceptorProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$authInterceptorHash() => r'b54ba9af62c3cd7b922ef4030a8e2debb0220e10';
/// Provider for AuthInterceptor
final class AuthInterceptorFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<AuthInterceptor>, Dio> {
const AuthInterceptorFamily._()
: super(
retry: null,
name: r'authInterceptorProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Provider for AuthInterceptor
AuthInterceptorProvider call(Dio dio) =>
AuthInterceptorProvider._(argument: dio, from: this);
@override
String toString() => r'authInterceptorProvider';
}
/// Provider for LoggingInterceptor
@ProviderFor(loggingInterceptor)
const loggingInterceptorProvider = LoggingInterceptorProvider._();
/// Provider for LoggingInterceptor
final class LoggingInterceptorProvider
extends
$FunctionalProvider<
LoggingInterceptor,
LoggingInterceptor,
LoggingInterceptor
>
with $Provider<LoggingInterceptor> {
/// Provider for LoggingInterceptor
const LoggingInterceptorProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'loggingInterceptorProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$loggingInterceptorHash();
@$internal
@override
$ProviderElement<LoggingInterceptor> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
LoggingInterceptor create(Ref ref) {
return loggingInterceptor(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(LoggingInterceptor value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<LoggingInterceptor>(value),
);
}
}
String _$loggingInterceptorHash() =>
r'f3dedaeb3152d5188544232f6f270bb6908c2827';
/// Provider for ErrorTransformerInterceptor
@ProviderFor(errorTransformerInterceptor)
const errorTransformerInterceptorProvider =
ErrorTransformerInterceptorProvider._();
/// Provider for ErrorTransformerInterceptor
final class ErrorTransformerInterceptorProvider
extends
$FunctionalProvider<
ErrorTransformerInterceptor,
ErrorTransformerInterceptor,
ErrorTransformerInterceptor
>
with $Provider<ErrorTransformerInterceptor> {
/// Provider for ErrorTransformerInterceptor
const ErrorTransformerInterceptorProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'errorTransformerInterceptorProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$errorTransformerInterceptorHash();
@$internal
@override
$ProviderElement<ErrorTransformerInterceptor> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
ErrorTransformerInterceptor create(Ref ref) {
return errorTransformerInterceptor(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ErrorTransformerInterceptor value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ErrorTransformerInterceptor>(value),
);
}
}
String _$errorTransformerInterceptorHash() =>
r'15a14206b96d046054277ee0b8220838e0e9e267';

View File

@@ -0,0 +1,496 @@
/// Dio HTTP client configuration for the Worker app
///
/// Provides a configured Dio instance with interceptors for:
/// - Authentication
/// - Logging
/// - Error handling
/// - Caching
/// - Retry logic
library;
import 'package:dio/dio.dart';
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/constants/api_constants.dart';
import 'package:worker/core/network/api_interceptor.dart';
import 'package:worker/core/network/network_info.dart';
part 'dio_client.g.dart';
// ============================================================================
// Dio Client Configuration
// ============================================================================
/// HTTP client wrapper around Dio with interceptors and configuration
class DioClient {
DioClient(this._dio, this._cacheStore);
final Dio _dio;
final CacheStore? _cacheStore;
/// Get the underlying Dio instance
Dio get dio => _dio;
/// Get the cache store
CacheStore? get cacheStore => _cacheStore;
// ============================================================================
// HTTP Methods
// ============================================================================
/// Perform GET request
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
}) async {
try {
return await _dio.get<T>(
path,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
);
} catch (e) {
rethrow;
}
}
/// Perform POST request
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) async {
try {
return await _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
} catch (e) {
rethrow;
}
}
/// Perform PUT request
Future<Response<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) async {
try {
return await _dio.put<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
} catch (e) {
rethrow;
}
}
/// Perform PATCH request
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
}) async {
try {
return await _dio.patch<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
);
} catch (e) {
rethrow;
}
}
/// Perform DELETE request
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
}) async {
try {
return await _dio.delete<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
);
} catch (e) {
rethrow;
}
}
/// Upload file with multipart/form-data
Future<Response<T>> uploadFile<T>(
String path, {
required FormData formData,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
}) async {
try {
return await _dio.post<T>(
path,
data: formData,
queryParameters: queryParameters,
options: options,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
);
} catch (e) {
rethrow;
}
}
/// Download file
Future<Response<dynamic>> downloadFile(
String urlPath,
String savePath, {
ProgressCallback? onReceiveProgress,
Map<String, dynamic>? queryParameters,
CancelToken? cancelToken,
bool deleteOnError = true,
String lengthHeader = Headers.contentLengthHeader,
Options? options,
}) async {
try {
return await _dio.download(
urlPath,
savePath,
onReceiveProgress: onReceiveProgress,
queryParameters: queryParameters,
cancelToken: cancelToken,
deleteOnError: deleteOnError,
lengthHeader: lengthHeader,
options: options,
);
} catch (e) {
rethrow;
}
}
// ============================================================================
// Cache Management
// ============================================================================
/// Clear all cached responses
Future<void> clearCache() async {
if (_cacheStore != null) {
await _cacheStore!.clean();
}
}
/// Clear specific cached response by key
Future<void> clearCacheByKey(String key) async {
if (_cacheStore != null) {
await _cacheStore!.delete(key);
}
}
/// Clear cache for specific path
Future<void> clearCacheByPath(String path) async {
if (_cacheStore != null) {
final key = CacheOptions.defaultCacheKeyBuilder(
RequestOptions(path: path),
);
await _cacheStore!.delete(key);
}
}
}
// ============================================================================
// Retry Interceptor
// ============================================================================
/// Interceptor for retrying failed requests with exponential backoff
class RetryInterceptor extends Interceptor {
RetryInterceptor(
this._networkInfo, {
this.maxRetries = ApiConstants.maxRetryAttempts,
this.initialDelay = ApiConstants.initialRetryDelay,
this.maxDelay = ApiConstants.maxRetryDelay,
this.delayMultiplier = ApiConstants.retryDelayMultiplier,
});
final NetworkInfo _networkInfo;
final int maxRetries;
final Duration initialDelay;
final Duration maxDelay;
final double delayMultiplier;
@override
void onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
// Get retry count from request extra
final retries = err.requestOptions.extra['retries'] as int? ?? 0;
// Check if we should retry
if (retries >= maxRetries || !_shouldRetry(err)) {
handler.next(err);
return;
}
// Check network connectivity before retrying
final isConnected = await _networkInfo.isConnected;
if (!isConnected) {
handler.next(err);
return;
}
// Calculate delay with exponential backoff
final delayMs = (initialDelay.inMilliseconds *
(delayMultiplier * (retries + 1))).toInt();
final delay = Duration(
milliseconds: delayMs.clamp(
initialDelay.inMilliseconds,
maxDelay.inMilliseconds,
),
);
// Wait before retry
await Future<void>.delayed(delay);
// Increment retry count
err.requestOptions.extra['retries'] = retries + 1;
// Retry the request
try {
final dio = Dio();
final response = await dio.fetch<dynamic>(err.requestOptions);
handler.resolve(response);
} on DioException catch (e) {
handler.next(e);
}
}
/// Determine if error should trigger a retry
bool _shouldRetry(DioException error) {
// Retry on connection errors
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.connectionError) {
return true;
}
// Retry on 5xx server errors (except 501)
final statusCode = error.response?.statusCode;
if (statusCode != null && statusCode >= 500 && statusCode != 501) {
return true;
}
// Retry on 408 Request Timeout
if (statusCode == 408) {
return true;
}
// Retry on 429 Too Many Requests (with delay from header)
if (statusCode == 429) {
return true;
}
return false;
}
}
// ============================================================================
// Riverpod Providers
// ============================================================================
/// Provider for cache store
@riverpod
Future<CacheStore> cacheStore(Ref ref) async {
final directory = await getTemporaryDirectory();
return HiveCacheStore(
directory.path,
hiveBoxName: 'dio_cache',
);
}
/// Provider for cache options
@riverpod
Future<CacheOptions> cacheOptions(Ref ref) async {
final store = await ref.watch(cacheStoreProvider.future);
return CacheOptions(
store: store,
maxStale: const Duration(days: 7), // Keep cache for 7 days
hitCacheOnErrorExcept: [401, 403], // Use cache on error except auth errors
priority: CachePriority.high,
cipher: null, // No encryption for now
keyBuilder: CacheOptions.defaultCacheKeyBuilder,
allowPostMethod: false, // Don't cache POST requests by default
);
}
/// Provider for Dio instance with all interceptors
@riverpod
Future<Dio> dio(Ref ref) async {
final dio = Dio();
// Base configuration
dio
..options = BaseOptions(
baseUrl: ApiConstants.apiBaseUrl,
connectTimeout: ApiConstants.connectionTimeout,
receiveTimeout: ApiConstants.receiveTimeout,
sendTimeout: ApiConstants.sendTimeout,
headers: {
'Content-Type': ApiConstants.contentTypeJson,
'Accept': ApiConstants.acceptJson,
'Accept-Language': ApiConstants.acceptLanguageVi,
},
responseType: ResponseType.json,
validateStatus: (status) {
// Accept all status codes and handle errors in interceptor
return status != null && status < 500;
},
)
// Add interceptors in order
// 1. Logging interceptor (first to log everything)
..interceptors.add(ref.watch(loggingInterceptorProvider))
// 2. Auth interceptor (add tokens to requests)
..interceptors.add(await ref.watch(authInterceptorProvider(dio).future))
// 3. Cache interceptor
..interceptors.add(DioCacheInterceptor(options: await ref.watch(cacheOptionsProvider.future)))
// 4. Retry interceptor
..interceptors.add(RetryInterceptor(ref.watch(networkInfoProvider)))
// 5. Error transformer (last to transform all errors)
..interceptors.add(ref.watch(errorTransformerInterceptorProvider));
return dio;
}
/// Provider for DioClient
@riverpod
Future<DioClient> dioClient(Ref ref) async {
final dio = await ref.watch(dioProvider.future);
final cacheStore = await ref.watch(cacheStoreProvider.future);
return DioClient(dio, cacheStore);
}
// ============================================================================
// Helper Classes
// ============================================================================
/// Options for API requests with custom cache policy
class ApiRequestOptions {
const ApiRequestOptions({
this.cachePolicy,
this.cacheDuration,
this.forceRefresh = false,
});
final CachePolicy? cachePolicy;
final Duration? cacheDuration;
final bool forceRefresh;
/// Options with cache enabled
static const cached = ApiRequestOptions(
cachePolicy: CachePolicy.forceCache,
);
/// Options with network-first strategy
static const networkFirst = ApiRequestOptions(
cachePolicy: CachePolicy.refreshForceCache,
);
/// Options to force refresh from network
static const forceNetwork = ApiRequestOptions(
cachePolicy: CachePolicy.refresh,
forceRefresh: true,
);
/// Convert to Dio Options
Options toDioOptions() {
return Options(
extra: <String, dynamic>{
if (cachePolicy != null)
CacheResponse.cacheKey: cachePolicy!.index,
if (cacheDuration != null)
'maxStale': cacheDuration,
if (forceRefresh)
'policy': CachePolicy.refresh.index,
},
);
}
}
/// Offline request queue item
class QueuedRequest {
QueuedRequest({
required this.method,
required this.path,
this.data,
this.queryParameters,
required this.timestamp,
});
factory QueuedRequest.fromJson(Map<String, dynamic> json) {
return QueuedRequest(
method: json['method'] as String,
path: json['path'] as String,
data: json['data'],
queryParameters: json['queryParameters'] as Map<String, dynamic>?,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
final String method;
final String path;
final dynamic data;
final Map<String, dynamic>? queryParameters;
final DateTime timestamp;
Map<String, dynamic> toJson() => <String, dynamic>{
'method': method,
'path': path,
'data': data,
'queryParameters': queryParameters,
'timestamp': timestamp.toIso8601String(),
};
}

View File

@@ -0,0 +1,177 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dio_client.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for cache store
@ProviderFor(cacheStore)
const cacheStoreProvider = CacheStoreProvider._();
/// Provider for cache store
final class CacheStoreProvider
extends
$FunctionalProvider<
AsyncValue<CacheStore>,
CacheStore,
FutureOr<CacheStore>
>
with $FutureModifier<CacheStore>, $FutureProvider<CacheStore> {
/// Provider for cache store
const CacheStoreProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cacheStoreProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cacheStoreHash();
@$internal
@override
$FutureProviderElement<CacheStore> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<CacheStore> create(Ref ref) {
return cacheStore(ref);
}
}
String _$cacheStoreHash() => r'8cbc2688ee267e03fc5aa6bf48c3ada249cb6345';
/// Provider for cache options
@ProviderFor(cacheOptions)
const cacheOptionsProvider = CacheOptionsProvider._();
/// Provider for cache options
final class CacheOptionsProvider
extends
$FunctionalProvider<
AsyncValue<CacheOptions>,
CacheOptions,
FutureOr<CacheOptions>
>
with $FutureModifier<CacheOptions>, $FutureProvider<CacheOptions> {
/// Provider for cache options
const CacheOptionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cacheOptionsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cacheOptionsHash();
@$internal
@override
$FutureProviderElement<CacheOptions> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<CacheOptions> create(Ref ref) {
return cacheOptions(ref);
}
}
String _$cacheOptionsHash() => r'6b6b951855d8c0094e36918efa79c6ba586e156d';
/// Provider for Dio instance with all interceptors
@ProviderFor(dio)
const dioProvider = DioProvider._();
/// Provider for Dio instance with all interceptors
final class DioProvider
extends $FunctionalProvider<AsyncValue<Dio>, Dio, FutureOr<Dio>>
with $FutureModifier<Dio>, $FutureProvider<Dio> {
/// Provider for Dio instance with all interceptors
const DioProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'dioProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$dioHash();
@$internal
@override
$FutureProviderElement<Dio> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<Dio> create(Ref ref) {
return dio(ref);
}
}
String _$dioHash() => r'f15495e99d11744c245e2be892657748aeeb8ae7';
/// Provider for DioClient
@ProviderFor(dioClient)
const dioClientProvider = DioClientProvider._();
/// Provider for DioClient
final class DioClientProvider
extends
$FunctionalProvider<
AsyncValue<DioClient>,
DioClient,
FutureOr<DioClient>
>
with $FutureModifier<DioClient>, $FutureProvider<DioClient> {
/// Provider for DioClient
const DioClientProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'dioClientProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$dioClientHash();
@$internal
@override
$FutureProviderElement<DioClient> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<DioClient> create(Ref ref) {
return dioClient(ref);
}
}
String _$dioClientHash() => r'4f6754880ccc00aa99b8ae19904e9da88950a4e1';

View File

@@ -0,0 +1,365 @@
/// Network connectivity information and monitoring
///
/// Provides real-time network status checking, connection type detection,
/// and connectivity monitoring for the Worker app.
library;
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'network_info.g.dart';
// ============================================================================
// Network Connection Types
// ============================================================================
/// Types of network connections
enum NetworkConnectionType {
/// WiFi connection
wifi,
/// Mobile data connection
mobile,
/// Ethernet connection (wired)
ethernet,
/// Bluetooth connection
bluetooth,
/// VPN connection
vpn,
/// No connection
none,
/// Unknown connection type
unknown,
}
// ============================================================================
// Network Status
// ============================================================================
/// Network connectivity status
class NetworkStatus {
const NetworkStatus({
required this.isConnected,
required this.connectionType,
required this.timestamp,
});
factory NetworkStatus.connected(NetworkConnectionType type) {
return NetworkStatus(
isConnected: true,
connectionType: type,
timestamp: DateTime.now(),
);
}
factory NetworkStatus.disconnected() {
return NetworkStatus(
isConnected: false,
connectionType: NetworkConnectionType.none,
timestamp: DateTime.now(),
);
}
final bool isConnected;
final NetworkConnectionType connectionType;
final DateTime timestamp;
/// Check if connected via WiFi
bool get isWiFi => connectionType == NetworkConnectionType.wifi;
/// Check if connected via mobile data
bool get isMobile => connectionType == NetworkConnectionType.mobile;
/// Check if connected via ethernet
bool get isEthernet => connectionType == NetworkConnectionType.ethernet;
/// Check if connection is metered (mobile data)
bool get isMetered => isMobile;
@override
String toString() {
return 'NetworkStatus(isConnected: $isConnected, type: $connectionType)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is NetworkStatus &&
other.isConnected == isConnected &&
other.connectionType == connectionType;
}
@override
int get hashCode => Object.hash(isConnected, connectionType);
}
// ============================================================================
// Network Info Interface
// ============================================================================
/// Abstract interface for network information
abstract class NetworkInfo {
/// Check if device is currently connected to internet
Future<bool> get isConnected;
/// Get current network connection type
Future<NetworkConnectionType> get connectionType;
/// Get current network status
Future<NetworkStatus> get networkStatus;
/// Stream of network status changes
Stream<NetworkStatus> get onNetworkStatusChanged;
}
// ============================================================================
// Network Info Implementation
// ============================================================================
/// Implementation of NetworkInfo using connectivity_plus
class NetworkInfoImpl implements NetworkInfo {
NetworkInfoImpl(this._connectivity);
final Connectivity _connectivity;
StreamController<NetworkStatus>? _statusController;
StreamSubscription<List<ConnectivityResult>>? _subscription;
@override
Future<bool> get isConnected async {
final results = await _connectivity.checkConnectivity();
return _hasConnection(results);
}
@override
Future<NetworkConnectionType> get connectionType async {
final results = await _connectivity.checkConnectivity();
return _mapConnectivityResult(results);
}
@override
Future<NetworkStatus> get networkStatus async {
final results = await _connectivity.checkConnectivity();
final hasConnection = _hasConnection(results);
final type = _mapConnectivityResult(results);
if (hasConnection) {
return NetworkStatus.connected(type);
} else {
return NetworkStatus.disconnected();
}
}
@override
Stream<NetworkStatus> get onNetworkStatusChanged {
_statusController ??= StreamController<NetworkStatus>.broadcast(
onListen: _startListening,
onCancel: _stopListening,
);
return _statusController!.stream;
}
void _startListening() {
_subscription = _connectivity.onConnectivityChanged.listen(
(results) {
final hasConnection = _hasConnection(results);
final type = _mapConnectivityResult(results);
final status = hasConnection
? NetworkStatus.connected(type)
: NetworkStatus.disconnected();
_statusController?.add(status);
},
onError: (error) {
_statusController?.add(NetworkStatus.disconnected());
},
);
}
void _stopListening() {
_subscription?.cancel();
_subscription = null;
}
bool _hasConnection(List<ConnectivityResult> results) {
if (results.isEmpty) return false;
return !results.contains(ConnectivityResult.none);
}
NetworkConnectionType _mapConnectivityResult(List<ConnectivityResult> results) {
if (results.isEmpty || results.contains(ConnectivityResult.none)) {
return NetworkConnectionType.none;
}
// Priority order: WiFi > Ethernet > Mobile > Bluetooth > VPN
if (results.contains(ConnectivityResult.wifi)) {
return NetworkConnectionType.wifi;
} else if (results.contains(ConnectivityResult.ethernet)) {
return NetworkConnectionType.ethernet;
} else if (results.contains(ConnectivityResult.mobile)) {
return NetworkConnectionType.mobile;
} else if (results.contains(ConnectivityResult.bluetooth)) {
return NetworkConnectionType.bluetooth;
} else if (results.contains(ConnectivityResult.vpn)) {
return NetworkConnectionType.vpn;
} else {
return NetworkConnectionType.unknown;
}
}
/// Dispose resources
void dispose() {
_subscription?.cancel();
_statusController?.close();
}
}
// ============================================================================
// Riverpod Providers
// ============================================================================
/// Provider for Connectivity instance
@riverpod
Connectivity connectivity(Ref ref) {
return Connectivity();
}
/// Provider for NetworkInfo instance
@riverpod
NetworkInfo networkInfo(Ref ref) {
final connectivity = ref.watch(connectivityProvider);
final networkInfo = NetworkInfoImpl(connectivity);
// Dispose when provider is disposed
ref.onDispose(() {
networkInfo.dispose();
});
return networkInfo;
}
/// Provider for current network connection status (boolean)
@riverpod
Future<bool> isConnected(Ref ref) async {
final networkInfo = ref.watch(networkInfoProvider);
return await networkInfo.isConnected;
}
/// Provider for current network connection type
@riverpod
Future<NetworkConnectionType> connectionType(Ref ref) async {
final networkInfo = ref.watch(networkInfoProvider);
return await networkInfo.connectionType;
}
/// Stream provider for network status changes
@riverpod
Stream<NetworkStatus> networkStatusStream(Ref ref) {
final networkInfo = ref.watch(networkInfoProvider);
return networkInfo.onNetworkStatusChanged;
}
/// Provider for current network status
@riverpod
class NetworkStatusNotifier extends _$NetworkStatusNotifier {
@override
Future<NetworkStatus> build() async {
final networkInfo = ref.watch(networkInfoProvider);
final status = await networkInfo.networkStatus;
// Listen to network changes
ref.listen(
networkStatusStreamProvider,
(_, next) {
next.whenData((newStatus) {
state = AsyncValue.data(newStatus);
});
},
);
return status;
}
/// Manually refresh network status
Future<void> refresh() async {
state = const AsyncValue.loading();
final networkInfo = ref.read(networkInfoProvider);
state = await AsyncValue.guard(() => networkInfo.networkStatus);
}
/// Check if connected
bool get isConnected {
return state.when(
data: (status) => status.isConnected,
loading: () => false,
error: (_, __) => false,
);
}
/// Get connection type
NetworkConnectionType get type {
return state.when(
data: (status) => status.connectionType,
loading: () => NetworkConnectionType.none,
error: (_, __) => NetworkConnectionType.none,
);
}
}
// ============================================================================
// Utility Extensions
// ============================================================================
/// Extension methods for NetworkConnectionType
extension NetworkConnectionTypeX on NetworkConnectionType {
/// Get display name in Vietnamese
String get displayNameVi {
switch (this) {
case NetworkConnectionType.wifi:
return 'WiFi';
case NetworkConnectionType.mobile:
return 'Dữ liệu di động';
case NetworkConnectionType.ethernet:
return 'Ethernet';
case NetworkConnectionType.bluetooth:
return 'Bluetooth';
case NetworkConnectionType.vpn:
return 'VPN';
case NetworkConnectionType.none:
return 'Không có kết nối';
case NetworkConnectionType.unknown:
return 'Không xác định';
}
}
/// Get display name in English
String get displayNameEn {
switch (this) {
case NetworkConnectionType.wifi:
return 'WiFi';
case NetworkConnectionType.mobile:
return 'Mobile Data';
case NetworkConnectionType.ethernet:
return 'Ethernet';
case NetworkConnectionType.bluetooth:
return 'Bluetooth';
case NetworkConnectionType.vpn:
return 'VPN';
case NetworkConnectionType.none:
return 'No Connection';
case NetworkConnectionType.unknown:
return 'Unknown';
}
}
/// Check if this is a valid connection type
bool get isValid {
return this != NetworkConnectionType.none &&
this != NetworkConnectionType.unknown;
}
}

View File

@@ -0,0 +1,282 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'network_info.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for Connectivity instance
@ProviderFor(connectivity)
const connectivityProvider = ConnectivityProvider._();
/// Provider for Connectivity instance
final class ConnectivityProvider
extends $FunctionalProvider<Connectivity, Connectivity, Connectivity>
with $Provider<Connectivity> {
/// Provider for Connectivity instance
const ConnectivityProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'connectivityProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$connectivityHash();
@$internal
@override
$ProviderElement<Connectivity> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Connectivity create(Ref ref) {
return connectivity(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Connectivity value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Connectivity>(value),
);
}
}
String _$connectivityHash() => r'6d67af0ea4110f6ee0246dd332f90f8901380eda';
/// Provider for NetworkInfo instance
@ProviderFor(networkInfo)
const networkInfoProvider = NetworkInfoProvider._();
/// Provider for NetworkInfo instance
final class NetworkInfoProvider
extends $FunctionalProvider<NetworkInfo, NetworkInfo, NetworkInfo>
with $Provider<NetworkInfo> {
/// Provider for NetworkInfo instance
const NetworkInfoProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'networkInfoProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$networkInfoHash();
@$internal
@override
$ProviderElement<NetworkInfo> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
NetworkInfo create(Ref ref) {
return networkInfo(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(NetworkInfo value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<NetworkInfo>(value),
);
}
}
String _$networkInfoHash() => r'aee276b1536c8c994273dbed1909a2c24a7c71d2';
/// Provider for current network connection status (boolean)
@ProviderFor(isConnected)
const isConnectedProvider = IsConnectedProvider._();
/// Provider for current network connection status (boolean)
final class IsConnectedProvider
extends $FunctionalProvider<AsyncValue<bool>, bool, FutureOr<bool>>
with $FutureModifier<bool>, $FutureProvider<bool> {
/// Provider for current network connection status (boolean)
const IsConnectedProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isConnectedProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isConnectedHash();
@$internal
@override
$FutureProviderElement<bool> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<bool> create(Ref ref) {
return isConnected(ref);
}
}
String _$isConnectedHash() => r'c9620cadbcdee8e738f865e747dd57262236782d';
/// Provider for current network connection type
@ProviderFor(connectionType)
const connectionTypeProvider = ConnectionTypeProvider._();
/// Provider for current network connection type
final class ConnectionTypeProvider
extends
$FunctionalProvider<
AsyncValue<NetworkConnectionType>,
NetworkConnectionType,
FutureOr<NetworkConnectionType>
>
with
$FutureModifier<NetworkConnectionType>,
$FutureProvider<NetworkConnectionType> {
/// Provider for current network connection type
const ConnectionTypeProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'connectionTypeProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$connectionTypeHash();
@$internal
@override
$FutureProviderElement<NetworkConnectionType> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<NetworkConnectionType> create(Ref ref) {
return connectionType(ref);
}
}
String _$connectionTypeHash() => r'413aead6c4ff6f2c1476e4795934fddb76b797e6';
/// Stream provider for network status changes
@ProviderFor(networkStatusStream)
const networkStatusStreamProvider = NetworkStatusStreamProvider._();
/// Stream provider for network status changes
final class NetworkStatusStreamProvider
extends
$FunctionalProvider<
AsyncValue<NetworkStatus>,
NetworkStatus,
Stream<NetworkStatus>
>
with $FutureModifier<NetworkStatus>, $StreamProvider<NetworkStatus> {
/// Stream provider for network status changes
const NetworkStatusStreamProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'networkStatusStreamProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$networkStatusStreamHash();
@$internal
@override
$StreamProviderElement<NetworkStatus> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<NetworkStatus> create(Ref ref) {
return networkStatusStream(ref);
}
}
String _$networkStatusStreamHash() =>
r'bdff8d93b214ebc290e81ab72fb8d51d8bfb27b1';
/// Provider for current network status
@ProviderFor(NetworkStatusNotifier)
const networkStatusProvider = NetworkStatusNotifierProvider._();
/// Provider for current network status
final class NetworkStatusNotifierProvider
extends $AsyncNotifierProvider<NetworkStatusNotifier, NetworkStatus> {
/// Provider for current network status
const NetworkStatusNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'networkStatusProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$networkStatusNotifierHash();
@$internal
@override
NetworkStatusNotifier create() => NetworkStatusNotifier();
}
String _$networkStatusNotifierHash() =>
r'628e313a66129282cd06dfdd561af3f0a4517b4f';
/// Provider for current network status
abstract class _$NetworkStatusNotifier extends $AsyncNotifier<NetworkStatus> {
FutureOr<NetworkStatus> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<NetworkStatus>, NetworkStatus>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<NetworkStatus>, NetworkStatus>,
AsyncValue<NetworkStatus>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,462 @@
# Riverpod 3.0 Quick Reference Card
## File Structure
```dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'my_provider.g.dart'; // REQUIRED!
// Your providers here
```
## Provider Types
### 1. Simple Value (Immutable)
```dart
@riverpod
String appName(AppNameRef ref) => 'Worker App';
// Usage
final name = ref.watch(appNameProvider);
```
### 2. Async Value (Future)
```dart
@riverpod
Future<User> user(UserRef ref, String id) async {
return await fetchUser(id);
}
// Usage
final userAsync = ref.watch(userProvider('123'));
userAsync.when(
data: (user) => Text(user.name),
loading: () => CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
);
```
### 3. Stream
```dart
@riverpod
Stream<Message> messages(MessagesRef ref) {
return webSocket.messages;
}
// Usage
final messages = ref.watch(messagesProvider);
```
### 4. Mutable State (Notifier)
```dart
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
void decrement() => state--;
}
// Usage
final count = ref.watch(counterProvider);
ref.read(counterProvider.notifier).increment();
```
### 5. Async Mutable State (AsyncNotifier)
```dart
@riverpod
class Profile extends _$Profile {
@override
Future<User> build() async {
return await api.getProfile();
}
Future<void> update(String name) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
return await api.updateProfile(name);
});
}
}
// Usage
final profile = ref.watch(profileProvider);
await ref.read(profileProvider.notifier).update('New Name');
```
## Family (Parameters)
```dart
// Single parameter
@riverpod
Future<Post> post(PostRef ref, String id) async {
return await api.getPost(id);
}
// Multiple parameters
@riverpod
Future<List<Post>> posts(
PostsRef ref, {
required String userId,
int page = 1,
String? category,
}) async {
return await api.getPosts(userId, page, category);
}
// Usage
ref.watch(postProvider('post-123'));
ref.watch(postsProvider(userId: 'user-1', page: 2));
```
## AutoDispose vs KeepAlive
```dart
// AutoDispose (default) - cleaned up when not used
@riverpod
String temp(TempRef ref) => 'Auto disposed';
// KeepAlive - stays alive
@Riverpod(keepAlive: true)
String config(ConfigRef ref) => 'Global config';
```
## Usage in Widgets
### ConsumerWidget
```dart
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(myProvider);
return Text(value);
}
}
```
### ConsumerStatefulWidget
```dart
class MyWidget extends ConsumerStatefulWidget {
@override
ConsumerState<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends ConsumerState<MyWidget> {
@override
Widget build(BuildContext context) {
final value = ref.watch(myProvider);
return Text(value);
}
}
```
### Consumer (optimization)
```dart
Consumer(
builder: (context, ref, child) {
final count = ref.watch(counterProvider);
return Text('$count');
},
)
```
## Ref Methods
### ref.watch() - Use in build
```dart
// Rebuilds when value changes
final value = ref.watch(myProvider);
```
### ref.read() - Use in callbacks
```dart
// One-time read, doesn't listen
onPressed: () {
ref.read(myProvider.notifier).update();
}
```
### ref.listen() - Side effects
```dart
ref.listen(authProvider, (prev, next) {
if (next.isLoggedOut) {
Navigator.of(context).pushReplacementNamed('/login');
}
});
```
### ref.invalidate() - Force refresh
```dart
ref.invalidate(userProvider);
```
### ref.refresh() - Invalidate and read
```dart
final newValue = ref.refresh(userProvider);
```
## AsyncValue Handling
### .when()
```dart
asyncValue.when(
data: (value) => Text(value),
loading: () => CircularProgressIndicator(),
error: (error, stack) => Text('Error: $error'),
);
```
### Pattern Matching (Dart 3+)
```dart
switch (asyncValue) {
case AsyncData(:final value):
return Text(value);
case AsyncError(:final error):
return Text('Error: $error');
case AsyncLoading():
return CircularProgressIndicator();
}
```
### Direct Checks
```dart
if (asyncValue.isLoading) return Loading();
if (asyncValue.hasError) return Error();
final data = asyncValue.value!;
```
## Performance Optimization
### Use .select()
```dart
// Bad - rebuilds on any user change
final user = ref.watch(userProvider);
// Good - rebuilds only when name changes
final name = ref.watch(
userProvider.select((user) => user.name),
);
// With AsyncValue
final name = ref.watch(
userProvider.select((async) => async.value?.name),
);
```
## Error Handling
### AsyncValue.guard()
```dart
@riverpod
class Data extends _$Data {
@override
Future<String> build() async => 'Initial';
Future<void> update(String value) async {
state = const AsyncValue.loading();
// Catches errors automatically
state = await AsyncValue.guard(() async {
return await api.update(value);
});
}
}
```
## Provider Composition
```dart
@riverpod
Future<Dashboard> dashboard(DashboardRef ref) async {
// Depend on other providers
final user = await ref.watch(userProvider.future);
final posts = await ref.watch(postsProvider.future);
return Dashboard(user: user, posts: posts);
}
```
## Lifecycle Hooks
```dart
@riverpod
String example(ExampleRef ref) {
ref.onDispose(() {
// Cleanup
print('Disposed');
});
ref.onCancel(() {
// Last listener removed
});
ref.onResume(() {
// New listener added
});
return 'value';
}
```
## ref.mounted Check (Riverpod 3.0)
```dart
@riverpod
class Example extends _$Example {
@override
String build() => 'Initial';
Future<void> update() async {
await Future.delayed(Duration(seconds: 2));
// Check if still mounted
if (!ref.mounted) return;
state = 'Updated';
}
}
```
## Code Generation Commands
```bash
# Watch mode (recommended)
dart run build_runner watch -d
# One-time build
dart run build_runner build --delete-conflicting-outputs
# Clean and rebuild
dart run build_runner clean && dart run build_runner build -d
```
## Linting
```bash
# Check Riverpod issues
dart run custom_lint
# Auto-fix
dart run custom_lint --fix
```
## Testing
```dart
test('counter increments', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(counterProvider), 0);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});
```
## Widget Testing
```dart
testWidgets('test', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userProvider.overrideWith((ref) => User(name: 'Test')),
],
child: MaterialApp(home: MyScreen()),
),
);
expect(find.text('Test'), findsOneWidget);
});
```
## Best Practices
**DO:**
- Use `ref.watch()` in build methods
- Use `ref.read()` in event handlers
- Use `.select()` to optimize rebuilds
- Check `ref.mounted` after async operations
- Use `AsyncValue.guard()` for error handling
- Use autoDispose for temporary state
- Keep providers in dedicated directories
**DON'T:**
- Use `ref.read()` in build methods
- Forget the `part` directive
- Use deprecated `StateNotifierProvider`
- Create providers without code generation
- Forget to run build_runner after changes
## Common Patterns
### Loading State
```dart
Future<void> save() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => api.save());
}
```
### Pagination
```dart
@riverpod
class PostList extends _$PostList {
@override
Future<List<Post>> build() => _fetch(0);
int _page = 0;
Future<void> loadMore() async {
final current = state.value ?? [];
_page++;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final newPosts = await _fetch(_page);
return [...current, ...newPosts];
});
}
Future<List<Post>> _fetch(int page) async {
return await api.getPosts(page: page);
}
}
```
### Form State
```dart
@riverpod
class LoginForm extends _$LoginForm {
@override
LoginFormState build() => LoginFormState();
void setEmail(String email) {
state = state.copyWith(email: email);
}
void setPassword(String password) {
state = state.copyWith(password: password);
}
Future<void> submit() async {
if (!state.isValid) return;
state = state.copyWith(isLoading: true);
try {
await api.login(state.email, state.password);
state = state.copyWith(isLoading: false, success: true);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
}
```
## Resources
- 📄 provider_examples.dart - All patterns with examples
- 📄 connectivity_provider.dart - Real-world implementation
- 📄 RIVERPOD_SETUP.md - Complete guide
- 🌐 https://riverpod.dev - Official documentation

View File

@@ -0,0 +1,454 @@
# Riverpod 3.0 Provider Architecture
This directory contains core-level providers that are used across the application.
## Directory Structure
```
lib/core/providers/
├── connectivity_provider.dart # Network connectivity monitoring
├── provider_examples.dart # Comprehensive Riverpod 3.0 examples
└── README.md # This file
```
## Setup Instructions
### 1. Install Dependencies
Dependencies are already configured in `pubspec.yaml`:
```yaml
dependencies:
flutter_riverpod: ^3.0.0
riverpod_annotation: ^3.0.0
dev_dependencies:
build_runner: ^2.4.11
riverpod_generator: ^3.0.0
riverpod_lint: ^3.0.0
custom_lint: ^0.7.0
```
Run to install:
```bash
flutter pub get
```
### 2. Generate Code
Run code generation whenever you create or modify providers:
```bash
# Watch mode (auto-regenerates on changes)
dart run build_runner watch -d
# One-time build
dart run build_runner build -d
# Clean and rebuild
dart run build_runner build --delete-conflicting-outputs
```
### 3. Wrap App with ProviderScope
In `main.dart`:
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
```
## Riverpod 3.0 Key Concepts
### @riverpod Annotation
The `@riverpod` annotation is the core of code generation. It automatically generates the appropriate provider type based on your function/class signature.
```dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'my_provider.g.dart';
// Simple value
@riverpod
String myValue(MyValueRef ref) => 'Hello';
// Async value
@riverpod
Future<String> myAsync(MyAsyncRef ref) async => 'Hello';
// Stream
@riverpod
Stream<int> myStream(MyStreamRef ref) => Stream.value(1);
// Mutable state
@riverpod
class MyNotifier extends _$MyNotifier {
@override
int build() => 0;
void increment() => state++;
}
// Async mutable state
@riverpod
class MyAsyncNotifier extends _$MyAsyncNotifier {
@override
Future<String> build() async => 'Initial';
Future<void> update(String value) async {
state = await AsyncValue.guard(() async => value);
}
}
```
### Provider Types (Auto-Generated)
1. **Simple Provider** - Immutable value
- Function returning T → Provider<T>
2. **FutureProvider** - Async value
- Function returning Future<T> → FutureProvider<T>
3. **StreamProvider** - Stream of values
- Function returning Stream<T> → StreamProvider<T>
4. **NotifierProvider** - Mutable state with methods
- Class extending Notifier → NotifierProvider
5. **AsyncNotifierProvider** - Async mutable state
- Class extending AsyncNotifier → AsyncNotifierProvider
6. **StreamNotifierProvider** - Stream mutable state
- Class extending StreamNotifier → StreamNotifierProvider
### Family (Parameters)
In Riverpod 3.0, family is just function parameters!
```dart
// Old way (Riverpod 2.x)
final userProvider = FutureProvider.family<User, String>((ref, id) async {
return fetchUser(id);
});
// New way (Riverpod 3.0)
@riverpod
Future<User> user(UserRef ref, String id) async {
return fetchUser(id);
}
// Multiple parameters (named, optional, defaults)
@riverpod
Future<List<Post>> posts(
PostsRef ref, {
required String userId,
int page = 1,
int limit = 20,
String? category,
}) async {
return fetchPosts(userId, page, limit, category);
}
// Usage
ref.watch(userProvider('user123'));
ref.watch(postsProvider(userId: 'user123', page: 2, category: 'tech'));
```
### AutoDispose vs KeepAlive
```dart
// AutoDispose (default) - cleaned up when not used
@riverpod
String autoExample(AutoExampleRef ref) => 'Auto disposed';
// KeepAlive - stays alive until app closes
@Riverpod(keepAlive: true)
String keepExample(KeepExampleRef ref) => 'Kept alive';
```
### Unified Ref Type
Riverpod 3.0 uses a single `Ref` type (no more FutureProviderRef, StreamProviderRef, etc.):
```dart
@riverpod
Future<String> example(ExampleRef ref) async {
// All providers use the same ref type
ref.watch(otherProvider);
ref.read(anotherProvider);
ref.listen(thirdProvider, (prev, next) {});
ref.invalidate(fourthProvider);
}
```
### ref.mounted Check
Always check `ref.mounted` after async operations in Notifiers:
```dart
@riverpod
class Example extends _$Example {
@override
String build() => 'Initial';
Future<void> updateData() async {
await Future.delayed(Duration(seconds: 2));
// Check if provider is still mounted
if (!ref.mounted) return;
state = 'Updated';
}
}
```
### Error Handling with AsyncValue.guard()
```dart
@riverpod
class Data extends _$Data {
@override
Future<String> build() async => 'Initial';
Future<void> update(String value) async {
state = const AsyncValue.loading();
// AsyncValue.guard catches errors automatically
state = await AsyncValue.guard(() async {
await api.update(value);
return value;
});
}
}
```
## Usage in Widgets
### ConsumerWidget
```dart
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(myProvider);
return Text(value);
}
}
```
### ConsumerStatefulWidget
```dart
class MyWidget extends ConsumerStatefulWidget {
@override
ConsumerState<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends ConsumerState<MyWidget> {
@override
void initState() {
super.initState();
// ref is available in all lifecycle methods
}
@override
Widget build(BuildContext context) {
final value = ref.watch(myProvider);
return Text(value);
}
}
```
### Consumer (for optimization)
```dart
Column(
children: [
const Text('Static'),
Consumer(
builder: (context, ref, child) {
final count = ref.watch(counterProvider);
return Text('$count');
},
),
],
)
```
## Best Practices
### 1. Use .select() for Optimization
```dart
// Bad - rebuilds on any user change
final user = ref.watch(userProvider);
// Good - rebuilds only when name changes
final name = ref.watch(userProvider.select((user) => user.name));
// Good - rebuilds only when async value has data
final userName = ref.watch(
userProvider.select((async) => async.value?.name),
);
```
### 2. Provider Dependencies
```dart
@riverpod
Future<Dashboard> dashboard(DashboardRef ref) async {
// Watch other providers
final user = await ref.watch(userProvider.future);
final posts = await ref.watch(postsProvider.future);
return Dashboard(user: user, posts: posts);
}
```
### 3. Invalidation and Refresh
```dart
// In a widget or notifier
ref.invalidate(userProvider); // Invalidate
ref.refresh(userProvider); // Invalidate and re-read
```
### 4. Lifecycle Hooks
```dart
@riverpod
String example(ExampleRef ref) {
ref.onDispose(() {
// Clean up
});
ref.onCancel(() {
// Last listener removed
});
ref.onResume(() {
// New listener added after cancel
});
return 'value';
}
```
### 5. Testing
```dart
test('counter increments', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(counterProvider), 0);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});
test('async provider', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
final value = await container.read(userProvider.future);
expect(value.name, 'John');
});
```
## Migration from Riverpod 2.x
### StateNotifierProvider → NotifierProvider
```dart
// Old (2.x)
class Counter extends StateNotifier<int> {
Counter() : super(0);
void increment() => state++;
}
final counterProvider = StateNotifierProvider<Counter, int>(Counter.new);
// New (3.0)
@riverpod
class Counter extends _$Counter {
@override
int build() => 0;
void increment() => state++;
}
```
### FutureProvider.family → Function with Parameters
```dart
// Old (2.x)
final userProvider = FutureProvider.family<User, String>((ref, id) async {
return fetchUser(id);
});
// New (3.0)
@riverpod
Future<User> user(UserRef ref, String id) async {
return fetchUser(id);
}
```
### Ref Types → Single Ref
```dart
// Old (2.x)
final provider = FutureProvider<String>((FutureProviderRef ref) async {
return 'value';
});
// New (3.0)
@riverpod
Future<String> provider(ProviderRef ref) async {
return 'value';
}
```
## Common Patterns
See `provider_examples.dart` for comprehensive examples of:
- Simple providers
- Async providers (FutureProvider pattern)
- Stream providers
- Notifier (mutable state)
- AsyncNotifier (async mutable state)
- StreamNotifier
- Family (parameters)
- Provider composition
- Error handling
- Lifecycle hooks
- Optimization with .select()
## Riverpod Lint Rules
The project is configured with `riverpod_lint` for additional checks:
- `provider_dependencies` - Ensure proper dependency usage
- `scoped_providers_should_specify_dependencies` - Scoped provider safety
- `avoid_public_notifier_properties` - Encapsulation
- `avoid_ref_read_inside_build` - Performance
- `avoid_manual_providers_as_generated_provider_dependency` - Use generated providers
- `functional_ref` - Proper ref usage
- `notifier_build` - Proper Notifier implementation
Run linting:
```bash
dart run custom_lint
```
## Resources
- [Riverpod Documentation](https://riverpod.dev)
- [Code Generation Guide](https://riverpod.dev/docs/concepts/about_code_generation)
- [Migration Guide](https://riverpod.dev/docs/migration/from_state_notifier)
- [Provider Examples](./provider_examples.dart)

View File

@@ -0,0 +1,113 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'connectivity_provider.g.dart';
/// Enum representing connectivity status
enum ConnectivityStatus {
/// Connected to WiFi
wifi,
/// Connected to mobile data
mobile,
/// No internet connection
offline,
}
/// Provider for the Connectivity instance
/// This is a simple provider that returns a singleton instance
@riverpod
Connectivity connectivity(Ref ref) {
return Connectivity();
}
/// Stream provider that monitors real-time connectivity changes
/// This automatically updates whenever the device connectivity changes
///
/// Usage:
/// ```dart
/// final connectivityState = ref.watch(connectivityStreamProvider);
/// connectivityState.when(
/// data: (status) => Text('Status: $status'),
/// loading: () => CircularProgressIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
@riverpod
Stream<ConnectivityStatus> connectivityStream(Ref ref) {
final connectivity = ref.watch(connectivityProvider);
return connectivity.onConnectivityChanged.map((result) {
// Handle the List<ConnectivityResult> from connectivity_plus
if (result.contains(ConnectivityResult.wifi)) {
return ConnectivityStatus.wifi;
} else if (result.contains(ConnectivityResult.mobile)) {
return ConnectivityStatus.mobile;
} else {
return ConnectivityStatus.offline;
}
});
}
/// Provider that checks current connectivity status once
/// This is useful for one-time checks without listening to changes
///
/// Usage:
/// ```dart
/// final status = await ref.read(currentConnectivityProvider.future);
/// if (status == ConnectivityStatus.offline) {
/// showOfflineDialog();
/// }
/// ```
@riverpod
Future<ConnectivityStatus> currentConnectivity(Ref ref) async {
final connectivity = ref.watch(connectivityProvider);
final result = await connectivity.checkConnectivity();
// Handle the List<ConnectivityResult>
if (result.contains(ConnectivityResult.wifi)) {
return ConnectivityStatus.wifi;
} else if (result.contains(ConnectivityResult.mobile)) {
return ConnectivityStatus.mobile;
} else {
return ConnectivityStatus.offline;
}
}
/// Provider that returns whether the device is currently online
/// Convenient boolean check for connectivity
///
/// Usage:
/// ```dart
/// final isOnlineAsync = ref.watch(isOnlineProvider);
/// isOnlineAsync.when(
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
/// loading: () => CircularProgressIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
@riverpod
Stream<bool> isOnline(Ref ref) {
// Get the connectivity stream and map it to a boolean
final connectivity = ref.watch(connectivityProvider);
return connectivity.onConnectivityChanged.map((result) {
// Online if connected to WiFi or mobile
return result.contains(ConnectivityResult.wifi) ||
result.contains(ConnectivityResult.mobile);
});
}
/// Example of using .select() for optimization
/// Only rebuilds when the online status changes, not on WiFi<->Mobile switches
///
/// Usage:
/// ```dart
/// // This only rebuilds when going online/offline
/// final isOnline = ref.watch(
/// connectivityStreamProvider.select((async) =>
/// async.value != ConnectivityStatus.offline
/// ),
/// );
/// ```

View File

@@ -0,0 +1,283 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'connectivity_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for the Connectivity instance
/// This is a simple provider that returns a singleton instance
@ProviderFor(connectivity)
const connectivityProvider = ConnectivityProvider._();
/// Provider for the Connectivity instance
/// This is a simple provider that returns a singleton instance
final class ConnectivityProvider
extends $FunctionalProvider<Connectivity, Connectivity, Connectivity>
with $Provider<Connectivity> {
/// Provider for the Connectivity instance
/// This is a simple provider that returns a singleton instance
const ConnectivityProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'connectivityProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$connectivityHash();
@$internal
@override
$ProviderElement<Connectivity> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Connectivity create(Ref ref) {
return connectivity(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Connectivity value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Connectivity>(value),
);
}
}
String _$connectivityHash() => r'6d67af0ea4110f6ee0246dd332f90f8901380eda';
/// Stream provider that monitors real-time connectivity changes
/// This automatically updates whenever the device connectivity changes
///
/// Usage:
/// ```dart
/// final connectivityState = ref.watch(connectivityStreamProvider);
/// connectivityState.when(
/// data: (status) => Text('Status: $status'),
/// loading: () => CircularProgressIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
@ProviderFor(connectivityStream)
const connectivityStreamProvider = ConnectivityStreamProvider._();
/// Stream provider that monitors real-time connectivity changes
/// This automatically updates whenever the device connectivity changes
///
/// Usage:
/// ```dart
/// final connectivityState = ref.watch(connectivityStreamProvider);
/// connectivityState.when(
/// data: (status) => Text('Status: $status'),
/// loading: () => CircularProgressIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
final class ConnectivityStreamProvider
extends
$FunctionalProvider<
AsyncValue<ConnectivityStatus>,
ConnectivityStatus,
Stream<ConnectivityStatus>
>
with
$FutureModifier<ConnectivityStatus>,
$StreamProvider<ConnectivityStatus> {
/// Stream provider that monitors real-time connectivity changes
/// This automatically updates whenever the device connectivity changes
///
/// Usage:
/// ```dart
/// final connectivityState = ref.watch(connectivityStreamProvider);
/// connectivityState.when(
/// data: (status) => Text('Status: $status'),
/// loading: () => CircularProgressIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
const ConnectivityStreamProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'connectivityStreamProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$connectivityStreamHash();
@$internal
@override
$StreamProviderElement<ConnectivityStatus> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<ConnectivityStatus> create(Ref ref) {
return connectivityStream(ref);
}
}
String _$connectivityStreamHash() =>
r'207d7c426c0182225f4d1fd2014b9bc6c667fd67';
/// Provider that checks current connectivity status once
/// This is useful for one-time checks without listening to changes
///
/// Usage:
/// ```dart
/// final status = await ref.read(currentConnectivityProvider.future);
/// if (status == ConnectivityStatus.offline) {
/// showOfflineDialog();
/// }
/// ```
@ProviderFor(currentConnectivity)
const currentConnectivityProvider = CurrentConnectivityProvider._();
/// Provider that checks current connectivity status once
/// This is useful for one-time checks without listening to changes
///
/// Usage:
/// ```dart
/// final status = await ref.read(currentConnectivityProvider.future);
/// if (status == ConnectivityStatus.offline) {
/// showOfflineDialog();
/// }
/// ```
final class CurrentConnectivityProvider
extends
$FunctionalProvider<
AsyncValue<ConnectivityStatus>,
ConnectivityStatus,
FutureOr<ConnectivityStatus>
>
with
$FutureModifier<ConnectivityStatus>,
$FutureProvider<ConnectivityStatus> {
/// Provider that checks current connectivity status once
/// This is useful for one-time checks without listening to changes
///
/// Usage:
/// ```dart
/// final status = await ref.read(currentConnectivityProvider.future);
/// if (status == ConnectivityStatus.offline) {
/// showOfflineDialog();
/// }
/// ```
const CurrentConnectivityProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentConnectivityProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentConnectivityHash();
@$internal
@override
$FutureProviderElement<ConnectivityStatus> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<ConnectivityStatus> create(Ref ref) {
return currentConnectivity(ref);
}
}
String _$currentConnectivityHash() =>
r'bf11d5eef553f9476a8b667e68572268bc25c9fb';
/// Provider that returns whether the device is currently online
/// Convenient boolean check for connectivity
///
/// Usage:
/// ```dart
/// final isOnlineAsync = ref.watch(isOnlineProvider);
/// isOnlineAsync.when(
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
/// loading: () => CircularProgressIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
@ProviderFor(isOnline)
const isOnlineProvider = IsOnlineProvider._();
/// Provider that returns whether the device is currently online
/// Convenient boolean check for connectivity
///
/// Usage:
/// ```dart
/// final isOnlineAsync = ref.watch(isOnlineProvider);
/// isOnlineAsync.when(
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
/// loading: () => CircularProgressIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
final class IsOnlineProvider
extends $FunctionalProvider<AsyncValue<bool>, bool, Stream<bool>>
with $FutureModifier<bool>, $StreamProvider<bool> {
/// Provider that returns whether the device is currently online
/// Convenient boolean check for connectivity
///
/// Usage:
/// ```dart
/// final isOnlineAsync = ref.watch(isOnlineProvider);
/// isOnlineAsync.when(
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
/// loading: () => CircularProgressIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
const IsOnlineProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'isOnlineProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$isOnlineHash();
@$internal
@override
$StreamProviderElement<bool> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<bool> create(Ref ref) {
return isOnline(ref);
}
}
String _$isOnlineHash() => r'09f68fd322b995ffdc28fab6249d8b80108512c4';

View File

@@ -0,0 +1,474 @@
// ignore_for_file: unreachable_from_main
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'provider_examples.g.dart';
/// ============================================================================
/// RIVERPOD 3.0 PROVIDER EXAMPLES WITH CODE GENERATION
/// ============================================================================
/// This file contains comprehensive examples of Riverpod 3.0 patterns
/// using the @riverpod annotation and code generation.
///
/// Key Changes in Riverpod 3.0:
/// - Unified Ref type (no more FutureProviderRef, StreamProviderRef, etc.)
/// - Simplified Notifier classes (no more separate AutoDisposeNotifier)
/// - Automatic retry for failed providers
/// - ref.mounted check after async operations
/// - Improved family parameters (just function parameters!)
/// ============================================================================
// ============================================================================
// 1. SIMPLE IMMUTABLE VALUE PROVIDER
// ============================================================================
/// Simple provider that returns an immutable value
/// Use this for constants, configurations, or computed values
@riverpod
String appVersion(Ref ref) {
return '1.0.0';
}
/// Provider with computation
@riverpod
int pointsMultiplier(Ref ref) {
// Can read other providers
final userTier = 'diamond'; // This would come from another provider
return userTier == 'diamond' ? 3 : userTier == 'platinum' ? 2 : 1;
}
// ============================================================================
// 2. ASYNC DATA FETCHING (FutureProvider pattern)
// ============================================================================
/// Async provider for fetching data once
/// Automatically handles loading and error states via AsyncValue
@riverpod
Future<String> userData(Ref ref) async {
// Simulate API call
await Future.delayed(const Duration(seconds: 1));
return 'User Data';
}
/// Async provider with parameters (Family pattern)
/// Parameters are just function parameters - much simpler than before!
@riverpod
Future<String> userProfile(Ref ref, String userId) async {
// Simulate API call with userId
await Future.delayed(const Duration(seconds: 1));
return 'Profile for user: $userId';
}
/// Async provider with multiple parameters
/// Named parameters, optional parameters, defaults - all supported!
@riverpod
Future<List<String>> productList(
Ref ref, {
required String category,
int page = 1,
int limit = 20,
String? searchQuery,
}) async {
// Simulate API call with parameters
await Future.delayed(const Duration(milliseconds: 500));
return ['Product 1', 'Product 2', 'Product 3'];
}
// ============================================================================
// 3. STREAM PROVIDER
// ============================================================================
/// Stream provider for real-time data
/// Use this for WebSocket connections, real-time updates, etc.
@riverpod
Stream<int> timer(Ref ref) {
return Stream.periodic(
const Duration(seconds: 1),
(count) => count,
);
}
/// Stream provider with parameters
@riverpod
Stream<String> chatMessages(Ref ref, String roomId) {
// Simulate WebSocket stream
return Stream.periodic(
const Duration(seconds: 2),
(count) => 'Message $count in room $roomId',
);
}
// ============================================================================
// 4. NOTIFIER - MUTABLE STATE WITH METHODS
// ============================================================================
/// Notifier for mutable state with methods
/// Use this when you need to expose methods to modify state
///
/// The @riverpod annotation generates:
/// - counterProvider: Access the state
/// - counterProvider.notifier: Access the notifier methods
@riverpod
class Counter extends _$Counter {
/// Build method returns the initial state
@override
int build() {
return 0;
}
/// Methods to modify state
void increment() {
state++;
}
void decrement() {
state--;
}
void reset() {
state = 0;
}
void add(int value) {
state += value;
}
}
/// Notifier with parameters (Family pattern)
@riverpod
class CartQuantity extends _$CartQuantity {
/// Parameters become properties you can access
@override
int build(String productId) {
// Initialize with 0 or load from local storage
return 0;
}
void increment() {
state++;
}
void decrement() {
if (state > 0) {
state--;
}
}
void setQuantity(int quantity) {
state = quantity.clamp(0, 99);
}
}
// ============================================================================
// 5. ASYNC NOTIFIER - MUTABLE STATE WITH ASYNC INITIALIZATION
// ============================================================================
/// AsyncNotifier for state that requires async initialization
/// Perfect for fetching data that can then be modified
///
/// State type: AsyncValue<UserProfile>
/// - AsyncValue.data(profile) when loaded
/// - AsyncValue.loading() when loading
/// - AsyncValue.error(error, stack) when error
@riverpod
class UserProfileNotifier extends _$UserProfileNotifier {
/// Build method returns Future of the initial state
@override
Future<UserProfileData> build() async {
// Fetch initial data
await Future.delayed(const Duration(seconds: 1));
return UserProfileData(name: 'John Doe', email: 'john@example.com');
}
/// Method to update profile
/// Uses AsyncValue.guard() for proper error handling
Future<void> updateName(String name) async {
// Set loading state
state = const AsyncValue.loading();
// Update state with error handling
state = await AsyncValue.guard(() async {
await Future.delayed(const Duration(milliseconds: 500));
final currentProfile = await future; // Get current data
return currentProfile.copyWith(name: name);
});
}
/// Refresh data
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await Future.delayed(const Duration(seconds: 1));
return UserProfileData(name: 'Refreshed', email: 'refresh@example.com');
});
}
/// Method with ref.mounted check (Riverpod 3.0 feature)
Future<void> updateWithMountedCheck(String name) async {
await Future.delayed(const Duration(seconds: 2));
// Check if provider is still mounted after async operation
if (!ref.mounted) return;
state = AsyncValue.data(
UserProfileData(name: name, email: 'email@example.com'),
);
}
}
// Simple data class for example
class UserProfileData {
UserProfileData({required this.name, required this.email});
final String name;
final String email;
UserProfileData copyWith({String? name, String? email}) {
return UserProfileData(
name: name ?? this.name,
email: email ?? this.email,
);
}
}
// ============================================================================
// 6. STREAM NOTIFIER - MUTABLE STATE FROM STREAM
// ============================================================================
/// StreamNotifier for state that comes from a stream but can be modified
/// Use for WebSocket connections with additional actions
@riverpod
class LiveChatNotifier extends _$LiveChatNotifier {
@override
Stream<List<String>> build() {
// Return the stream
return Stream.periodic(
const Duration(seconds: 1),
(count) => ['Message $count'],
);
}
/// Send a new message
Future<void> sendMessage(String message) async {
// Send via WebSocket/API
await Future.delayed(const Duration(milliseconds: 100));
}
}
// ============================================================================
// 7. PROVIDER DEPENDENCIES (COMPOSITION)
// ============================================================================
/// Provider that depends on other providers
@riverpod
Future<String> dashboardData(Ref ref) async {
// Watch other providers
final userData = await ref.watch(userDataProvider.future);
final version = ref.watch(appVersionProvider);
return 'Dashboard for $userData on version $version';
}
/// Provider that selectively watches for optimization
/// Note: userProfileProvider is actually a Family provider (takes userId parameter)
/// In real code, you would use the generated AsyncNotifier provider
@riverpod
String userDisplayName(Ref ref) {
// Example: Watch a simple computed value based on other providers
final version = ref.watch(appVersionProvider);
// In a real app, you would watch the UserProfileNotifier like:
// final asyncProfile = ref.watch(userProfileNotifierProvider);
// return asyncProfile.when(...)
return 'User on version $version';
}
// ============================================================================
// 8. KEEP ALIVE vs AUTO DISPOSE
// ============================================================================
/// By default, generated providers are autoDispose
/// They clean up when no longer used
@riverpod
String autoDisposeExample(Ref ref) {
// This provider will be disposed when no longer watched
ref.onDispose(() {
// Clean up resources
});
return 'Auto disposed';
}
/// Keep alive provider - never auto-disposes
/// Use for global state, singletons, services
@Riverpod(keepAlive: true)
String keepAliveExample(Ref ref) {
// This provider stays alive until the app closes
return 'Kept alive';
}
// ============================================================================
// 9. LIFECYCLE HOOKS
// ============================================================================
/// Provider with lifecycle hooks
@riverpod
String lifecycleExample(Ref ref) {
// Called when provider is first created
ref.onDispose(() {
// Clean up when provider is disposed
print('Provider disposed');
});
ref.onCancel(() {
// Called when last listener is removed (before dispose)
print('Last listener removed');
});
ref.onResume(() {
// Called when a new listener is added after onCancel
print('New listener added');
});
return 'Lifecycle example';
}
// ============================================================================
// 10. INVALIDATION AND REFRESH
// ============================================================================
/// Provider showing how to invalidate and refresh
@riverpod
class DataManager extends _$DataManager {
@override
Future<String> build() async {
await Future.delayed(const Duration(seconds: 1));
return 'Initial data';
}
/// Refresh this provider's data
Future<void> refresh() async {
// Method 1: Use ref.invalidateSelf()
ref.invalidateSelf();
// Method 2: Manually update state
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
await Future.delayed(const Duration(seconds: 1));
return 'Refreshed data';
});
}
/// Invalidate another provider
void invalidateOther() {
ref.invalidate(userDataProvider);
}
}
// ============================================================================
// 11. ERROR HANDLING PATTERNS
// ============================================================================
/// Provider with comprehensive error handling
@riverpod
class ErrorHandlingExample extends _$ErrorHandlingExample {
@override
Future<String> build() async {
return await _fetchData();
}
Future<String> _fetchData() async {
try {
// Simulate API call
await Future.delayed(const Duration(seconds: 1));
return 'Success';
} catch (e) {
// Errors are automatically caught by AsyncValue
rethrow;
}
}
/// Update with error handling
Future<void> updateData(String newData) async {
state = const AsyncValue.loading();
// AsyncValue.guard automatically catches errors
state = await AsyncValue.guard(() async {
await Future.delayed(const Duration(milliseconds: 500));
return newData;
});
// Handle the result
state.when(
data: (data) {
// Success
print('Updated successfully: $data');
},
loading: () {
// Still loading
},
error: (error, stack) {
// Handle error
print('Update failed: $error');
},
);
}
}
// ============================================================================
// USAGE IN WIDGETS
// ============================================================================
/*
// 1. Watching a simple provider
final version = ref.watch(appVersionProvider);
// 2. Watching async provider
final userData = ref.watch(userDataProvider);
userData.when(
data: (data) => Text(data),
loading: () => CircularProgressIndicator(),
error: (error, stack) => Text('Error: $error'),
);
// 3. Watching with family (parameters)
final profile = ref.watch(userProfileProvider('user123'));
// 4. Watching state from Notifier
final count = ref.watch(counterProvider);
// 5. Calling Notifier methods
ref.read(counterProvider.notifier).increment();
// 6. Watching AsyncNotifier state
final profileState = ref.watch(userProfileNotifierProvider);
// 7. Calling AsyncNotifier methods
await ref.read(userProfileNotifierProvider.notifier).updateName('New Name');
// 8. Selective watching for optimization
final userName = ref.watch(
userProfileNotifierProvider.select((async) => async.value?.name),
);
// 9. Invalidating a provider
ref.invalidate(userDataProvider);
// 10. Refreshing a provider
ref.refresh(userDataProvider);
// 11. Pattern matching (Dart 3.0+)
final profileState = ref.watch(userProfileNotifierProvider);
switch (profileState) {
case AsyncData(:final value):
return Text(value.name);
case AsyncError(:final error):
return Text('Error: $error');
case AsyncLoading():
return CircularProgressIndicator();
}
*/

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,460 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/theme/typography.dart';
/// App theme configuration for Material 3 design system
/// Provides both light and dark theme variants
class AppTheme {
// Prevent instantiation
AppTheme._();
// ==================== Light Theme ====================
/// Light theme configuration
static ThemeData lightTheme() {
final ColorScheme colorScheme = ColorScheme.fromSeed(
seedColor: AppColors.primaryBlue,
brightness: Brightness.light,
primary: AppColors.primaryBlue,
secondary: AppColors.lightBlue,
tertiary: AppColors.accentCyan,
error: AppColors.danger,
surface: AppColors.white,
background: AppColors.grey50,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
fontFamily: AppTypography.fontFamily,
// ==================== App Bar Theme ====================
appBarTheme: AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
titleTextStyle: AppTypography.titleLarge.copyWith(
color: AppColors.white,
fontWeight: FontWeight.w600,
),
iconTheme: const IconThemeData(
color: AppColors.white,
size: 24,
),
systemOverlayStyle: SystemUiOverlayStyle.light,
),
// ==================== Card Theme ====================
cardTheme: const CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
clipBehavior: Clip.antiAlias,
color: AppColors.white,
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
// ==================== Elevated Button Theme ====================
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
textStyle: AppTypography.buttonText,
minimumSize: const Size(64, 48),
),
),
// ==================== Text Button Theme ====================
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
textStyle: AppTypography.buttonText,
),
),
// ==================== Outlined Button Theme ====================
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
side: const BorderSide(color: AppColors.primaryBlue, width: 1.5),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
textStyle: AppTypography.buttonText,
minimumSize: const Size(64, 48),
),
),
// ==================== Input Decoration Theme ====================
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.grey100, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.grey100, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 2),
),
labelStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
),
hintStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
),
errorStyle: AppTypography.bodySmall.copyWith(
color: AppColors.danger,
),
),
// ==================== Bottom Navigation Bar Theme ====================
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.white,
selectedItemColor: AppColors.primaryBlue,
unselectedItemColor: AppColors.grey500,
selectedIconTheme: IconThemeData(
size: 28,
color: AppColors.primaryBlue,
),
unselectedIconTheme: IconThemeData(
size: 24,
color: AppColors.grey500,
),
selectedLabelStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
fontFamily: AppTypography.fontFamily,
),
unselectedLabelStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
fontFamily: AppTypography.fontFamily,
),
type: BottomNavigationBarType.fixed,
elevation: 8,
),
// ==================== Floating Action Button Theme ====================
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: AppColors.accentCyan,
foregroundColor: AppColors.white,
elevation: 6,
shape: CircleBorder(),
iconSize: 24,
),
// ==================== Chip Theme ====================
chipTheme: ChipThemeData(
backgroundColor: AppColors.grey50,
selectedColor: AppColors.primaryBlue,
disabledColor: AppColors.grey100,
secondarySelectedColor: AppColors.lightBlue,
labelStyle: AppTypography.labelMedium,
secondaryLabelStyle: AppTypography.labelMedium.copyWith(
color: AppColors.white,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
// ==================== Dialog Theme ====================
dialogTheme: const DialogThemeData(
backgroundColor: AppColors.white,
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
).copyWith(
titleTextStyle: AppTypography.headlineMedium.copyWith(
color: AppColors.grey900,
),
contentTextStyle: AppTypography.bodyLarge.copyWith(
color: AppColors.grey900,
),
),
// ==================== Snackbar Theme ====================
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.grey900,
contentTextStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.white,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
behavior: SnackBarBehavior.floating,
elevation: 4,
),
// ==================== Divider Theme ====================
dividerTheme: const DividerThemeData(
color: AppColors.grey100,
thickness: 1,
space: 1,
),
// ==================== Icon Theme ====================
iconTheme: const IconThemeData(
color: AppColors.grey900,
size: 24,
),
// ==================== List Tile Theme ====================
listTileTheme: ListTileThemeData(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
titleTextStyle: AppTypography.titleMedium.copyWith(
color: AppColors.grey900,
),
subtitleTextStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
),
iconColor: AppColors.grey500,
),
// ==================== Switch Theme ====================
switchTheme: SwitchThemeData(
thumbColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.selected)) {
return AppColors.primaryBlue;
}
return AppColors.grey500;
}),
trackColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.selected)) {
return AppColors.lightBlue;
}
return AppColors.grey100;
}),
),
// ==================== Checkbox Theme ====================
checkboxTheme: CheckboxThemeData(
fillColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.selected)) {
return AppColors.primaryBlue;
}
return AppColors.white;
}),
checkColor: MaterialStateProperty.all(AppColors.white),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
// ==================== Radio Theme ====================
radioTheme: RadioThemeData(
fillColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.selected)) {
return AppColors.primaryBlue;
}
return AppColors.grey500;
}),
),
// ==================== Progress Indicator Theme ====================
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.primaryBlue,
linearTrackColor: AppColors.grey100,
circularTrackColor: AppColors.grey100,
),
// ==================== Badge Theme ====================
badgeTheme: const BadgeThemeData(
backgroundColor: AppColors.danger,
textColor: AppColors.white,
smallSize: 6,
largeSize: 16,
),
// ==================== Tab Bar Theme ====================
tabBarTheme: const TabBarThemeData(
labelColor: AppColors.primaryBlue,
unselectedLabelColor: AppColors.grey500,
indicatorColor: AppColors.primaryBlue,
).copyWith(
labelStyle: AppTypography.labelLarge,
unselectedLabelStyle: AppTypography.labelLarge,
),
);
}
// ==================== Dark Theme ====================
/// Dark theme configuration
static ThemeData darkTheme() {
final ColorScheme colorScheme = ColorScheme.fromSeed(
seedColor: AppColors.primaryBlue,
brightness: Brightness.dark,
primary: AppColors.lightBlue,
secondary: AppColors.accentCyan,
tertiary: AppColors.primaryBlue,
error: AppColors.danger,
surface: const Color(0xFF1E1E1E),
background: const Color(0xFF121212),
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
fontFamily: AppTypography.fontFamily,
// ==================== App Bar Theme ====================
appBarTheme: AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: const Color(0xFF1E1E1E),
foregroundColor: AppColors.white,
titleTextStyle: AppTypography.titleLarge.copyWith(
color: AppColors.white,
fontWeight: FontWeight.w600,
),
iconTheme: const IconThemeData(
color: AppColors.white,
size: 24,
),
systemOverlayStyle: SystemUiOverlayStyle.light,
),
// ==================== Card Theme ====================
cardTheme: const CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
clipBehavior: Clip.antiAlias,
color: Color(0xFF1E1E1E),
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
// ==================== Elevated Button Theme ====================
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightBlue,
foregroundColor: AppColors.white,
elevation: 2,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
textStyle: AppTypography.buttonText,
minimumSize: const Size(64, 48),
),
),
// ==================== Input Decoration Theme ====================
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF2A2A2A),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.lightBlue, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 2),
),
labelStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
),
hintStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
),
errorStyle: AppTypography.bodySmall.copyWith(
color: AppColors.danger,
),
),
// ==================== Bottom Navigation Bar Theme ====================
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: Color(0xFF1E1E1E),
selectedItemColor: AppColors.lightBlue,
unselectedItemColor: AppColors.grey500,
selectedIconTheme: IconThemeData(
size: 28,
color: AppColors.lightBlue,
),
unselectedIconTheme: IconThemeData(
size: 24,
color: AppColors.grey500,
),
selectedLabelStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
fontFamily: AppTypography.fontFamily,
),
unselectedLabelStyle: TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
fontFamily: AppTypography.fontFamily,
),
type: BottomNavigationBarType.fixed,
elevation: 8,
),
// ==================== Floating Action Button Theme ====================
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: AppColors.accentCyan,
foregroundColor: AppColors.white,
elevation: 6,
shape: CircleBorder(),
iconSize: 24,
),
// ==================== Snackbar Theme ====================
snackBarTheme: SnackBarThemeData(
backgroundColor: const Color(0xFF2A2A2A),
contentTextStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.white,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
behavior: SnackBarBehavior.floating,
elevation: 4,
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
/// App color palette following the Worker app design system.
///
/// Primary colors are used for main UI elements, tier colors for membership cards,
/// status colors for feedback, and neutral colors for text and backgrounds.
class AppColors {
// Primary Colors
/// Main brand color - Used for primary buttons, app bar, etc.
static const primaryBlue = Color(0xFF005B9A);
/// Light variant of primary color - Used for highlights and accents
static const lightBlue = Color(0xFF38B6FF);
/// Accent color for special actions - Used for FAB, links, etc.
static const accentCyan = Color(0xFF35C6F4);
// Status Colors
/// Success state - Used for completed actions, positive feedback
static const success = Color(0xFF28a745);
/// Warning state - Used for caution messages, pending states
static const warning = Color(0xFFffc107);
/// Danger/Error state - Used for errors, destructive actions
static const danger = Color(0xFFdc3545);
/// Info state - Used for informational messages
static const info = Color(0xFF17a2b8);
// Neutral Colors
/// Lightest background shade
static const grey50 = Color(0xFFf8f9fa);
/// Light background/border shade
static const grey100 = Color(0xFFe9ecef);
/// Medium grey for secondary text
static const grey500 = Color(0xFF6c757d);
/// Dark grey for primary text
static const grey900 = Color(0xFF343a40);
/// Pure white
static const white = Color(0xFFFFFFFF);
// Tier Gradients for Membership Cards
/// Diamond tier gradient (purple-blue)
static const diamondGradient = LinearGradient(
colors: [Color(0xFF4A00E0), Color(0xFF8E2DE2)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
/// Platinum tier gradient (grey-silver)
static const platinumGradient = LinearGradient(
colors: [Color(0xFF7F8C8D), Color(0xFFBDC3C7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
/// Gold tier gradient (yellow-orange)
static const goldGradient = LinearGradient(
colors: [Color(0xFFf7b733), Color(0xFFfc4a1a)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}

View File

@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
/// App typography system following Material 3 type scale
/// Uses Roboto as the primary font family
class AppTypography {
// Prevent instantiation
AppTypography._();
/// Font family used throughout the app
static const String fontFamily = 'Roboto';
// ==================== Display Styles ====================
/// Display Large - 32sp, Bold
/// Used for: Large hero text, splash screens
static const TextStyle displayLarge = TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
fontFamily: fontFamily,
letterSpacing: 0,
height: 1.2,
);
/// Display Medium - 28sp, Semi-bold
/// Used for: Page titles, section headers
static const TextStyle displayMedium = TextStyle(
fontSize: 28,
fontWeight: FontWeight.w600,
fontFamily: fontFamily,
letterSpacing: 0,
height: 1.2,
);
/// Display Small - 24sp, Semi-bold
/// Used for: Sub-section headers
static const TextStyle displaySmall = TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
fontFamily: fontFamily,
letterSpacing: 0,
height: 1.3,
);
// ==================== Headline Styles ====================
/// Headline Large - 24sp, Semi-bold
/// Used for: Main headings, dialog titles
static const TextStyle headlineLarge = TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
fontFamily: fontFamily,
letterSpacing: 0,
height: 1.3,
);
/// Headline Medium - 20sp, Semi-bold
/// Used for: Card titles, list headers
static const TextStyle headlineMedium = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
fontFamily: fontFamily,
letterSpacing: 0.15,
height: 1.3,
);
/// Headline Small - 18sp, Medium
/// Used for: Small headers, emphasized text
static const TextStyle headlineSmall = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
fontFamily: fontFamily,
letterSpacing: 0.15,
height: 1.4,
);
// ==================== Title Styles ====================
/// Title Large - 20sp, Medium
/// Used for: App bar titles, prominent labels
static const TextStyle titleLarge = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w500,
fontFamily: fontFamily,
letterSpacing: 0.15,
height: 1.4,
);
/// Title Medium - 16sp, Medium
/// Used for: List item titles, card headers
static const TextStyle titleMedium = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
fontFamily: fontFamily,
letterSpacing: 0.15,
height: 1.5,
);
/// Title Small - 14sp, Medium
/// Used for: Small titles, tab labels
static const TextStyle titleSmall = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
fontFamily: fontFamily,
letterSpacing: 0.1,
height: 1.5,
);
// ==================== Body Styles ====================
/// Body Large - 16sp, Regular
/// Used for: Main body text, descriptions
static const TextStyle bodyLarge = TextStyle(
fontSize: 16,
fontWeight: FontWeight.normal,
fontFamily: fontFamily,
letterSpacing: 0.5,
height: 1.5,
);
/// Body Medium - 14sp, Regular
/// Used for: Secondary body text, captions
static const TextStyle bodyMedium = TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
fontFamily: fontFamily,
letterSpacing: 0.25,
height: 1.5,
);
/// Body Small - 12sp, Regular
/// Used for: Small body text, helper text
static const TextStyle bodySmall = TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
fontFamily: fontFamily,
letterSpacing: 0.4,
height: 1.5,
);
// ==================== Label Styles ====================
/// Label Large - 14sp, Medium
/// Used for: Button text, input labels
static const TextStyle labelLarge = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
fontFamily: fontFamily,
letterSpacing: 0.1,
height: 1.4,
);
/// Label Medium - 12sp, Medium
/// Used for: Small button text, chips
static const TextStyle labelMedium = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
fontFamily: fontFamily,
letterSpacing: 0.5,
height: 1.4,
);
/// Label Small - 12sp, Regular
/// Used for: Overline text, tags, badges
static const TextStyle labelSmall = TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
fontFamily: fontFamily,
letterSpacing: 0.5,
height: 1.4,
);
// ==================== Special Purpose Styles ====================
/// Points Display - 28sp, Bold
/// Used for: Loyalty points display on member cards
static const TextStyle pointsDisplay = TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
fontFamily: fontFamily,
letterSpacing: 0,
height: 1.2,
);
/// Price Large - 20sp, Bold
/// Used for: Product prices, totals
static const TextStyle priceLarge = TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
fontFamily: fontFamily,
letterSpacing: 0,
height: 1.3,
);
/// Price Medium - 16sp, Semi-bold
/// Used for: List item prices
static const TextStyle priceMedium = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: fontFamily,
letterSpacing: 0,
height: 1.3,
);
/// Price Small - 14sp, Semi-bold
/// Used for: Small price displays
static const TextStyle priceSmall = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
fontFamily: fontFamily,
letterSpacing: 0,
height: 1.3,
);
/// Button Text - 14sp, Medium
/// Used for: All button labels
static const TextStyle buttonText = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
fontFamily: fontFamily,
letterSpacing: 1.25,
height: 1.0,
);
/// Overline - 10sp, Medium, Uppercase
/// Used for: Section labels, categories
static const TextStyle overline = TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
fontFamily: fontFamily,
letterSpacing: 1.5,
height: 1.6,
);
/// Caption - 12sp, Regular
/// Used for: Image captions, timestamps
static const TextStyle caption = TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
fontFamily: fontFamily,
letterSpacing: 0.4,
height: 1.33,
);
}

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

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
/// Custom bottom navigation bar for the Worker app.
///
/// This widget will be fully implemented once navigation system is in place.
/// It will support 5 main tabs: Home, Products, Loyalty, Account, and More.
///
/// Example usage:
/// ```dart
/// CustomBottomNavBar(
/// currentIndex: _currentIndex,
/// onTap: (index) => _onNavigate(index),
/// )
/// ```
class CustomBottomNavBar extends StatelessWidget {
/// Current selected tab index
final int currentIndex;
/// Callback when a tab is tapped
final ValueChanged<int> onTap;
/// Optional badge count for notifications
final int? badgeCount;
const CustomBottomNavBar({
super.key,
required this.currentIndex,
required this.onTap,
this.badgeCount,
});
@override
Widget build(BuildContext context) {
// Will be implemented with navigation
// TODO: Implement full bottom navigation with:
// - Home tab (home icon)
// - Products tab (shopping_bag icon)
// - Loyalty tab (card_membership icon)
// - Account tab (person icon)
// - More tab (menu icon) with notification badge
//
// Design specs:
// - Height: 72px
// - Icon size: 24px (selected: 28px)
// - Label font size: 12px
// - Selected color: primaryBlue
// - Unselected color: grey500
// - Badge: red circle with white text
return BottomNavigationBar(
currentIndex: currentIndex,
onTap: onTap,
type: BottomNavigationBarType.fixed,
selectedItemColor: AppColors.primaryBlue,
unselectedItemColor: AppColors.grey500,
selectedFontSize: 12,
unselectedFontSize: 12,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.shopping_bag),
label: 'Products',
),
BottomNavigationBarItem(
icon: Icon(Icons.card_membership),
label: 'Loyalty',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Account',
),
BottomNavigationBarItem(
icon: Icon(Icons.menu),
label: 'More',
),
],
);
}
}

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
/// Button variant types for different use cases.
enum ButtonVariant {
/// Primary button with filled background color
primary,
/// Secondary button with outlined border
secondary,
}
/// Custom button widget following the Worker app design system.
///
/// Supports primary and secondary variants, loading states, and disabled states.
///
/// Example usage:
/// ```dart
/// CustomButton(
/// text: 'Login',
/// onPressed: () => _handleLogin(),
/// variant: ButtonVariant.primary,
/// isLoading: _isLoading,
/// )
/// ```
class CustomButton extends StatelessWidget {
/// The text to display on the button
final String text;
/// Callback when button is pressed. If null, button is disabled.
final VoidCallback? onPressed;
/// Visual variant of the button (primary or secondary)
final ButtonVariant variant;
/// Whether to show loading indicator instead of text
final bool isLoading;
/// Optional icon to display before the text
final IconData? icon;
/// Custom width for the button. If null, uses parent constraints.
final double? width;
/// Custom height for the button. Defaults to 48.
final double? height;
const CustomButton({
super.key,
required this.text,
required this.onPressed,
this.variant = ButtonVariant.primary,
this.isLoading = false,
this.icon,
this.width,
this.height,
});
@override
Widget build(BuildContext context) {
final isDisabled = onPressed == null || isLoading;
if (variant == ButtonVariant.primary) {
return SizedBox(
width: width,
height: height ?? 48,
child: ElevatedButton(
onPressed: isDisabled ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.grey500,
disabledForegroundColor: Colors.white70,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: _buildContent(),
),
);
} else {
return SizedBox(
width: width,
height: height ?? 48,
child: OutlinedButton(
onPressed: isDisabled ? null : onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
disabledForegroundColor: AppColors.grey500,
side: BorderSide(
color: isDisabled ? AppColors.grey500 : AppColors.primaryBlue,
width: 1.5,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: _buildContent(),
),
);
}
}
/// Builds the button content (text, icon, or loading indicator)
Widget _buildContent() {
if (isLoading) {
return const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
);
}
if (icon != null) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20),
const SizedBox(width: 8),
Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
);
}
return Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
);
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
/// Empty state widget for displaying when lists or collections are empty.
///
/// Shows an icon, title, subtitle, and optional action button to guide users
/// when there's no content to display.
///
/// Example usage:
/// ```dart
/// EmptyState(
/// icon: Icons.shopping_cart_outlined,
/// title: 'Your cart is empty',
/// subtitle: 'Add some products to get started',
/// actionLabel: 'Browse Products',
/// onAction: () => Navigator.pushNamed(context, '/products'),
/// )
/// ```
class EmptyState extends StatelessWidget {
/// Icon to display at the top
final IconData icon;
/// Main title text
final String title;
/// Optional subtitle/description text
final String? subtitle;
/// Optional action button label. If null, no button is shown.
final String? actionLabel;
/// Optional callback for action button
final VoidCallback? onAction;
/// Size of the icon. Defaults to 80.
final double iconSize;
const EmptyState({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.actionLabel,
this.onAction,
this.iconSize = 80,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: iconSize,
color: AppColors.grey500,
),
const SizedBox(height: 16),
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
textAlign: TextAlign.center,
),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 24),
ElevatedButton(
onPressed: onAction,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Text(
actionLabel!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
/// Custom error widget for displaying error states with retry functionality.
///
/// Shows an error icon, message, and optional retry button. Used throughout
/// the app for error states in async operations.
///
/// Example usage:
/// ```dart
/// CustomErrorWidget(
/// message: 'Failed to load products',
/// onRetry: () => _loadProducts(),
/// )
/// ```
class CustomErrorWidget extends StatelessWidget {
/// Error message to display
final String message;
/// Optional callback for retry button. If null, no button is shown.
final VoidCallback? onRetry;
/// Optional icon to display. Defaults to error_outline.
final IconData? icon;
/// Size of the error icon. Defaults to 64.
final double iconSize;
const CustomErrorWidget({
super.key,
required this.message,
this.onRetry,
this.icon,
this.iconSize = 64,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon ?? Icons.error_outline,
size: iconSize,
color: AppColors.danger,
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 16,
color: AppColors.grey900,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
if (onRetry != null) ...[
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
/// Floating action button for chat support access.
///
/// Positioned at bottom-right of the screen with accent cyan color.
/// Opens chat support when tapped.
///
/// Example usage:
/// ```dart
/// Scaffold(
/// floatingActionButton: ChatFloatingButton(
/// onPressed: () => Navigator.pushNamed(context, '/chat'),
/// ),
/// )
/// ```
class ChatFloatingButton extends StatelessWidget {
/// Callback when the button is pressed
final VoidCallback onPressed;
/// Optional badge count for unread messages
final int? unreadCount;
/// Size of the FAB. Defaults to 56.
final double size;
const ChatFloatingButton({
super.key,
required this.onPressed,
this.unreadCount,
this.size = 56,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: Stack(
children: [
FloatingActionButton(
onPressed: onPressed,
backgroundColor: AppColors.accentCyan,
elevation: 6,
child: const Icon(
Icons.chat_bubble_outline,
color: Colors.white,
size: 24,
),
),
if (unreadCount != null && unreadCount! > 0)
Positioned(
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.danger,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 2,
),
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
child: Center(
child: Text(
unreadCount! > 99 ? '99+' : unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
/// Custom loading indicator widget with optional message text.
///
/// Displays a centered circular progress indicator with an optional
/// message below it. Used for loading states throughout the app.
///
/// Example usage:
/// ```dart
/// CustomLoadingIndicator(
/// message: 'Loading products...',
/// )
/// ```
class CustomLoadingIndicator extends StatelessWidget {
/// Optional message to display below the loading indicator
final String? message;
/// Size of the loading indicator. Defaults to 40.
final double size;
/// Color of the loading indicator. Defaults to primaryBlue.
final Color? color;
const CustomLoadingIndicator({
super.key,
this.message,
this.size = 40,
this.color,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
color ?? AppColors.primaryBlue,
),
),
),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message!,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
textAlign: TextAlign.center,
),
],
],
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

39
lib/hive_registrar.g.dart Normal file
View File

@@ -0,0 +1,39 @@
// Generated by Hive CE
// Do not modify
// Check in to version control
import 'package:hive_ce/hive.dart';
import 'package:worker/core/database/models/cached_data.dart';
import 'package:worker/core/database/models/enums.dart';
extension HiveRegistrar on HiveInterface {
void registerAdapters() {
registerAdapter(CachedDataAdapter());
registerAdapter(GiftStatusAdapter());
registerAdapter(MemberTierAdapter());
registerAdapter(NotificationTypeAdapter());
registerAdapter(OrderStatusAdapter());
registerAdapter(PaymentMethodAdapter());
registerAdapter(PaymentStatusAdapter());
registerAdapter(ProjectStatusAdapter());
registerAdapter(ProjectTypeAdapter());
registerAdapter(TransactionTypeAdapter());
registerAdapter(UserTypeAdapter());
}
}
extension IsolatedHiveRegistrar on IsolatedHiveInterface {
void registerAdapters() {
registerAdapter(CachedDataAdapter());
registerAdapter(GiftStatusAdapter());
registerAdapter(MemberTierAdapter());
registerAdapter(NotificationTypeAdapter());
registerAdapter(OrderStatusAdapter());
registerAdapter(PaymentMethodAdapter());
registerAdapter(PaymentStatusAdapter());
registerAdapter(ProjectStatusAdapter());
registerAdapter(ProjectTypeAdapter());
registerAdapter(TransactionTypeAdapter());
registerAdapter(UserTypeAdapter());
}
}

914
lib/l10n/app_en.arb Normal file
View File

@@ -0,0 +1,914 @@
{
"@@locale": "en",
"appTitle": "Worker App",
"@appTitle": {
"description": "The application title"
},
"home": "Home",
"@home": {
"description": "Home navigation item"
},
"products": "Products",
"@products": {
"description": "Products navigation item"
},
"loyalty": "Loyalty",
"@loyalty": {
"description": "Loyalty navigation item"
},
"account": "Account",
"@account": {
"description": "Account navigation item"
},
"more": "More",
"@more": {
"description": "More navigation item"
},
"login": "Login",
"phone": "Phone Number",
"enterPhone": "Enter phone number",
"enterPhoneHint": "Ex: 0912345678",
"continueButton": "Continue",
"verifyOTP": "Verify OTP",
"enterOTP": "Enter 6-digit OTP code",
"otpSentTo": "OTP code has been sent to {phone}",
"@otpSentTo": {
"description": "OTP sent message",
"placeholders": {
"phone": {
"type": "String",
"example": "0912345678"
}
}
},
"resendOTP": "Resend code",
"resendOTPIn": "Resend in {seconds}s",
"@resendOTPIn": {
"description": "Resend OTP countdown",
"placeholders": {
"seconds": {
"type": "int",
"example": "60"
}
}
},
"register": "Register",
"registerNewAccount": "Register new account",
"logout": "Logout",
"logoutConfirm": "Are you sure you want to logout?",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"search": "Search",
"filter": "Filter",
"sort": "Sort",
"confirm": "Confirm",
"close": "Close",
"back": "Back",
"next": "Next",
"submit": "Submit",
"apply": "Apply",
"clear": "Clear",
"clearAll": "Clear All",
"viewDetails": "View Details",
"viewAll": "View All",
"refresh": "Refresh",
"share": "Share",
"copy": "Copy",
"copied": "Copied",
"yes": "Yes",
"no": "No",
"pending": "Pending",
"processing": "Processing",
"shipping": "Shipping",
"completed": "Completed",
"cancelled": "Cancelled",
"active": "Active",
"inactive": "Inactive",
"expired": "Expired",
"draft": "Draft",
"sent": "Sent",
"accepted": "Accepted",
"rejected": "Rejected",
"name": "Name",
"fullName": "Full Name",
"email": "Email",
"password": "Password",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"address": "Address",
"street": "Street",
"city": "City",
"district": "District",
"ward": "Ward",
"postalCode": "Postal Code",
"company": "Company",
"taxId": "Tax ID",
"dateOfBirth": "Date of Birth",
"gender": "Gender",
"male": "Male",
"female": "Female",
"other": "Other",
"contractor": "Contractor",
"architect": "Architect",
"distributor": "Distributor",
"broker": "Broker",
"selectUserType": "Select user type",
"points": "Points",
"currentPoints": "Current Points",
"pointsBalance": "{points} points",
"@pointsBalance": {
"description": "Points balance display",
"placeholders": {
"points": {
"type": "int",
"example": "1000"
}
}
},
"earnedPoints": "+{points} points",
"@earnedPoints": {
"description": "Points earned",
"placeholders": {
"points": {
"type": "int",
"example": "100"
}
}
},
"spentPoints": "-{points} points",
"@spentPoints": {
"description": "Points spent",
"placeholders": {
"points": {
"type": "int",
"example": "50"
}
}
},
"memberTier": "Member Tier",
"diamond": "Diamond",
"platinum": "Platinum",
"gold": "Gold",
"pointsToNextTier": "{points} points to reach {tier}",
"@pointsToNextTier": {
"description": "Points needed for next tier",
"placeholders": {
"points": {
"type": "int",
"example": "500"
},
"tier": {
"type": "String",
"example": "Platinum"
}
}
},
"rewards": "Rewards",
"redeemReward": "Redeem Reward",
"pointsHistory": "Points History",
"myGifts": "My Gifts",
"referral": "Refer Friends",
"referralCode": "Referral Code",
"referralLink": "Referral Link",
"totalReferrals": "Total Referrals",
"shareReferralCode": "Share Referral Code",
"copyReferralCode": "Copy Code",
"copyReferralLink": "Copy Link",
"product": "Product",
"productName": "Product Name",
"productCode": "Product Code",
"price": "Price",
"salePrice": "Sale Price",
"quantity": "Quantity",
"stock": "Stock",
"inStock": "In Stock",
"outOfStock": "Out of Stock",
"category": "Category",
"allCategories": "All Categories",
"addToCart": "Add to Cart",
"cart": "Cart",
"cartEmpty": "Cart is empty",
"cartItemsCount": "{count} items",
"@cartItemsCount": {
"description": "Number of items in cart",
"placeholders": {
"count": {
"type": "int",
"example": "3"
}
}
},
"removeFromCart": "Remove from Cart",
"clearCart": "Clear Cart",
"clearCartConfirm": "Are you sure you want to clear all items from the cart?",
"checkout": "Checkout",
"subtotal": "Subtotal",
"discount": "Discount",
"shipping": "Shipping",
"total": "Total",
"placeOrder": "Place Order",
"orderPlaced": "Order Placed",
"orderSuccess": "Order Successful",
"orders": "Orders",
"myOrders": "My Orders",
"orderNumber": "Order Number",
"orderDate": "Order Date",
"orderStatus": "Order Status",
"orderDetails": "Order Details",
"trackOrder": "Track Order",
"reorder": "Reorder",
"paymentMethod": "Payment Method",
"cashOnDelivery": "Cash on Delivery",
"bankTransfer": "Bank Transfer",
"creditCard": "Credit Card",
"eWallet": "E-Wallet",
"deliveryAddress": "Delivery Address",
"estimatedDelivery": "Estimated Delivery",
"payments": "Payments",
"paymentId": "Payment ID",
"paymentStatus": "Payment Status",
"projects": "Projects",
"myProjects": "My Projects",
"createProject": "Create Project",
"projectName": "Project Name",
"projectCode": "Project Code",
"projectType": "Project Type",
"residential": "Residential",
"commercial": "Commercial",
"industrial": "Industrial",
"client": "Client",
"clientName": "Client Name",
"clientPhone": "Client Phone",
"location": "Location",
"startDate": "Start Date",
"endDate": "End Date",
"progress": "Progress",
"budget": "Budget",
"description": "Description",
"notes": "Notes",
"quotes": "Quotes",
"createQuote": "Create Quote",
"quoteNumber": "Quote Number",
"quoteDate": "Quote Date",
"validity": "Validity",
"convertToOrder": "Convert to Order",
"duplicate": "Duplicate",
"profile": "Profile",
"editProfile": "Edit Profile",
"avatar": "Avatar",
"uploadAvatar": "Upload Avatar",
"changePassword": "Change Password",
"passwordChanged": "Password changed successfully",
"addresses": "Addresses",
"myAddresses": "My Addresses",
"addAddress": "Add Address",
"editAddress": "Edit Address",
"deleteAddress": "Delete Address",
"deleteAddressConfirm": "Are you sure you want to delete this address?",
"setAsDefault": "Set as Default",
"defaultAddress": "Default Address",
"homeAddress": "Home",
"officeAddress": "Office",
"settings": "Settings",
"notifications": "Notifications",
"notificationSettings": "Notification Settings",
"language": "Language",
"theme": "Theme",
"lightMode": "Light",
"darkMode": "Dark",
"systemMode": "System",
"promotions": "Promotions",
"promotion": "Promotion",
"activePromotions": "Active Promotions",
"upcomingPromotions": "Upcoming Promotions",
"expiredPromotions": "Expired Promotions",
"claimPromotion": "Claim Promotion",
"termsAndConditions": "Terms & Conditions",
"chat": "Chat",
"chatSupport": "Chat Support",
"sendMessage": "Send Message",
"typeMessage": "Type a message...",
"typingIndicator": "typing...",
"attachFile": "Attach File",
"supportAgent": "Support Agent",
"fieldRequired": "This field is required",
"invalidPhone": "Invalid phone number",
"invalidEmail": "Invalid email",
"invalidOTP": "Invalid OTP code",
"passwordTooShort": "Password must be at least 8 characters",
"passwordsNotMatch": "Passwords do not match",
"passwordRequirements": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
"invalidAmount": "Invalid amount",
"insufficientPoints": "Insufficient points to redeem",
"error": "Error",
"errorOccurred": "An error occurred",
"networkError": "Network error. Please check your internet connection.",
"serverError": "Server error. Please try again later.",
"sessionExpired": "Session expired. Please login again.",
"notFound": "Not found",
"unauthorized": "Unauthorized access",
"tryAgain": "Try Again",
"contactSupport": "Contact Support",
"success": "Success",
"savedSuccessfully": "Saved successfully",
"updatedSuccessfully": "Updated successfully",
"deletedSuccessfully": "Deleted successfully",
"sentSuccessfully": "Sent successfully",
"redeemSuccessful": "Reward redeemed successfully",
"giftCode": "Gift Code",
"loading": "Loading...",
"loadingData": "Loading data...",
"processing": "Processing...",
"pleaseWait": "Please wait...",
"noData": "No data",
"noResults": "No results",
"noProductsFound": "No products found",
"noOrdersYet": "No orders yet",
"noProjectsYet": "No projects yet",
"noNotifications": "No notifications",
"noGiftsYet": "No gifts yet",
"startShopping": "Start Shopping",
"createFirstProject": "Create Your First Project",
"today": "Today",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"thisMonth": "This Month",
"all": "All",
"dateRange": "Date Range",
"from": "From",
"to": "To",
"date": "Date",
"time": "Time",
"version": "Version",
"appVersion": "App Version",
"help": "Help",
"helpCenter": "Help Center",
"aboutUs": "About Us",
"privacyPolicy": "Privacy Policy",
"termsOfService": "Terms of Service",
"rateApp": "Rate App",
"feedback": "Feedback",
"sendFeedback": "Send Feedback",
"unsavedChanges": "Unsaved Changes",
"unsavedChangesMessage": "Do you want to save changes before leaving?",
"welcome": "Welcome",
"welcomeBack": "Welcome Back",
"welcomeTo": "Welcome to {appName}",
"@welcomeTo": {
"description": "Welcome message with app name",
"placeholders": {
"appName": {
"type": "String",
"example": "Worker App"
}
}
},
"itemsInCart": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
"@itemsInCart": {
"description": "Number of items in cart with pluralization",
"placeholders": {
"count": {
"type": "int",
"example": "3"
}
}
},
"ordersCount": "{count, plural, =0{No orders} =1{1 order} other{{count} orders}}",
"@ordersCount": {
"description": "Number of orders with pluralization",
"placeholders": {
"count": {
"type": "int",
"example": "5"
}
}
},
"projectsCount": "{count, plural, =0{No projects} =1{1 project} other{{count} projects}}",
"@projectsCount": {
"description": "Number of projects with pluralization",
"placeholders": {
"count": {
"type": "int",
"example": "3"
}
}
},
"daysRemaining": "{count, plural, =0{Today} =1{1 day left} other{{count} days left}}",
"@daysRemaining": {
"description": "Days remaining with pluralization",
"placeholders": {
"count": {
"type": "int",
"example": "7"
}
}
},
"formatCurrency": "{amount} ₫",
"@formatCurrency": {
"description": "Format currency in Vietnamese Dong",
"placeholders": {
"amount": {
"type": "String",
"example": "1,000,000"
}
}
},
"formatDate": "{month}/{day}/{year}",
"@formatDate": {
"description": "Date format MM/DD/YYYY",
"placeholders": {
"day": {
"type": "String"
},
"month": {
"type": "String"
},
"year": {
"type": "String"
}
}
},
"formatDateTime": "{month}/{day}/{year} at {hour}:{minute}",
"@formatDateTime": {
"description": "DateTime format MM/DD/YYYY at HH:mm",
"placeholders": {
"day": {"type": "String"},
"month": {"type": "String"},
"year": {"type": "String"},
"hour": {"type": "String"},
"minute": {"type": "String"}
}
},
"memberSince": "Member since {date}",
"@memberSince": {
"description": "Member since date",
"placeholders": {
"date": {
"type": "String",
"example": "01/2024"
}
}
},
"validUntil": "Valid until {date}",
"@validUntil": {
"description": "Valid until date",
"placeholders": {
"date": {
"type": "String",
"example": "12/31/2024"
}
}
},
"used": "Used",
"unused": "Unused",
"available": "Available",
"unavailable": "Unavailable",
"validFrom": "Valid from",
"validTo": "Valid to",
"usageInstructions": "Usage Instructions",
"useNow": "Use Now",
"scanQRCode": "Scan QR Code",
"scanBarcode": "Scan Barcode",
"qrCodeScanner": "QR Code Scanner",
"memberId": "Member ID",
"showQRCode": "Show QR Code",
"tier": "Tier",
"tierBenefits": "Tier Benefits",
"pointsMultiplier": "Points Multiplier",
"multiplierX": "x{multiplier}",
"@multiplierX": {
"description": "Points multiplier display",
"placeholders": {
"multiplier": {
"type": "String",
"example": "1.5"
}
}
},
"specialOffers": "Special Offers",
"exclusiveDiscounts": "Exclusive Discounts",
"prioritySupport": "Priority Support",
"earlyAccess": "Early Access",
"birthdayGift": "Birthday Gift",
"transactionType": "Transaction Type",
"earnPoints": "Earn Points",
"redeemPoints": "Redeem Points",
"bonusPoints": "Bonus Points",
"refundPoints": "Refund Points",
"expiredPoints": "Expired Points",
"transferPoints": "Transfer Points",
"pointsExpiry": "Points Expiry",
"pointsWillExpireOn": "Points will expire on {date}",
"@pointsWillExpireOn": {
"description": "Points expiration date",
"placeholders": {
"date": {
"type": "String",
"example": "12/31/2024"
}
}
},
"pointsExpiringSoon": "{points} points expiring soon",
"@pointsExpiringSoon": {
"description": "Points expiring soon warning",
"placeholders": {
"points": {
"type": "int",
"example": "100"
}
}
},
"newBalance": "New Balance",
"previousBalance": "Previous Balance",
"balanceAfter": "Balance After Transaction",
"disputeTransaction": "Dispute Transaction",
"disputeReason": "Dispute Reason",
"disputeSubmitted": "Dispute Submitted",
"rewardCategory": "Reward Category",
"vouchers": "Vouchers",
"productRewards": "Product Rewards",
"services": "Services",
"experiences": "Experiences",
"pointsCost": "Points Cost",
"pointsRequired": "Requires {points} points",
"@pointsRequired": {
"description": "Points required for reward",
"placeholders": {
"points": {
"type": "int",
"example": "500"
}
}
},
"expiryDate": "Expiry Date",
"expiresOn": "Expires on {date}",
"@expiresOn": {
"description": "Expiration date",
"placeholders": {
"date": {
"type": "String",
"example": "12/31/2024"
}
}
},
"redeemConfirm": "Confirm Redemption",
"redeemConfirmMessage": "Are you sure you want to redeem {points} points for {reward}?",
"@redeemConfirmMessage": {
"description": "Redeem confirmation message",
"placeholders": {
"points": {
"type": "int",
"example": "500"
},
"reward": {
"type": "String",
"example": "Gift Voucher"
}
}
},
"giftStatus": "Gift Status",
"activeGifts": "Active Gifts",
"usedGifts": "Used Gifts",
"expiredGifts": "Expired Gifts",
"giftDetails": "Gift Details",
"howToUse": "How to Use",
"referralInvite": "Invite Friends",
"referralReward": "Referral Reward",
"referralSuccess": "Referral Successful",
"friendsReferred": "Friends Referred",
"pointsEarned": "Points Earned",
"referralSteps": "How It Works",
"step1": "Step 1",
"step2": "Step 2",
"step3": "Step 3",
"shareYourCode": "Share Your Code",
"friendRegisters": "Friend Registers",
"bothGetRewards": "Both Get Rewards",
"inviteFriends": "Invite Friends",
"sku": "SKU",
"brand": "Brand",
"model": "Model",
"specification": "Specification",
"specifications": "Specifications",
"material": "Material",
"size": "Size",
"color": "Color",
"weight": "Weight",
"dimensions": "Dimensions",
"availability": "Availability",
"addedToCart": "Added to Cart",
"productDetails": "Product Details",
"relatedProducts": "Related Products",
"recommended": "Recommended",
"newArrival": "New Arrival",
"bestSeller": "Best Seller",
"onSale": "On Sale",
"limitedStock": "Limited Stock",
"lowStock": "Low Stock",
"updateQuantity": "Update Quantity",
"itemRemoved": "Item Removed",
"cartUpdated": "Cart Updated",
"proceedToCheckout": "Proceed to Checkout",
"continueShopping": "Continue Shopping",
"emptyCart": "Empty Cart",
"emptyCartMessage": "You don't have any items in your cart",
"selectAddress": "Select Address",
"selectPaymentMethod": "Select Payment Method",
"orderSummary": "Order Summary",
"orderConfirmation": "Order Confirmation",
"orderSuccessMessage": "Your order has been placed successfully!",
"orderNumberIs": "Order Number: {orderNumber}",
"@orderNumberIs": {
"description": "Order number display",
"placeholders": {
"orderNumber": {
"type": "String",
"example": "ORD-2024-001"
}
}
},
"estimatedDeliveryDate": "Estimated Delivery: {date}",
"@estimatedDeliveryDate": {
"description": "Estimated delivery date",
"placeholders": {
"date": {
"type": "String",
"example": "12/25/2024"
}
}
},
"viewOrder": "View Order",
"backToHome": "Back to Home",
"allOrders": "All Orders",
"pendingOrders": "Pending",
"processingOrders": "Processing",
"shippingOrders": "Shipping",
"completedOrders": "Completed",
"cancelledOrders": "Cancelled",
"cancelOrder": "Cancel Order",
"cancelOrderConfirm": "Are you sure you want to cancel this order?",
"cancelReason": "Cancellation Reason",
"orderCancelled": "Order Cancelled",
"orderTimeline": "Order Timeline",
"orderPlacedAt": "Order placed at",
"orderProcessedAt": "Order processed at",
"orderShippedAt": "Order shipped at",
"orderDeliveredAt": "Order delivered at",
"trackingNumber": "Tracking Number",
"shippingCarrier": "Shipping Carrier",
"allProjects": "All Projects",
"planningProjects": "Planning",
"inProgressProjects": "In Progress",
"completedProjects": "Completed",
"projectDetails": "Project Details",
"projectStatus": "Project Status",
"updateProgress": "Update Progress",
"progressUpdated": "Progress Updated",
"projectCompleted": "Project Completed",
"completeProject": "Complete Project",
"completeProjectConfirm": "Are you sure you want to mark this project as completed?",
"deleteProject": "Delete Project",
"deleteProjectConfirm": "Are you sure you want to delete this project?",
"projectPhotos": "Project Photos",
"addPhotos": "Add Photos",
"projectDocuments": "Project Documents",
"uploadDocument": "Upload Document",
"allQuotes": "All Quotes",
"draftQuotes": "Drafts",
"sentQuotes": "Sent",
"acceptedQuotes": "Accepted",
"rejectedQuotes": "Rejected",
"expiredQuotes": "Expired",
"quoteDetails": "Quote Details",
"sendQuote": "Send Quote",
"sendQuoteConfirm": "Are you sure you want to send this quote to the client?",
"quoteSent": "Quote Sent",
"acceptQuote": "Accept Quote",
"rejectQuote": "Reject Quote",
"deleteQuote": "Delete Quote",
"deleteQuoteConfirm": "Are you sure you want to delete this quote?",
"quoteItems": "Quote Items",
"addItem": "Add Item",
"editItem": "Edit Item",
"removeItem": "Remove Item",
"recipient": "Recipient",
"recipientName": "Recipient Name",
"recipientPhone": "Recipient Phone",
"addressType": "Address Type",
"addressLabel": "Address Label",
"setDefault": "Set as Default",
"defaultLabel": "Default",
"addressSaved": "Address Saved",
"currentPasswordRequired": "Please enter current password",
"newPasswordRequired": "Please enter new password",
"confirmPasswordRequired": "Please confirm new password",
"incorrectPassword": "Incorrect password",
"passwordStrength": "Password Strength",
"weak": "Weak",
"medium": "Medium",
"strong": "Strong",
"veryStrong": "Very Strong",
"passwordRequirement1": "At least 8 characters",
"passwordRequirement2": "Include uppercase letter",
"passwordRequirement3": "Include lowercase letter",
"passwordRequirement4": "Include number",
"passwordRequirement5": "Include special character",
"uploadPhoto": "Upload Photo",
"takePhoto": "Take Photo",
"chooseFromGallery": "Choose from Gallery",
"removePhoto": "Remove Photo",
"cropPhoto": "Crop Photo",
"photoUploaded": "Photo Uploaded",
"enableNotifications": "Enable Notifications",
"disableNotifications": "Disable Notifications",
"orderNotifications": "Order Notifications",
"promotionNotifications": "Promotion Notifications",
"systemNotifications": "System Notifications",
"chatNotifications": "Chat Notifications",
"pushNotifications": "Push Notifications",
"emailNotifications": "Email Notifications",
"smsNotifications": "SMS Notifications",
"vietnamese": "Vietnamese",
"english": "English",
"selectLanguage": "Select Language",
"languageChanged": "Language Changed",
"selectTheme": "Select Theme",
"themeChanged": "Theme Changed",
"autoTheme": "Auto",
"allNotifications": "All",
"orderNotification": "Orders",
"systemNotification": "System",
"promotionNotification": "Promotions",
"markAsRead": "Mark as Read",
"markAllAsRead": "Mark All as Read",
"deleteNotification": "Delete Notification",
"clearNotifications": "Clear All Notifications",
"clearNotificationsConfirm": "Are you sure you want to clear all notifications?",
"notificationCleared": "Notification Cleared",
"unreadNotifications": "{count} unread notifications",
"@unreadNotifications": {
"description": "Unread notifications count",
"placeholders": {
"count": {
"type": "int",
"example": "5"
}
}
},
"online": "Online",
"offline": "Offline",
"away": "Away",
"busy": "Busy",
"lastSeenAt": "Last seen {time}",
"@lastSeenAt": {
"description": "Last seen timestamp",
"placeholders": {
"time": {
"type": "String",
"example": "10 minutes ago"
}
}
},
"messageRead": "Read",
"messageDelivered": "Delivered",
"messageSent": "Sent",
"messageFailed": "Failed",
"retryMessage": "Retry",
"deleteMessage": "Delete Message",
"deleteMessageConfirm": "Are you sure you want to delete this message?",
"messageDeleted": "Message Deleted",
"filterBy": "Filter By",
"sortBy": "Sort By",
"priceAscending": "Price: Low to High",
"priceDescending": "Price: High to Low",
"nameAscending": "Name: A-Z",
"nameDescending": "Name: Z-A",
"dateAscending": "Oldest First",
"dateDescending": "Newest First",
"popularityDescending": "Most Popular",
"applyFilters": "Apply Filters",
"clearFilters": "Clear Filters",
"filterApplied": "Filter Applied",
"noFilterApplied": "No Filter Applied",
"connectionError": "Connection Error",
"noInternetConnection": "No Internet Connection",
"checkConnection": "Check Connection",
"retryConnection": "Retry Connection",
"offlineMode": "Offline Mode",
"syncData": "Sync Data",
"syncInProgress": "Syncing...",
"syncCompleted": "Sync Completed",
"syncFailed": "Sync Failed",
"lastSyncAt": "Last sync: {time}",
"@lastSyncAt": {
"description": "Last sync timestamp",
"placeholders": {
"time": {
"type": "String",
"example": "5 minutes ago"
}
}
},
"minutesAgo": "{minutes} minutes ago",
"@minutesAgo": {
"placeholders": {
"minutes": {"type": "int"}
}
},
"hoursAgo": "{hours} hours ago",
"@hoursAgo": {
"placeholders": {
"hours": {"type": "int"}
}
},
"daysAgo": "{days} days ago",
"@daysAgo": {
"placeholders": {
"days": {"type": "int"}
}
},
"weeksAgo": "{weeks} weeks ago",
"@weeksAgo": {
"placeholders": {
"weeks": {"type": "int"}
}
},
"monthsAgo": "{months} months ago",
"@monthsAgo": {
"placeholders": {
"months": {"type": "int"}
}
},
"yearsAgo": "{years} years ago",
"@yearsAgo": {
"placeholders": {
"years": {"type": "int"}
}
},
"justNow": "Just now",
"comingSoon": "Coming Soon",
"underMaintenance": "Under Maintenance",
"featureNotAvailable": "Feature Not Available",
"pageNotFound": "Page Not Found",
"goToHomePage": "Go to Home Page"
}

914
lib/l10n/app_vi.arb Normal file
View File

@@ -0,0 +1,914 @@
{
"@@locale": "vi",
"appTitle": "Worker App",
"@appTitle": {
"description": "The application title"
},
"home": "Trang chủ",
"@home": {
"description": "Home navigation item"
},
"products": "Sản phẩm",
"@products": {
"description": "Products navigation item"
},
"loyalty": "Hội viên",
"@loyalty": {
"description": "Loyalty navigation item"
},
"account": "Tài khoản",
"@account": {
"description": "Account navigation item"
},
"more": "Thêm",
"@more": {
"description": "More navigation item"
},
"login": "Đăng nhập",
"phone": "Số điện thoại",
"enterPhone": "Nhập số điện thoại",
"enterPhoneHint": "VD: 0912345678",
"continueButton": "Tiếp tục",
"verifyOTP": "Xác thực OTP",
"enterOTP": "Nhập mã OTP 6 số",
"otpSentTo": "Mã OTP đã được gửi đến {phone}",
"@otpSentTo": {
"description": "OTP sent message",
"placeholders": {
"phone": {
"type": "String",
"example": "0912345678"
}
}
},
"resendOTP": "Gửi lại mã",
"resendOTPIn": "Gửi lại sau {seconds}s",
"@resendOTPIn": {
"description": "Resend OTP countdown",
"placeholders": {
"seconds": {
"type": "int",
"example": "60"
}
}
},
"register": "Đăng ký",
"registerNewAccount": "Đăng ký tài khoản mới",
"logout": "Đăng xuất",
"logoutConfirm": "Bạn có chắc chắn muốn đăng xuất?",
"save": "Lưu",
"cancel": "Hủy",
"delete": "Xóa",
"edit": "Sửa",
"search": "Tìm kiếm",
"filter": "Lọc",
"sort": "Sắp xếp",
"confirm": "Xác nhận",
"close": "Đóng",
"back": "Quay lại",
"next": "Tiếp theo",
"submit": "Gửi",
"apply": "Áp dụng",
"clear": "Xóa",
"clearAll": "Xóa tất cả",
"viewDetails": "Xem chi tiết",
"viewAll": "Xem tất cả",
"refresh": "Làm mới",
"share": "Chia sẻ",
"copy": "Sao chép",
"copied": "Đã sao chép",
"yes": "Có",
"no": "Không",
"pending": "Chờ xử lý",
"processing": "Đang xử lý",
"shipping": "Đang giao hàng",
"completed": "Hoàn thành",
"cancelled": "Đã hủy",
"active": "Đang hoạt động",
"inactive": "Ngưng hoạt động",
"expired": "Hết hạn",
"draft": "Bản nháp",
"sent": "Đã gửi",
"accepted": "Đã chấp nhận",
"rejected": "Đã từ chối",
"name": "Tên",
"fullName": "Họ và tên",
"email": "Email",
"password": "Mật khẩu",
"currentPassword": "Mật khẩu hiện tại",
"newPassword": "Mật khẩu mới",
"confirmPassword": "Xác nhận mật khẩu",
"address": "Địa chỉ",
"street": "Đường",
"city": "Thành phố",
"district": "Quận/Huyện",
"ward": "Phường/Xã",
"postalCode": "Mã bưu điện",
"company": "Công ty",
"taxId": "Mã số thuế",
"dateOfBirth": "Ngày sinh",
"gender": "Giới tính",
"male": "Nam",
"female": "Nữ",
"other": "Khác",
"contractor": "Thầu thợ",
"architect": "Kiến trúc sư",
"distributor": "Đại lý phân phối",
"broker": "Môi giới",
"selectUserType": "Chọn loại người dùng",
"points": "Điểm",
"currentPoints": "Điểm hiện tại",
"pointsBalance": "{points} điểm",
"@pointsBalance": {
"description": "Points balance display",
"placeholders": {
"points": {
"type": "int",
"example": "1000"
}
}
},
"earnedPoints": "+{points} điểm",
"@earnedPoints": {
"description": "Points earned",
"placeholders": {
"points": {
"type": "int",
"example": "100"
}
}
},
"spentPoints": "-{points} điểm",
"@spentPoints": {
"description": "Points spent",
"placeholders": {
"points": {
"type": "int",
"example": "50"
}
}
},
"memberTier": "Hạng thành viên",
"diamond": "Kim cương",
"platinum": "Bạch kim",
"gold": "Vàng",
"pointsToNextTier": "Còn {points} điểm để lên hạng {tier}",
"@pointsToNextTier": {
"description": "Points needed for next tier",
"placeholders": {
"points": {
"type": "int",
"example": "500"
},
"tier": {
"type": "String",
"example": "Platinum"
}
}
},
"rewards": "Quà tặng",
"redeemReward": "Đổi quà",
"pointsHistory": "Lịch sử điểm",
"myGifts": "Quà của tôi",
"referral": "Giới thiệu bạn bè",
"referralCode": "Mã giới thiệu",
"referralLink": "Link giới thiệu",
"totalReferrals": "Tổng số người giới thiệu",
"shareReferralCode": "Chia sẻ mã giới thiệu",
"copyReferralCode": "Sao chép mã",
"copyReferralLink": "Sao chép link",
"product": "Sản phẩm",
"productName": "Tên sản phẩm",
"productCode": "Mã sản phẩm",
"price": "Giá",
"salePrice": "Giá khuyến mãi",
"quantity": "Số lượng",
"stock": "Kho",
"inStock": "Còn hàng",
"outOfStock": "Hết hàng",
"category": "Danh mục",
"allCategories": "Tất cả danh mục",
"addToCart": "Thêm vào giỏ",
"cart": "Giỏ hàng",
"cartEmpty": "Giỏ hàng trống",
"cartItemsCount": "{count} sản phẩm",
"@cartItemsCount": {
"description": "Number of items in cart",
"placeholders": {
"count": {
"type": "int",
"example": "3"
}
}
},
"removeFromCart": "Xóa khỏi giỏ",
"clearCart": "Xóa giỏ hàng",
"clearCartConfirm": "Bạn có chắc chắn muốn xóa tất cả sản phẩm trong giỏ hàng?",
"checkout": "Thanh toán",
"subtotal": "Tạm tính",
"discount": "Giảm giá",
"shipping": "Phí vận chuyển",
"total": "Tổng cộng",
"placeOrder": "Đặt hàng",
"orderPlaced": "Đơn hàng đã được đặt",
"orderSuccess": "Đặt hàng thành công",
"orders": "Đơn hàng",
"myOrders": "Đơn hàng của tôi",
"orderNumber": "Số đơn hàng",
"orderDate": "Ngày đặt",
"orderStatus": "Trạng thái đơn hàng",
"orderDetails": "Chi tiết đơn hàng",
"trackOrder": "Theo dõi đơn hàng",
"reorder": "Đặt lại",
"paymentMethod": "Phương thức thanh toán",
"cashOnDelivery": "Thanh toán khi nhận hàng",
"bankTransfer": "Chuyển khoản ngân hàng",
"creditCard": "Thẻ tín dụng",
"eWallet": "Ví điện tử",
"deliveryAddress": "Địa chỉ giao hàng",
"estimatedDelivery": "Dự kiến giao hàng",
"payments": "Thanh toán",
"paymentId": "Mã thanh toán",
"paymentStatus": "Trạng thái thanh toán",
"projects": "Công trình",
"myProjects": "Công trình của tôi",
"createProject": "Tạo công trình",
"projectName": "Tên công trình",
"projectCode": "Mã công trình",
"projectType": "Loại công trình",
"residential": "Dân dụng",
"commercial": "Thương mại",
"industrial": "Công nghiệp",
"client": "Khách hàng",
"clientName": "Tên khách hàng",
"clientPhone": "SĐT khách hàng",
"location": "Vị trí",
"startDate": "Ngày bắt đầu",
"endDate": "Ngày kết thúc",
"progress": "Tiến độ",
"budget": "Ngân sách",
"description": "Mô tả",
"notes": "Ghi chú",
"quotes": "Báo giá",
"createQuote": "Tạo báo giá",
"quoteNumber": "Số báo giá",
"quoteDate": "Ngày báo giá",
"validity": "Hiệu lực",
"convertToOrder": "Chuyển thành đơn hàng",
"duplicate": "Nhân bản",
"profile": "Hồ sơ",
"editProfile": "Chỉnh sửa hồ sơ",
"avatar": "Ảnh đại diện",
"uploadAvatar": "Tải lên ảnh đại diện",
"changePassword": "Đổi mật khẩu",
"passwordChanged": "Mật khẩu đã được thay đổi",
"addresses": "Địa chỉ",
"myAddresses": "Địa chỉ của tôi",
"addAddress": "Thêm địa chỉ",
"editAddress": "Sửa địa chỉ",
"deleteAddress": "Xóa địa chỉ",
"deleteAddressConfirm": "Bạn có chắc chắn muốn xóa địa chỉ này?",
"setAsDefault": "Đặt làm mặc định",
"defaultAddress": "Địa chỉ mặc định",
"homeAddress": "Nhà riêng",
"officeAddress": "Văn phòng",
"settings": "Cài đặt",
"notifications": "Thông báo",
"notificationSettings": "Cài đặt thông báo",
"language": "Ngôn ngữ",
"theme": "Giao diện",
"lightMode": "Sáng",
"darkMode": "Tối",
"systemMode": "Theo hệ thống",
"promotions": "Khuyến mãi",
"promotion": "Chương trình khuyến mãi",
"activePromotions": "Khuyến mãi đang diễn ra",
"upcomingPromotions": "Khuyến mãi sắp diễn ra",
"expiredPromotions": "Khuyến mãi đã kết thúc",
"claimPromotion": "Nhận ưu đãi",
"termsAndConditions": "Điều khoản & Điều kiện",
"chat": "Trò chuyện",
"chatSupport": "Hỗ trợ trực tuyến",
"sendMessage": "Gửi tin nhắn",
"typeMessage": "Nhập tin nhắn...",
"typingIndicator": "đang nhập...",
"attachFile": "Đính kèm tệp",
"supportAgent": "Nhân viên hỗ trợ",
"fieldRequired": "Trường này là bắt buộc",
"invalidPhone": "Số điện thoại không hợp lệ",
"invalidEmail": "Email không hợp lệ",
"invalidOTP": "Mã OTP không hợp lệ",
"passwordTooShort": "Mật khẩu phải có ít nhất 8 ký tự",
"passwordsNotMatch": "Mật khẩu không khớp",
"passwordRequirements": "Mật khẩu phải có ít nhất 8 ký tự, bao gồm chữ hoa, chữ thường, số và ký tự đặc biệt",
"invalidAmount": "Số tiền không hợp lệ",
"insufficientPoints": "Không đủ điểm để đổi quà",
"error": "Lỗi",
"errorOccurred": "Đã xảy ra lỗi",
"networkError": "Lỗi kết nối mạng. Vui lòng kiểm tra kết nối internet của bạn.",
"serverError": "Lỗi máy chủ. Vui lòng thử lại sau.",
"sessionExpired": "Phiên đăng nhập đã hết hạn. Vui lòng đăng nhập lại.",
"notFound": "Không tìm thấy",
"unauthorized": "Không có quyền truy cập",
"tryAgain": "Thử lại",
"contactSupport": "Liên hệ hỗ trợ",
"success": "Thành công",
"savedSuccessfully": "Đã lưu thành công",
"updatedSuccessfully": "Đã cập nhật thành công",
"deletedSuccessfully": "Đã xóa thành công",
"sentSuccessfully": "Đã gửi thành công",
"redeemSuccessful": "Đổi quà thành công",
"giftCode": "Mã quà tặng",
"loading": "Đang tải...",
"loadingData": "Đang tải dữ liệu...",
"processing": "Đang xử lý...",
"pleaseWait": "Vui lòng đợi...",
"noData": "Không có dữ liệu",
"noResults": "Không có kết quả",
"noProductsFound": "Không tìm thấy sản phẩm",
"noOrdersYet": "Chưa có đơn hàng nào",
"noProjectsYet": "Chưa có công trình nào",
"noNotifications": "Không có thông báo",
"noGiftsYet": "Chưa có quà tặng nào",
"startShopping": "Bắt đầu mua sắm",
"createFirstProject": "Tạo công trình đầu tiên",
"today": "Hôm nay",
"yesterday": "Hôm qua",
"thisWeek": "Tuần này",
"thisMonth": "Tháng này",
"all": "Tất cả",
"dateRange": "Khoảng thời gian",
"from": "Từ",
"to": "Đến",
"date": "Ngày",
"time": "Giờ",
"version": "Phiên bản",
"appVersion": "Phiên bản ứng dụng",
"help": "Trợ giúp",
"helpCenter": "Trung tâm trợ giúp",
"aboutUs": "Về chúng tôi",
"privacyPolicy": "Chính sách bảo mật",
"termsOfService": "Điều khoản sử dụng",
"rateApp": "Đánh giá ứng dụng",
"feedback": "Phản hồi",
"sendFeedback": "Gửi phản hồi",
"unsavedChanges": "Có thay đổi chưa được lưu",
"unsavedChangesMessage": "Bạn có muốn lưu các thay đổi trước khi thoát?",
"welcome": "Chào mừng",
"welcomeBack": "Chào mừng trở lại",
"welcomeTo": "Chào mừng đến với {appName}",
"@welcomeTo": {
"description": "Welcome message with app name",
"placeholders": {
"appName": {
"type": "String",
"example": "Worker App"
}
}
},
"itemsInCart": "{count, plural, =0{Không có sản phẩm} =1{1 sản phẩm} other{{count} sản phẩm}}",
"@itemsInCart": {
"description": "Number of items in cart with pluralization",
"placeholders": {
"count": {
"type": "int",
"example": "3"
}
}
},
"ordersCount": "{count, plural, =0{Không có đơn hàng} =1{1 đơn hàng} other{{count} đơn hàng}}",
"@ordersCount": {
"description": "Number of orders with pluralization",
"placeholders": {
"count": {
"type": "int",
"example": "5"
}
}
},
"projectsCount": "{count, plural, =0{Không có công trình} =1{1 công trình} other{{count} công trình}}",
"@projectsCount": {
"description": "Number of projects with pluralization",
"placeholders": {
"count": {
"type": "int",
"example": "3"
}
}
},
"daysRemaining": "{count, plural, =0{Hôm nay} =1{Còn 1 ngày} other{Còn {count} ngày}}",
"@daysRemaining": {
"description": "Days remaining with pluralization",
"placeholders": {
"count": {
"type": "int",
"example": "7"
}
}
},
"formatCurrency": "{amount} ₫",
"@formatCurrency": {
"description": "Format currency in Vietnamese Dong",
"placeholders": {
"amount": {
"type": "String",
"example": "1.000.000"
}
}
},
"formatDate": "{day}/{month}/{year}",
"@formatDate": {
"description": "Date format DD/MM/YYYY",
"placeholders": {
"day": {
"type": "String"
},
"month": {
"type": "String"
},
"year": {
"type": "String"
}
}
},
"formatDateTime": "{day}/{month}/{year} lúc {hour}:{minute}",
"@formatDateTime": {
"description": "DateTime format DD/MM/YYYY at HH:mm",
"placeholders": {
"day": {"type": "String"},
"month": {"type": "String"},
"year": {"type": "String"},
"hour": {"type": "String"},
"minute": {"type": "String"}
}
},
"memberSince": "Thành viên từ {date}",
"@memberSince": {
"description": "Member since date",
"placeholders": {
"date": {
"type": "String",
"example": "01/2024"
}
}
},
"validUntil": "Có hiệu lực đến {date}",
"@validUntil": {
"description": "Valid until date",
"placeholders": {
"date": {
"type": "String",
"example": "31/12/2024"
}
}
},
"used": "Đã sử dụng",
"unused": "Chưa sử dụng",
"available": "Có sẵn",
"unavailable": "Không có sẵn",
"validFrom": "Có hiệu lực từ",
"validTo": "Có hiệu lực đến",
"usageInstructions": "Hướng dẫn sử dụng",
"useNow": "Sử dụng ngay",
"scanQRCode": "Quét mã QR",
"scanBarcode": "Quét mã vạch",
"qrCodeScanner": "Quét mã QR",
"memberId": "Mã thành viên",
"showQRCode": "Hiển thị mã QR",
"tier": "Hạng",
"tierBenefits": "Quyền lợi hạng thành viên",
"pointsMultiplier": "Hệ số điểm",
"multiplierX": "x{multiplier}",
"@multiplierX": {
"description": "Points multiplier display",
"placeholders": {
"multiplier": {
"type": "String",
"example": "1.5"
}
}
},
"specialOffers": "Ưu đãi đặc biệt",
"exclusiveDiscounts": "Giảm giá độc quyền",
"prioritySupport": "Hỗ trợ ưu tiên",
"earlyAccess": "Truy cập sớm",
"birthdayGift": "Quà sinh nhật",
"transactionType": "Loại giao dịch",
"earnPoints": "Tích điểm",
"redeemPoints": "Đổi điểm",
"bonusPoints": "Điểm thưởng",
"refundPoints": "Hoàn điểm",
"expiredPoints": "Điểm hết hạn",
"transferPoints": "Chuyển điểm",
"pointsExpiry": "Điểm hết hạn",
"pointsWillExpireOn": "Điểm sẽ hết hạn vào {date}",
"@pointsWillExpireOn": {
"description": "Points expiration date",
"placeholders": {
"date": {
"type": "String",
"example": "31/12/2024"
}
}
},
"pointsExpiringSoon": "{points} điểm sắp hết hạn",
"@pointsExpiringSoon": {
"description": "Points expiring soon warning",
"placeholders": {
"points": {
"type": "int",
"example": "100"
}
}
},
"newBalance": "Số dư mới",
"previousBalance": "Số dư trước đó",
"balanceAfter": "Số dư sau giao dịch",
"disputeTransaction": "Khiếu nại giao dịch",
"disputeReason": "Lý do khiếu nại",
"disputeSubmitted": "Khiếu nại đã được gửi",
"rewardCategory": "Danh mục quà tặng",
"vouchers": "Phiếu quà tặng",
"productRewards": "Quà tặng sản phẩm",
"services": "Dịch vụ",
"experiences": "Trải nghiệm",
"pointsCost": "Chi phí điểm",
"pointsRequired": "Yêu cầu {points} điểm",
"@pointsRequired": {
"description": "Points required for reward",
"placeholders": {
"points": {
"type": "int",
"example": "500"
}
}
},
"expiryDate": "Ngày hết hạn",
"expiresOn": "Hết hạn vào {date}",
"@expiresOn": {
"description": "Expiration date",
"placeholders": {
"date": {
"type": "String",
"example": "31/12/2024"
}
}
},
"redeemConfirm": "Xác nhận đổi quà",
"redeemConfirmMessage": "Bạn có chắc chắn muốn đổi {points} điểm để nhận {reward}?",
"@redeemConfirmMessage": {
"description": "Redeem confirmation message",
"placeholders": {
"points": {
"type": "int",
"example": "500"
},
"reward": {
"type": "String",
"example": "Gift Voucher"
}
}
},
"giftStatus": "Trạng thái quà",
"activeGifts": "Quà đang dùng",
"usedGifts": "Quà đã dùng",
"expiredGifts": "Quà hết hạn",
"giftDetails": "Chi tiết quà tặng",
"howToUse": "Cách sử dụng",
"referralInvite": "Mời bạn bè",
"referralReward": "Phần thưởng giới thiệu",
"referralSuccess": "Giới thiệu thành công",
"friendsReferred": "Bạn bè đã giới thiệu",
"pointsEarned": "Điểm đã kiếm",
"referralSteps": "Cách thức giới thiệu",
"step1": "Bước 1",
"step2": "Bước 2",
"step3": "Bước 3",
"shareYourCode": "Chia sẻ mã của bạn",
"friendRegisters": "Bạn bè đăng ký",
"bothGetRewards": "Cả hai nhận thưởng",
"inviteFriends": "Mời bạn bè",
"sku": "SKU",
"brand": "Thương hiệu",
"model": "Mẫu",
"specification": "Thông số kỹ thuật",
"specifications": "Chi tiết kỹ thuật",
"material": "Chất liệu",
"size": "Kích thước",
"color": "Màu sắc",
"weight": "Trọng lượng",
"dimensions": "Kích thước",
"availability": "Tình trạng",
"addedToCart": "Đã thêm vào giỏ hàng",
"productDetails": "Chi tiết sản phẩm",
"relatedProducts": "Sản phẩm liên quan",
"recommended": "Đề xuất",
"newArrival": "Hàng mới về",
"bestSeller": "Bán chạy nhất",
"onSale": "Đang giảm giá",
"limitedStock": "Số lượng có hạn",
"lowStock": "Sắp hết hàng",
"updateQuantity": "Cập nhật số lượng",
"itemRemoved": "Đã xóa sản phẩm",
"cartUpdated": "Giỏ hàng đã được cập nhật",
"proceedToCheckout": "Tiến hành thanh toán",
"continueShopping": "Tiếp tục mua sắm",
"emptyCart": "Giỏ hàng trống",
"emptyCartMessage": "Bạn chưa có sản phẩm nào trong giỏ hàng",
"selectAddress": "Chọn địa chỉ",
"selectPaymentMethod": "Chọn phương thức thanh toán",
"orderSummary": "Tóm tắt đơn hàng",
"orderConfirmation": "Xác nhận đơn hàng",
"orderSuccessMessage": "Đơn hàng của bạn đã được đặt thành công!",
"orderNumberIs": "Số đơn hàng: {orderNumber}",
"@orderNumberIs": {
"description": "Order number display",
"placeholders": {
"orderNumber": {
"type": "String",
"example": "ORD-2024-001"
}
}
},
"estimatedDeliveryDate": "Dự kiến giao hàng: {date}",
"@estimatedDeliveryDate": {
"description": "Estimated delivery date",
"placeholders": {
"date": {
"type": "String",
"example": "25/12/2024"
}
}
},
"viewOrder": "Xem đơn hàng",
"backToHome": "Về trang chủ",
"allOrders": "Tất cả đơn hàng",
"pendingOrders": "Chờ xử lý",
"processingOrders": "Đang xử lý",
"shippingOrders": "Đang giao",
"completedOrders": "Hoàn thành",
"cancelledOrders": "Đã hủy",
"cancelOrder": "Hủy đơn hàng",
"cancelOrderConfirm": "Bạn có chắc chắn muốn hủy đơn hàng này?",
"cancelReason": "Lý do hủy",
"orderCancelled": "Đơn hàng đã được hủy",
"orderTimeline": "Lịch sử đơn hàng",
"orderPlacedAt": "Đơn hàng đã đặt lúc",
"orderProcessedAt": "Đơn hàng đã xử lý lúc",
"orderShippedAt": "Đơn hàng đã giao lúc",
"orderDeliveredAt": "Đơn hàng đã nhận lúc",
"trackingNumber": "Mã vận đơn",
"shippingCarrier": "Đơn vị vận chuyển",
"allProjects": "Tất cả công trình",
"planningProjects": "Đang lập kế hoạch",
"inProgressProjects": "Đang thực hiện",
"completedProjects": "Đã hoàn thành",
"projectDetails": "Chi tiết công trình",
"projectStatus": "Trạng thái công trình",
"updateProgress": "Cập nhật tiến độ",
"progressUpdated": "Tiến độ đã được cập nhật",
"projectCompleted": "Công trình đã hoàn thành",
"completeProject": "Hoàn thành công trình",
"completeProjectConfirm": "Bạn có chắc chắn muốn đánh dấu công trình này là hoàn thành?",
"deleteProject": "Xóa công trình",
"deleteProjectConfirm": "Bạn có chắc chắn muốn xóa công trình này?",
"projectPhotos": "Hình ảnh công trình",
"addPhotos": "Thêm hình ảnh",
"projectDocuments": "Tài liệu công trình",
"uploadDocument": "Tải lên tài liệu",
"allQuotes": "Tất cả báo giá",
"draftQuotes": "Bản nháp",
"sentQuotes": "Đã gửi",
"acceptedQuotes": "Đã chấp nhận",
"rejectedQuotes": "Đã từ chối",
"expiredQuotes": "Hết hạn",
"quoteDetails": "Chi tiết báo giá",
"sendQuote": "Gửi báo giá",
"sendQuoteConfirm": "Bạn có chắc chắn muốn gửi báo giá này cho khách hàng?",
"quoteSent": "Báo giá đã được gửi",
"acceptQuote": "Chấp nhận báo giá",
"rejectQuote": "Từ chối báo giá",
"deleteQuote": "Xóa báo giá",
"deleteQuoteConfirm": "Bạn có chắc chắn muốn xóa báo giá này?",
"quoteItems": "Các hạng mục",
"addItem": "Thêm hạng mục",
"editItem": "Sửa hạng mục",
"removeItem": "Xóa hạng mục",
"recipient": "Người nhận",
"recipientName": "Tên người nhận",
"recipientPhone": "SĐT người nhận",
"addressType": "Loại địa chỉ",
"addressLabel": "Nhãn địa chỉ",
"setDefault": "Đặt làm mặc định",
"defaultLabel": "Mặc định",
"addressSaved": "Địa chỉ đã được lưu",
"currentPasswordRequired": "Vui lòng nhập mật khẩu hiện tại",
"newPasswordRequired": "Vui lòng nhập mật khẩu mới",
"confirmPasswordRequired": "Vui lòng xác nhận mật khẩu mới",
"incorrectPassword": "Mật khẩu không chính xác",
"passwordStrength": "Độ mạnh mật khẩu",
"weak": "Yếu",
"medium": "Trung bình",
"strong": "Mạnh",
"veryStrong": "Rất mạnh",
"passwordRequirement1": "Ít nhất 8 ký tự",
"passwordRequirement2": "Có chữ hoa",
"passwordRequirement3": "Có chữ thường",
"passwordRequirement4": "Có số",
"passwordRequirement5": "Có ký tự đặc biệt",
"uploadPhoto": "Tải lên ảnh",
"takePhoto": "Chụp ảnh",
"chooseFromGallery": "Chọn từ thư viện",
"removePhoto": "Xóa ảnh",
"cropPhoto": "Cắt ảnh",
"photoUploaded": "Ảnh đã được tải lên",
"enableNotifications": "Bật thông báo",
"disableNotifications": "Tắt thông báo",
"orderNotifications": "Thông báo đơn hàng",
"promotionNotifications": "Thông báo khuyến mãi",
"systemNotifications": "Thông báo hệ thống",
"chatNotifications": "Thông báo trò chuyện",
"pushNotifications": "Thông báo đẩy",
"emailNotifications": "Thông báo email",
"smsNotifications": "Thông báo SMS",
"vietnamese": "Tiếng Việt",
"english": "Tiếng Anh",
"selectLanguage": "Chọn ngôn ngữ",
"languageChanged": "Ngôn ngữ đã được thay đổi",
"selectTheme": "Chọn giao diện",
"themeChanged": "Giao diện đã được thay đổi",
"autoTheme": "Tự động",
"allNotifications": "Tất cả",
"orderNotification": "Đơn hàng",
"systemNotification": "Hệ thống",
"promotionNotification": "Khuyến mãi",
"markAsRead": "Đánh dấu đã đọc",
"markAllAsRead": "Đánh dấu tất cả đã đọc",
"deleteNotification": "Xóa thông báo",
"clearNotifications": "Xóa tất cả thông báo",
"clearNotificationsConfirm": "Bạn có chắc chắn muốn xóa tất cả thông báo?",
"notificationCleared": "Thông báo đã được xóa",
"unreadNotifications": "{count} thông báo chưa đọc",
"@unreadNotifications": {
"description": "Unread notifications count",
"placeholders": {
"count": {
"type": "int",
"example": "5"
}
}
},
"online": "Trực tuyến",
"offline": "Ngoại tuyến",
"away": "Vắng mặt",
"busy": "Bận",
"lastSeenAt": "Hoạt động lần cuối {time}",
"@lastSeenAt": {
"description": "Last seen timestamp",
"placeholders": {
"time": {
"type": "String",
"example": "10 phút trước"
}
}
},
"messageRead": "Đã đọc",
"messageDelivered": "Đã gửi",
"messageSent": "Đã gửi",
"messageFailed": "Gửi thất bại",
"retryMessage": "Gửi lại",
"deleteMessage": "Xóa tin nhắn",
"deleteMessageConfirm": "Bạn có chắc chắn muốn xóa tin nhắn này?",
"messageDeleted": "Tin nhắn đã được xóa",
"filterBy": "Lọc theo",
"sortBy": "Sắp xếp theo",
"priceAscending": "Giá tăng dần",
"priceDescending": "Giá giảm dần",
"nameAscending": "Tên A-Z",
"nameDescending": "Tên Z-A",
"dateAscending": "Cũ nhất",
"dateDescending": "Mới nhất",
"popularityDescending": "Phổ biến nhất",
"applyFilters": "Áp dụng bộ lọc",
"clearFilters": "Xóa bộ lọc",
"filterApplied": "Đã áp dụng bộ lọc",
"noFilterApplied": "Chưa có bộ lọc nào",
"connectionError": "Lỗi kết nối",
"noInternetConnection": "Không có kết nối Internet",
"checkConnection": "Kiểm tra kết nối",
"retryConnection": "Thử kết nối lại",
"offlineMode": "Chế độ ngoại tuyến",
"syncData": "Đồng bộ dữ liệu",
"syncInProgress": "Đang đồng bộ...",
"syncCompleted": "Đồng bộ hoàn tất",
"syncFailed": "Đồng bộ thất bại",
"lastSyncAt": "Đồng bộ lần cuối: {time}",
"@lastSyncAt": {
"description": "Last sync timestamp",
"placeholders": {
"time": {
"type": "String",
"example": "5 phút trước"
}
}
},
"minutesAgo": "{minutes} phút trước",
"@minutesAgo": {
"placeholders": {
"minutes": {"type": "int"}
}
},
"hoursAgo": "{hours} giờ trước",
"@hoursAgo": {
"placeholders": {
"hours": {"type": "int"}
}
},
"daysAgo": "{days} ngày trước",
"@daysAgo": {
"placeholders": {
"days": {"type": "int"}
}
},
"weeksAgo": "{weeks} tuần trước",
"@weeksAgo": {
"placeholders": {
"weeks": {"type": "int"}
}
},
"monthsAgo": "{months} tháng trước",
"@monthsAgo": {
"placeholders": {
"months": {"type": "int"}
}
},
"yearsAgo": "{years} năm trước",
"@yearsAgo": {
"placeholders": {
"years": {"type": "int"}
}
},
"justNow": "Vừa xong",
"comingSoon": "Sắp ra mắt",
"underMaintenance": "Đang bảo trì",
"featureNotAvailable": "Tính năng chưa khả dụng",
"pageNotFound": "Không tìm thấy trang",
"goToHomePage": "Về trang chủ"
}

View File

@@ -1,122 +1,267 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
runApp(const MyApp());
import 'package:worker/app.dart';
import 'package:worker/core/database/hive_initializer.dart';
/// Main entry point of the Worker Mobile App
///
/// Initializes core dependencies:
/// - Hive database with adapters and boxes
/// - SharedPreferences for simple key-value storage
/// - Riverpod ProviderScope for state management
/// - Error handling boundaries
/// - System UI customization
void main() async {
// Ensure Flutter is initialized before async operations
WidgetsFlutterBinding.ensureInitialized();
// Set preferred device orientations
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Initialize app with error handling
await _initializeApp();
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
/// Initialize all app dependencies with comprehensive error handling
Future<void> _initializeApp() async {
// Set up error handlers before anything else
_setupErrorHandlers();
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
// This is the theme of your application.
//
// TRY THIS: Try running your application with "flutter run". You'll see
// the application has a purple toolbar. Then, without quitting the app,
// try changing the seedColor in the colorScheme below to Colors.green
// and then invoke "hot reload" (save your changes or press the "hot
// reload" button in a Flutter-supported IDE, or press "r" if you used
// the command line to start the app).
//
// Notice that the counter didn't reset back to zero; the application
// state is not lost during the reload. To reset the state, use hot
// restart instead.
//
// This works for code too, not just values: Most code changes can be
// tested with just a hot reload.
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
try {
// Initialize core dependencies in parallel for faster startup
await Future.wait([
_initializeHive(),
_initializeSharedPreferences(),
]);
// Run the app with Riverpod ProviderScope
runApp(
const ProviderScope(
child: WorkerApp(),
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
} catch (error, stackTrace) {
// Critical initialization error - show error screen
debugPrint('Failed to initialize app: $error');
debugPrint('StackTrace: $stackTrace');
// Run minimal error app
runApp(_buildErrorApp(error, stackTrace));
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
/// Initialize Hive database
///
/// Sets up local database with:
/// - Type adapters for all models
/// - All required boxes (user, cart, products, etc.)
/// - Cache cleanup for expired data
/// - Encryption for sensitive data (in production)
Future<void> _initializeHive() async {
try {
debugPrint('Initializing Hive database...');
// This widget is the home page of your application. It is stateful, meaning
// that it has a State object (defined below) that contains fields that affect
// how it looks.
await HiveInitializer.initialize(
enableEncryption: kReleaseMode, // Enable encryption in release builds
verbose: kDebugMode, // Verbose logging in debug mode
);
// This class is the configuration for the state. It holds the values (in this
// case the title) provided by the parent (in this case the App widget) and
// used by the build method of the State. Fields in a Widget subclass are
// always marked "final".
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
debugPrint('Hive database initialized successfully');
} catch (error, stackTrace) {
debugPrint('Failed to initialize Hive: $error');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
/// Initialize SharedPreferences
///
/// Used for simple key-value storage like:
/// - Last sync timestamp
/// - User preferences (language, theme)
/// - App settings
/// - Feature flags
Future<void> _initializeSharedPreferences() async {
try {
debugPrint('Initializing SharedPreferences...');
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that something has
// changed in this State, which causes it to rerun the build method below
// so that the display can reflect the updated values. If we changed
// _counter without calling setState(), then the build method would not be
// called again, and so nothing would appear to happen.
_counter++;
});
// Pre-initialize SharedPreferences instance
await SharedPreferences.getInstance();
debugPrint('SharedPreferences initialized successfully');
} catch (error, stackTrace) {
debugPrint('Failed to initialize SharedPreferences: $error');
debugPrint('StackTrace: $stackTrace');
rethrow;
}
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance as done
// by the _incrementCounter method above.
//
// The Flutter framework has been optimized to make rerunning build methods
// fast, so that you can just rebuild anything that needs updating rather
// than having to individually change instances of widgets.
return Scaffold(
appBar: AppBar(
// TRY THIS: Try changing the color here to a specific color (to
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
// change color while the other colors stay the same.
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
// Here we take the value from the MyHomePage object that was created by
// the App.build method, and use it to set our appbar title.
title: Text(widget.title),
),
body: Center(
// Center is a layout widget. It takes a single child and positions it
// in the middle of the parent.
child: Column(
// Column is also a layout widget. It takes a list of children and
// arranges them vertically. By default, it sizes itself to fit its
// children horizontally, and tries to be as tall as its parent.
//
// Column has various properties to control how it sizes itself and
// how it positions its children. Here we use mainAxisAlignment to
// center the children vertically; the main axis here is the vertical
// axis because Columns are vertical (the cross axis would be
// horizontal).
//
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
// action in the IDE, or press "p" in the console), to see the
// wireframe for each widget.
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You have pushed the button this many times:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
/// Set up global error handlers
///
/// Captures and logs all Flutter framework errors and uncaught exceptions
void _setupErrorHandlers() {
// Handle Flutter framework errors
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
// Log to console in debug mode
if (kDebugMode) {
debugPrint('Flutter Error: ${details.exceptionAsString()}');
debugPrint('StackTrace: ${details.stack}');
}
// In production, you would send to crash analytics service
// Example: FirebaseCrashlytics.instance.recordFlutterError(details);
};
// Handle errors outside of Flutter framework
PlatformDispatcher.instance.onError = (error, stackTrace) {
if (kDebugMode) {
debugPrint('Platform Error: $error');
debugPrint('StackTrace: $stackTrace');
}
// In production, you would send to crash analytics service
// Example: FirebaseCrashlytics.instance.recordError(error, stackTrace);
return true; // Return true to indicate error was handled
};
// Handle zone errors (async errors not caught by Flutter)
runZonedGuarded(
() {
// App will run in this zone
},
(error, stackTrace) {
if (kDebugMode) {
debugPrint('Zone Error: $error');
debugPrint('StackTrace: $stackTrace');
}
// In production, you would send to crash analytics service
// Example: FirebaseCrashlytics.instance.recordError(error, stackTrace);
},
);
}
/// Build minimal error app when initialization fails
///
/// Shows a user-friendly error screen instead of crashing
Widget _buildErrorApp(Object error, StackTrace stackTrace) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Error icon
const Icon(
Icons.error_outline,
size: 80,
color: Color(0xFFDC3545),
),
const SizedBox(height: 24),
// Error title
const Text(
'Không thể khởi động ứng dụng',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF212529),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Error message
const Text(
'Đã xảy ra lỗi khi khởi động ứng dụng. '
'Vui lòng thử lại sau hoặc liên hệ hỗ trợ.',
style: TextStyle(
fontSize: 16,
color: Color(0xFF6C757D),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Error details (debug mode only)
if (kDebugMode) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFFFECB5),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Debug Information:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Color(0xFF856404),
),
),
const SizedBox(height: 8),
Text(
error.toString(),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF856404),
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(height: 16),
],
// Restart button
ElevatedButton.icon(
onPressed: () {
// Restart app
_initializeApp();
},
icon: const Icon(Icons.refresh),
label: const Text('Thử lại'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF005B9A),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
),
);
}

View File

@@ -0,0 +1,145 @@
/// Custom App Bar Widget
///
/// Reusable app bar with consistent styling across the app
library;
import 'package:flutter/material.dart';
import '../../core/constants/ui_constants.dart';
/// Custom app bar with consistent styling
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
final Widget? leading;
final bool centerTitle;
final Color? backgroundColor;
final Color? foregroundColor;
final double elevation;
final PreferredSizeWidget? bottom;
final bool automaticallyImplyLeading;
const CustomAppBar({
super.key,
required this.title,
this.actions,
this.leading,
this.centerTitle = true,
this.backgroundColor,
this.foregroundColor,
this.elevation = AppElevation.none,
this.bottom,
this.automaticallyImplyLeading = true,
});
@override
Widget build(BuildContext context) {
return AppBar(
title: Text(title),
actions: actions,
leading: leading,
centerTitle: centerTitle,
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
elevation: elevation,
bottom: bottom,
automaticallyImplyLeading: automaticallyImplyLeading,
);
}
@override
Size get preferredSize => Size.fromHeight(
AppBarSpecs.height + (bottom?.preferredSize.height ?? 0),
);
}
/// Transparent app bar for overlay scenarios
class TransparentAppBar extends StatelessWidget implements PreferredSizeWidget {
final String? title;
final List<Widget>? actions;
final Widget? leading;
final bool centerTitle;
final Color? foregroundColor;
const TransparentAppBar({
super.key,
this.title,
this.actions,
this.leading,
this.centerTitle = true,
this.foregroundColor,
});
@override
Widget build(BuildContext context) {
return AppBar(
title: title != null ? Text(title!) : null,
actions: actions,
leading: leading,
centerTitle: centerTitle,
backgroundColor: Colors.transparent,
foregroundColor: foregroundColor ?? Colors.white,
elevation: 0,
);
}
@override
Size get preferredSize => const Size.fromHeight(AppBarSpecs.height);
}
/// Search app bar with search field
class SearchAppBar extends StatelessWidget implements PreferredSizeWidget {
final String hintText;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onClear;
final TextEditingController? controller;
final bool autofocus;
final Widget? leading;
const SearchAppBar({
super.key,
this.hintText = 'Tìm kiếm...',
this.onChanged,
this.onSubmitted,
this.onClear,
this.controller,
this.autofocus = false,
this.leading,
});
@override
Widget build(BuildContext context) {
return AppBar(
leading: leading ??
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
title: TextField(
controller: controller,
autofocus: autofocus,
onChanged: onChanged,
onSubmitted: onSubmitted,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(color: Colors.white.withOpacity(0.7)),
border: InputBorder.none,
suffixIcon: controller?.text.isNotEmpty ?? false
? IconButton(
icon: const Icon(Icons.clear, color: Colors.white),
onPressed: () {
controller?.clear();
onClear?.call();
},
)
: null,
),
),
elevation: AppElevation.none,
);
}
@override
Size get preferredSize => const Size.fromHeight(AppBarSpecs.height);
}

View File

@@ -0,0 +1,375 @@
/// Date Picker Input Field
///
/// Input field that opens a date picker dialog when tapped.
/// Displays dates in Vietnamese format (dd/MM/yyyy).
library;
import 'package:flutter/material.dart';
import '../../core/utils/formatters.dart';
import '../../core/utils/validators.dart';
import '../../core/constants/ui_constants.dart';
/// Date picker input field
class DatePickerField extends StatefulWidget {
final TextEditingController? controller;
final String? labelText;
final String? hintText;
final DateTime? initialDate;
final DateTime? firstDate;
final DateTime? lastDate;
final ValueChanged<DateTime>? onDateSelected;
final FormFieldValidator<String>? validator;
final bool enabled;
final bool required;
final Widget? prefixIcon;
final Widget? suffixIcon;
final InputDecoration? decoration;
const DatePickerField({
super.key,
this.controller,
this.labelText,
this.hintText,
this.initialDate,
this.firstDate,
this.lastDate,
this.onDateSelected,
this.validator,
this.enabled = true,
this.required = true,
this.prefixIcon,
this.suffixIcon,
this.decoration,
});
@override
State<DatePickerField> createState() => _DatePickerFieldState();
}
class _DatePickerFieldState extends State<DatePickerField> {
late TextEditingController _controller;
bool _isControllerInternal = false;
DateTime? _selectedDate;
@override
void initState() {
super.initState();
_selectedDate = widget.initialDate;
if (widget.controller == null) {
_controller = TextEditingController(
text: _selectedDate != null
? DateFormatter.formatDate(_selectedDate!)
: '',
);
_isControllerInternal = true;
} else {
_controller = widget.controller!;
}
}
@override
void dispose() {
if (_isControllerInternal) {
_controller.dispose();
}
super.dispose();
}
Future<void> _selectDate(BuildContext context) async {
if (!widget.enabled) return;
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? DateTime.now(),
firstDate: widget.firstDate ?? DateTime(1900),
lastDate: widget.lastDate ?? DateTime(2100),
locale: const Locale('vi', 'VN'),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light(
primary: Theme.of(context).primaryColor,
),
),
child: child!,
);
},
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
_controller.text = DateFormatter.formatDate(picked);
});
widget.onDateSelected?.call(picked);
}
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: _controller,
readOnly: true,
enabled: widget.enabled,
onTap: () => _selectDate(context),
decoration: widget.decoration ??
InputDecoration(
labelText: widget.labelText ?? 'Ngày',
hintText: widget.hintText ?? 'dd/MM/yyyy',
prefixIcon: widget.prefixIcon ?? const Icon(Icons.calendar_today),
suffixIcon: widget.suffixIcon,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
),
contentPadding: InputFieldSpecs.contentPadding,
),
validator: widget.validator ??
(widget.required ? Validators.date : null),
);
}
}
/// Date range picker field
class DateRangePickerField extends StatefulWidget {
final String? labelText;
final String? hintText;
final DateTimeRange? initialRange;
final DateTime? firstDate;
final DateTime? lastDate;
final ValueChanged<DateTimeRange>? onRangeSelected;
final bool enabled;
final Widget? prefixIcon;
const DateRangePickerField({
super.key,
this.labelText,
this.hintText,
this.initialRange,
this.firstDate,
this.lastDate,
this.onRangeSelected,
this.enabled = true,
this.prefixIcon,
});
@override
State<DateRangePickerField> createState() => _DateRangePickerFieldState();
}
class _DateRangePickerFieldState extends State<DateRangePickerField> {
late TextEditingController _controller;
DateTimeRange? _selectedRange;
@override
void initState() {
super.initState();
_selectedRange = widget.initialRange;
_controller = TextEditingController(
text: _selectedRange != null
? '${DateFormatter.formatDate(_selectedRange!.start)} - ${DateFormatter.formatDate(_selectedRange!.end)}'
: '',
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _selectDateRange(BuildContext context) async {
if (!widget.enabled) return;
final DateTimeRange? picked = await showDateRangePicker(
context: context,
initialDateRange: _selectedRange,
firstDate: widget.firstDate ?? DateTime(1900),
lastDate: widget.lastDate ?? DateTime(2100),
locale: const Locale('vi', 'VN'),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light(
primary: Theme.of(context).primaryColor,
),
),
child: child!,
);
},
);
if (picked != null && picked != _selectedRange) {
setState(() {
_selectedRange = picked;
_controller.text =
'${DateFormatter.formatDate(picked.start)} - ${DateFormatter.formatDate(picked.end)}';
});
widget.onRangeSelected?.call(picked);
}
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: _controller,
readOnly: true,
enabled: widget.enabled,
onTap: () => _selectDateRange(context),
decoration: InputDecoration(
labelText: widget.labelText ?? 'Khoảng thời gian',
hintText: widget.hintText ?? 'Chọn khoảng thời gian',
prefixIcon: widget.prefixIcon ?? const Icon(Icons.date_range),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
),
contentPadding: InputFieldSpecs.contentPadding,
),
);
}
}
/// Date of birth picker field
class DateOfBirthField extends StatelessWidget {
final TextEditingController? controller;
final String? labelText;
final String? hintText;
final ValueChanged<DateTime>? onDateSelected;
final FormFieldValidator<String>? validator;
final bool enabled;
final int minAge;
const DateOfBirthField({
super.key,
this.controller,
this.labelText,
this.hintText,
this.onDateSelected,
this.validator,
this.enabled = true,
this.minAge = 18,
});
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final maxDate = DateTime(now.year - minAge, now.month, now.day);
final minDate = DateTime(now.year - 100, now.month, now.day);
return DatePickerField(
controller: controller,
labelText: labelText ?? 'Ngày sinh',
hintText: hintText ?? 'dd/MM/yyyy',
initialDate: maxDate,
firstDate: minDate,
lastDate: maxDate,
onDateSelected: onDateSelected,
validator: validator ?? (value) => Validators.age(value, minAge: minAge),
enabled: enabled,
prefixIcon: const Icon(Icons.cake),
);
}
}
/// Time picker field
class TimePickerField extends StatefulWidget {
final TextEditingController? controller;
final String? labelText;
final String? hintText;
final TimeOfDay? initialTime;
final ValueChanged<TimeOfDay>? onTimeSelected;
final bool enabled;
final Widget? prefixIcon;
const TimePickerField({
super.key,
this.controller,
this.labelText,
this.hintText,
this.initialTime,
this.onTimeSelected,
this.enabled = true,
this.prefixIcon,
});
@override
State<TimePickerField> createState() => _TimePickerFieldState();
}
class _TimePickerFieldState extends State<TimePickerField> {
late TextEditingController _controller;
bool _isControllerInternal = false;
TimeOfDay? _selectedTime;
@override
void initState() {
super.initState();
_selectedTime = widget.initialTime;
if (widget.controller == null) {
_controller = TextEditingController(
text: _selectedTime != null
? '${_selectedTime!.hour.toString().padLeft(2, '0')}:${_selectedTime!.minute.toString().padLeft(2, '0')}'
: '',
);
_isControllerInternal = true;
} else {
_controller = widget.controller!;
}
}
@override
void dispose() {
if (_isControllerInternal) {
_controller.dispose();
}
super.dispose();
}
Future<void> _selectTime(BuildContext context) async {
if (!widget.enabled) return;
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: _selectedTime ?? TimeOfDay.now(),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.light(
primary: Theme.of(context).primaryColor,
),
),
child: child!,
);
},
);
if (picked != null && picked != _selectedTime) {
setState(() {
_selectedTime = picked;
_controller.text =
'${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}';
});
widget.onTimeSelected?.call(picked);
}
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: _controller,
readOnly: true,
enabled: widget.enabled,
onTap: () => _selectTime(context),
decoration: InputDecoration(
labelText: widget.labelText ?? 'Thời gian',
hintText: widget.hintText ?? 'HH:mm',
prefixIcon: widget.prefixIcon ?? const Icon(Icons.access_time),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
),
contentPadding: InputFieldSpecs.contentPadding,
),
);
}
}

View File

@@ -0,0 +1,270 @@
/// Gradient Card Widget
///
/// Reusable card with gradient background used for member cards
/// and other gradient-based UI elements.
library;
import 'package:flutter/material.dart';
import '../../core/constants/ui_constants.dart';
/// Card with gradient background
class GradientCard extends StatelessWidget {
final Widget child;
final Gradient gradient;
final double borderRadius;
final double elevation;
final EdgeInsets padding;
final double? width;
final double? height;
final VoidCallback? onTap;
final List<BoxShadow>? shadows;
const GradientCard({
super.key,
required this.child,
required this.gradient,
this.borderRadius = AppRadius.card,
this.elevation = AppElevation.card,
this.padding = const EdgeInsets.all(AppSpacing.md),
this.width,
this.height,
this.onTap,
this.shadows,
});
@override
Widget build(BuildContext context) {
final cardContent = Container(
width: width,
height: height,
padding: padding,
decoration: BoxDecoration(
gradient: gradient,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: shadows ??
[
BoxShadow(
color: Colors.black.withOpacity(0.1 * (elevation / 4)),
blurRadius: elevation,
offset: Offset(0, elevation / 2),
),
],
),
child: child,
);
if (onTap != null) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(borderRadius),
child: cardContent,
);
}
return cardContent;
}
}
/// Diamond tier gradient card
class DiamondGradientCard extends StatelessWidget {
final Widget child;
final double borderRadius;
final double elevation;
final EdgeInsets padding;
final double? width;
final double? height;
final VoidCallback? onTap;
const DiamondGradientCard({
super.key,
required this.child,
this.borderRadius = MemberCardSpecs.borderRadius,
this.elevation = MemberCardSpecs.elevation,
this.padding = MemberCardSpecs.padding,
this.width = MemberCardSpecs.width,
this.height = MemberCardSpecs.height,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GradientCard(
gradient: const LinearGradient(
colors: [Color(0xFF4A00E0), Color(0xFF8E2DE2)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: borderRadius,
elevation: elevation,
padding: padding,
width: width,
height: height,
onTap: onTap,
child: child,
);
}
}
/// Platinum tier gradient card
class PlatinumGradientCard extends StatelessWidget {
final Widget child;
final double borderRadius;
final double elevation;
final EdgeInsets padding;
final double? width;
final double? height;
final VoidCallback? onTap;
const PlatinumGradientCard({
super.key,
required this.child,
this.borderRadius = MemberCardSpecs.borderRadius,
this.elevation = MemberCardSpecs.elevation,
this.padding = MemberCardSpecs.padding,
this.width = MemberCardSpecs.width,
this.height = MemberCardSpecs.height,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GradientCard(
gradient: const LinearGradient(
colors: [Color(0xFF7F8C8D), Color(0xFFBDC3C7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: borderRadius,
elevation: elevation,
padding: padding,
width: width,
height: height,
onTap: onTap,
child: child,
);
}
}
/// Gold tier gradient card
class GoldGradientCard extends StatelessWidget {
final Widget child;
final double borderRadius;
final double elevation;
final EdgeInsets padding;
final double? width;
final double? height;
final VoidCallback? onTap;
const GoldGradientCard({
super.key,
required this.child,
this.borderRadius = MemberCardSpecs.borderRadius,
this.elevation = MemberCardSpecs.elevation,
this.padding = MemberCardSpecs.padding,
this.width = MemberCardSpecs.width,
this.height = MemberCardSpecs.height,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GradientCard(
gradient: const LinearGradient(
colors: [Color(0xFFf7b733), Color(0xFFfc4a1a)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: borderRadius,
elevation: elevation,
padding: padding,
width: width,
height: height,
onTap: onTap,
child: child,
);
}
}
/// Animated gradient card with shimmer effect
class ShimmerGradientCard extends StatefulWidget {
final Widget child;
final Gradient gradient;
final double borderRadius;
final double elevation;
final EdgeInsets padding;
final double? width;
final double? height;
final Duration shimmerDuration;
const ShimmerGradientCard({
super.key,
required this.child,
required this.gradient,
this.borderRadius = AppRadius.card,
this.elevation = AppElevation.card,
this.padding = const EdgeInsets.all(AppSpacing.md),
this.width,
this.height,
this.shimmerDuration = AppDuration.shimmer,
});
@override
State<ShimmerGradientCard> createState() => _ShimmerGradientCardState();
}
class _ShimmerGradientCardState extends State<ShimmerGradientCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: widget.shimmerDuration,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return ShaderMask(
shaderCallback: (bounds) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withOpacity(0.1),
Colors.white.withOpacity(0.3),
Colors.white.withOpacity(0.1),
],
stops: [
_controller.value - 0.3,
_controller.value,
_controller.value + 0.3,
],
).createShader(bounds);
},
blendMode: BlendMode.srcATop,
child: GradientCard(
gradient: widget.gradient,
borderRadius: widget.borderRadius,
elevation: widget.elevation,
padding: widget.padding,
width: widget.width,
height: widget.height,
child: widget.child,
),
);
},
);
}
}

View File

@@ -0,0 +1,269 @@
/// Price Display Widget
///
/// Formats and displays prices in Vietnamese currency format
library;
import 'package:flutter/material.dart';
import '../../core/utils/formatters.dart';
/// Price display with Vietnamese currency formatting
class PriceDisplay extends StatelessWidget {
final double price;
final TextStyle? style;
final bool showSymbol;
final int decimalDigits;
final Color? color;
final FontWeight? fontWeight;
final double? fontSize;
const PriceDisplay({
super.key,
required this.price,
this.style,
this.showSymbol = true,
this.decimalDigits = 0,
this.color,
this.fontWeight,
this.fontSize,
});
@override
Widget build(BuildContext context) {
final formattedPrice = CurrencyFormatter.formatWithDecimals(
price,
decimalDigits: decimalDigits,
showSymbol: showSymbol,
);
return Text(
formattedPrice,
style: style ??
TextStyle(
color: color,
fontWeight: fontWeight ?? FontWeight.w600,
fontSize: fontSize,
),
);
}
}
/// Price display with sale price comparison
class SalePriceDisplay extends StatelessWidget {
final double originalPrice;
final double salePrice;
final TextStyle? originalPriceStyle;
final TextStyle? salePriceStyle;
final bool showSymbol;
final MainAxisAlignment alignment;
const SalePriceDisplay({
super.key,
required this.originalPrice,
required this.salePrice,
this.originalPriceStyle,
this.salePriceStyle,
this.showSymbol = true,
this.alignment = MainAxisAlignment.start,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: alignment,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
// Sale price (larger, prominent)
Text(
CurrencyFormatter.format(salePrice, showSymbol: showSymbol),
style: salePriceStyle ??
const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
const SizedBox(width: 8),
// Original price (smaller, strikethrough)
Text(
CurrencyFormatter.format(originalPrice, showSymbol: showSymbol),
style: originalPriceStyle ??
TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: Colors.grey[600],
decoration: TextDecoration.lineThrough,
),
),
],
);
}
}
/// Price display with discount percentage badge
class PriceWithDiscount extends StatelessWidget {
final double originalPrice;
final double salePrice;
final bool showSymbol;
final TextStyle? salePriceStyle;
final TextStyle? originalPriceStyle;
const PriceWithDiscount({
super.key,
required this.originalPrice,
required this.salePrice,
this.showSymbol = true,
this.salePriceStyle,
this.originalPriceStyle,
});
double get discountPercentage {
return ((originalPrice - salePrice) / originalPrice * 100);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Sale price
Text(
CurrencyFormatter.format(salePrice, showSymbol: showSymbol),
style: salePriceStyle ??
const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
const SizedBox(width: 8),
// Discount badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'-${discountPercentage.toStringAsFixed(0)}%',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
),
const SizedBox(height: 4),
// Original price
Text(
CurrencyFormatter.format(originalPrice, showSymbol: showSymbol),
style: originalPriceStyle ??
TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: Colors.grey[600],
decoration: TextDecoration.lineThrough,
),
),
],
);
}
}
/// Compact price display for lists/grids
class CompactPriceDisplay extends StatelessWidget {
final double price;
final double? salePrice;
final bool showSymbol;
const CompactPriceDisplay({
super.key,
required this.price,
this.salePrice,
this.showSymbol = true,
});
@override
Widget build(BuildContext context) {
final bool isOnSale = salePrice != null && salePrice! < price;
if (isOnSale) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
CurrencyFormatter.format(salePrice!, showSymbol: showSymbol),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
Text(
CurrencyFormatter.format(price, showSymbol: showSymbol),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
decoration: TextDecoration.lineThrough,
),
),
],
);
}
return Text(
CurrencyFormatter.format(price, showSymbol: showSymbol),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
);
}
}
/// Large price display for checkout/order summary
class LargePriceDisplay extends StatelessWidget {
final double price;
final String? label;
final bool showSymbol;
final Color? color;
const LargePriceDisplay({
super.key,
required this.price,
this.label,
this.showSymbol = true,
this.color,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null) ...[
Text(
label!,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
],
Text(
CurrencyFormatter.format(price, showSymbol: showSymbol),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: color ?? Theme.of(context).primaryColor,
),
),
],
);
}
}

View File

@@ -0,0 +1,245 @@
/// Status Badge Widget
///
/// Displays status indicators with color-coded badges for orders,
/// projects, payments, and other status-based entities.
library;
import 'package:flutter/material.dart';
import '../../core/constants/ui_constants.dart';
import '../../core/theme/colors.dart';
/// Status badge with color-coded indicators
class StatusBadge extends StatelessWidget {
final String label;
final Color color;
final Color? textColor;
final double borderRadius;
final EdgeInsets padding;
final double fontSize;
final FontWeight fontWeight;
const StatusBadge({
super.key,
required this.label,
required this.color,
this.textColor,
this.borderRadius = StatusBadgeSpecs.borderRadius,
this.padding = StatusBadgeSpecs.padding,
this.fontSize = StatusBadgeSpecs.fontSize,
this.fontWeight = StatusBadgeSpecs.fontWeight,
});
/// Order status badges
factory StatusBadge.orderPending() => const StatusBadge(
label: 'Chờ xử lý',
color: AppColors.info,
);
factory StatusBadge.orderProcessing() => const StatusBadge(
label: 'Đang xử lý',
color: AppColors.warning,
);
factory StatusBadge.orderShipping() => const StatusBadge(
label: 'Đang giao',
color: AppColors.lightBlue,
);
factory StatusBadge.orderCompleted() => const StatusBadge(
label: 'Hoàn thành',
color: AppColors.success,
);
factory StatusBadge.orderCancelled() => const StatusBadge(
label: 'Đã hủy',
color: AppColors.danger,
);
/// Payment status badges
factory StatusBadge.paymentPending() => const StatusBadge(
label: 'Chờ thanh toán',
color: AppColors.warning,
);
factory StatusBadge.paymentProcessing() => const StatusBadge(
label: 'Đang xử lý',
color: AppColors.info,
);
factory StatusBadge.paymentCompleted() => const StatusBadge(
label: 'Đã thanh toán',
color: AppColors.success,
);
factory StatusBadge.paymentFailed() => const StatusBadge(
label: 'Thất bại',
color: AppColors.danger,
);
/// Project status badges
factory StatusBadge.projectPlanning() => const StatusBadge(
label: 'Lập kế hoạch',
color: AppColors.info,
);
factory StatusBadge.projectInProgress() => const StatusBadge(
label: 'Đang thực hiện',
color: AppColors.warning,
);
factory StatusBadge.projectCompleted() => const StatusBadge(
label: 'Hoàn thành',
color: AppColors.success,
);
factory StatusBadge.projectOnHold() => const StatusBadge(
label: 'Tạm dừng',
color: AppColors.grey500,
);
/// Gift status badges
factory StatusBadge.giftActive() => const StatusBadge(
label: 'Còn hạn',
color: AppColors.success,
);
factory StatusBadge.giftUsed() => const StatusBadge(
label: 'Đã sử dụng',
color: AppColors.grey500,
);
factory StatusBadge.giftExpired() => const StatusBadge(
label: 'Hết hạn',
color: AppColors.danger,
);
/// Member tier badges
factory StatusBadge.tierDiamond() => const StatusBadge(
label: 'Kim Cương',
color: Color(0xFF4A00E0),
);
factory StatusBadge.tierPlatinum() => const StatusBadge(
label: 'Bạch Kim',
color: Color(0xFF7F8C8D),
);
factory StatusBadge.tierGold() => const StatusBadge(
label: 'Vàng',
color: Color(0xFFf7b733),
);
@override
Widget build(BuildContext context) {
return Container(
padding: padding,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(borderRadius),
),
child: Text(
label,
style: TextStyle(
color: textColor ?? Colors.white,
fontSize: fontSize,
fontWeight: fontWeight,
),
),
);
}
}
/// Outlined status badge
class OutlinedStatusBadge extends StatelessWidget {
final String label;
final Color color;
final double borderRadius;
final EdgeInsets padding;
final double fontSize;
final FontWeight fontWeight;
final double borderWidth;
const OutlinedStatusBadge({
super.key,
required this.label,
required this.color,
this.borderRadius = StatusBadgeSpecs.borderRadius,
this.padding = StatusBadgeSpecs.padding,
this.fontSize = StatusBadgeSpecs.fontSize,
this.fontWeight = StatusBadgeSpecs.fontWeight,
this.borderWidth = 1.5,
});
@override
Widget build(BuildContext context) {
return Container(
padding: padding,
decoration: BoxDecoration(
border: Border.all(color: color, width: borderWidth),
borderRadius: BorderRadius.circular(borderRadius),
),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: fontSize,
fontWeight: fontWeight,
),
),
);
}
}
/// Status badge with icon
class IconStatusBadge extends StatelessWidget {
final String label;
final Color color;
final IconData icon;
final Color? textColor;
final double borderRadius;
final EdgeInsets padding;
final double fontSize;
final double iconSize;
const IconStatusBadge({
super.key,
required this.label,
required this.color,
required this.icon,
this.textColor,
this.borderRadius = StatusBadgeSpecs.borderRadius,
this.padding = StatusBadgeSpecs.padding,
this.fontSize = StatusBadgeSpecs.fontSize,
this.iconSize = 14.0,
});
@override
Widget build(BuildContext context) {
return Container(
padding: padding,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(borderRadius),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: iconSize,
color: textColor ?? Colors.white,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: textColor ?? Colors.white,
fontSize: fontSize,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,271 @@
/// Vietnamese Phone Number Input Field
///
/// Specialized input field for Vietnamese phone numbers with
/// auto-formatting and validation.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/utils/validators.dart';
import '../../core/utils/formatters.dart';
import '../../core/constants/ui_constants.dart';
/// Phone number input field with Vietnamese formatting
class VietnamesePhoneField extends StatefulWidget {
final TextEditingController? controller;
final String? labelText;
final String? hintText;
final String? initialValue;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final FormFieldValidator<String>? validator;
final bool enabled;
final bool autoFocus;
final TextInputAction? textInputAction;
final FocusNode? focusNode;
final bool required;
final Widget? prefixIcon;
final Widget? suffixIcon;
const VietnamesePhoneField({
super.key,
this.controller,
this.labelText,
this.hintText,
this.initialValue,
this.onChanged,
this.onSubmitted,
this.validator,
this.enabled = true,
this.autoFocus = false,
this.textInputAction,
this.focusNode,
this.required = true,
this.prefixIcon,
this.suffixIcon,
});
@override
State<VietnamesePhoneField> createState() => _VietnamesePhoneFieldState();
}
class _VietnamesePhoneFieldState extends State<VietnamesePhoneField> {
late TextEditingController _controller;
bool _isControllerInternal = false;
@override
void initState() {
super.initState();
if (widget.controller == null) {
_controller = TextEditingController(text: widget.initialValue);
_isControllerInternal = true;
} else {
_controller = widget.controller!;
}
}
@override
void dispose() {
if (_isControllerInternal) {
_controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: _controller,
focusNode: widget.focusNode,
enabled: widget.enabled,
autofocus: widget.autoFocus,
keyboardType: TextInputType.phone,
textInputAction: widget.textInputAction ?? TextInputAction.next,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(11),
_PhoneNumberFormatter(),
],
decoration: InputDecoration(
labelText: widget.labelText ?? 'Số điện thoại',
hintText: widget.hintText ?? '0xxx xxx xxx',
prefixIcon: widget.prefixIcon ??
const Icon(Icons.phone),
suffixIcon: widget.suffixIcon,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
),
contentPadding: InputFieldSpecs.contentPadding,
),
validator: widget.validator ??
(widget.required ? Validators.phone : Validators.phoneOptional),
onChanged: widget.onChanged,
onFieldSubmitted: widget.onSubmitted,
);
}
}
/// Phone number text input formatter
class _PhoneNumberFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final text = newValue.text;
if (text.isEmpty) {
return newValue;
}
// Format as: 0xxx xxx xxx
String formatted = text;
if (text.length > 4 && text.length <= 7) {
formatted = '${text.substring(0, 4)} ${text.substring(4)}';
} else if (text.length > 7) {
formatted =
'${text.substring(0, 4)} ${text.substring(4, 7)} ${text.substring(7)}';
}
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
/// Read-only phone display field
class PhoneDisplayField extends StatelessWidget {
final String phoneNumber;
final String? labelText;
final Widget? prefixIcon;
final VoidCallback? onTap;
const PhoneDisplayField({
super.key,
required this.phoneNumber,
this.labelText,
this.prefixIcon,
this.onTap,
});
@override
Widget build(BuildContext context) {
return TextFormField(
initialValue: PhoneFormatter.format(phoneNumber),
readOnly: true,
enabled: onTap != null,
onTap: onTap,
decoration: InputDecoration(
labelText: labelText ?? 'Số điện thoại',
prefixIcon: prefixIcon ?? const Icon(Icons.phone),
suffixIcon: onTap != null ? const Icon(Icons.edit) : null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
),
contentPadding: InputFieldSpecs.contentPadding,
),
);
}
}
/// Phone field with country code selector
class InternationalPhoneField extends StatefulWidget {
final TextEditingController? controller;
final String? labelText;
final String? hintText;
final ValueChanged<String>? onChanged;
final FormFieldValidator<String>? validator;
final bool enabled;
final String defaultCountryCode;
const InternationalPhoneField({
super.key,
this.controller,
this.labelText,
this.hintText,
this.onChanged,
this.validator,
this.enabled = true,
this.defaultCountryCode = '+84',
});
@override
State<InternationalPhoneField> createState() =>
_InternationalPhoneFieldState();
}
class _InternationalPhoneFieldState extends State<InternationalPhoneField> {
late TextEditingController _controller;
late String _selectedCountryCode;
bool _isControllerInternal = false;
@override
void initState() {
super.initState();
_selectedCountryCode = widget.defaultCountryCode;
if (widget.controller == null) {
_controller = TextEditingController();
_isControllerInternal = true;
} else {
_controller = widget.controller!;
}
}
@override
void dispose() {
if (_isControllerInternal) {
_controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextFormField(
controller: _controller,
enabled: widget.enabled,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
decoration: InputDecoration(
labelText: widget.labelText ?? 'Số điện thoại',
hintText: widget.hintText ?? 'xxx xxx xxx',
prefixIcon: Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButton<String>(
value: _selectedCountryCode,
underline: const SizedBox(),
items: const [
DropdownMenuItem(value: '+84', child: Text('+84')),
DropdownMenuItem(value: '+1', child: Text('+1')),
DropdownMenuItem(value: '+86', child: Text('+86')),
],
onChanged: widget.enabled
? (value) {
if (value != null) {
setState(() {
_selectedCountryCode = value;
});
widget.onChanged?.call('$value${_controller.text}');
}
}
: null,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
),
contentPadding: InputFieldSpecs.contentPadding,
),
validator: widget.validator,
onChanged: (value) {
widget.onChanged?.call('$_selectedCountryCode$value');
},
);
}
}