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