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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,278 @@
# Localization Extensions - Quick Start Guide
## Using Localization in the Worker App
This file demonstrates how to use the localization utilities in the Worker Flutter app.
## Basic Usage
### 1. Import the Extension
```dart
import 'package:worker/core/utils/l10n_extensions.dart';
```
### 2. Access Translations
```dart
// In any widget with BuildContext
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(context.l10n.home), // "Trang chủ" or "Home"
Text(context.l10n.products), // "Sản phẩm" or "Products"
Text(context.l10n.loyalty), // "Hội viên" or "Loyalty"
],
);
}
}
```
## Helper Functions
### Date and Time
```dart
// Format date
final dateStr = L10nHelper.formatDate(context, DateTime.now());
// Vietnamese: "17/10/2025"
// English: "10/17/2025"
// Format date-time
final dateTimeStr = L10nHelper.formatDateTime(context, DateTime.now());
// Vietnamese: "17/10/2025 lúc 14:30"
// English: "10/17/2025 at 14:30"
// Relative time
final relativeTime = L10nHelper.formatRelativeTime(
context,
DateTime.now().subtract(Duration(minutes: 5)),
);
// Vietnamese: "5 phút trước"
// English: "5 minutes ago"
```
### Currency
```dart
// Format Vietnamese Dong
final price = L10nHelper.formatCurrency(context, 1500000);
// Vietnamese: "1.500.000 ₫"
// English: "1,500,000 ₫"
```
### Status Helpers
```dart
// Get localized order status
final status = L10nHelper.getOrderStatus(context, 'pending');
// Vietnamese: "Chờ xử lý"
// English: "Pending"
// Get localized project status
final projectStatus = L10nHelper.getProjectStatus(context, 'in_progress');
// Vietnamese: "Đang thực hiện"
// English: "In Progress"
// Get localized member tier
final tier = L10nHelper.getMemberTier(context, 'diamond');
// Vietnamese: "Kim cương"
// English: "Diamond"
// Get localized user type
final userType = L10nHelper.getUserType(context, 'contractor');
// Vietnamese: "Thầu thợ"
// English: "Contractor"
```
### Counts with Pluralization
```dart
// Format points with sign
final points = L10nHelper.formatPoints(context, 100);
// Vietnamese: "+100 điểm"
// English: "+100 points"
// Format item count
final items = L10nHelper.formatItemCount(context, 5);
// Vietnamese: "5 sản phẩm"
// English: "5 items"
// Format order count
final orders = L10nHelper.formatOrderCount(context, 3);
// Vietnamese: "3 đơn hàng"
// English: "3 orders"
// Format project count
final projects = L10nHelper.formatProjectCount(context, 2);
// Vietnamese: "2 công trình"
// English: "2 projects"
// Format days remaining
final days = L10nHelper.formatDaysRemaining(context, 7);
// Vietnamese: "Còn 7 ngày"
// English: "7 days left"
```
## Context Extensions
### Language Checks
```dart
// Get current language code
final languageCode = context.languageCode; // "vi" or "en"
// Check if Vietnamese
if (context.isVietnamese) {
// Do something specific for Vietnamese
}
// Check if English
if (context.isEnglish) {
// Do something specific for English
}
```
## Parameterized Translations
```dart
// Simple parameter
final welcome = context.l10n.welcomeTo('Worker App');
// Vietnamese: "Chào mừng đến với Worker App"
// English: "Welcome to Worker App"
// Multiple parameters
final message = context.l10n.pointsToNextTier(500, 'Platinum');
// Vietnamese: "Còn 500 điểm để lên hạng Platinum"
// English: "500 points to reach Platinum"
// Order number
final orderNum = context.l10n.orderNumberIs('ORD-2024-001');
// Vietnamese: "Số đơn hàng: ORD-2024-001"
// English: "Order Number: ORD-2024-001"
// Redeem confirmation
final confirm = context.l10n.redeemConfirmMessage(500, 'Gift Voucher');
// Vietnamese: "Bạn có chắc chắn muốn đổi 500 điểm để nhận Gift Voucher?"
// English: "Are you sure you want to redeem 500 points for Gift Voucher?"
```
## Complete Example
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/core/utils/l10n_extensions.dart';
class OrderDetailPage extends ConsumerWidget {
final Order order;
const OrderDetailPage({required this.order});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.orderDetails),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Order number
Text(
context.l10n.orderNumberIs(order.orderNumber),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
// Order date
Text(
'${context.l10n.orderDate}: ${L10nHelper.formatDate(context, order.createdAt)}',
),
const SizedBox(height: 8),
// Order status
Row(
children: [
Text(context.l10n.orderStatus + ': '),
Chip(
label: Text(
L10nHelper.getOrderStatus(context, order.status),
),
),
],
),
const SizedBox(height: 16),
// Items count
Text(
L10nHelper.formatItemCount(context, order.items.length),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
// Total amount
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.total,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
L10nHelper.formatCurrency(context, order.total),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 24),
// Relative time
Text(
'${context.l10n.orderPlacedAt} ${L10nHelper.formatRelativeTime(context, order.createdAt)}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
}
```
## Best Practices
1. **Always use `context.l10n` instead of `AppLocalizations.of(context)!`**
- Shorter and cleaner
- Consistent throughout the codebase
2. **Use helper functions for formatting**
- `L10nHelper.formatCurrency()` instead of manual formatting
- `L10nHelper.formatDate()` for locale-aware dates
- `L10nHelper.getOrderStatus()` for localized status strings
3. **Check language when needed**
- Use `context.isVietnamese` and `context.isEnglish`
- Useful for conditional rendering or logic
4. **Never hard-code strings**
- Always use translation keys
- Supports both Vietnamese and English automatically
5. **Test both languages**
- Switch device language to test
- Verify text fits in UI for both languages
## See Also
- Full documentation: `/Users/ssg/project/worker/LOCALIZATION.md`
- Vietnamese translations: `/Users/ssg/project/worker/lib/l10n/app_vi.arb`
- English translations: `/Users/ssg/project/worker/lib/l10n/app_en.arb`
- Helper source code: `/Users/ssg/project/worker/lib/core/utils/l10n_extensions.dart`

View File

@@ -0,0 +1,471 @@
/// Dart Extension Methods
///
/// Provides useful extension methods for common data types
/// used throughout the app.
library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
// ============================================================================
// String Extensions
// ============================================================================
extension StringExtensions on String {
/// Check if string is null or empty
bool get isNullOrEmpty => trim().isEmpty;
/// Check if string is not null and not empty
bool get isNotNullOrEmpty => trim().isNotEmpty;
/// Capitalize first letter
String get capitalize {
if (isEmpty) return this;
return '${this[0].toUpperCase()}${substring(1)}';
}
/// Capitalize each word
String get capitalizeWords {
if (isEmpty) return this;
return split(' ').map((word) => word.capitalize).join(' ');
}
/// Convert to title case
String get titleCase => capitalizeWords;
/// Remove all whitespace
String get removeWhitespace => replaceAll(RegExp(r'\s+'), '');
/// Check if string is a valid email
bool get isEmail {
final emailRegex = RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
);
return emailRegex.hasMatch(this);
}
/// Check if string is a valid Vietnamese phone number
bool get isPhoneNumber {
final phoneRegex = RegExp(r'^(0|\+?84)(3|5|7|8|9)[0-9]{8}$');
return phoneRegex.hasMatch(replaceAll(RegExp(r'[^\d+]'), ''));
}
/// Check if string is numeric
bool get isNumeric {
return double.tryParse(this) != null;
}
/// Convert string to int (returns null if invalid)
int? get toIntOrNull => int.tryParse(this);
/// Convert string to double (returns null if invalid)
double? get toDoubleOrNull => double.tryParse(this);
/// Truncate string with ellipsis
String truncate(int maxLength, {String ellipsis = '...'}) {
if (length <= maxLength) return this;
return '${substring(0, maxLength - ellipsis.length)}$ellipsis';
}
/// Remove Vietnamese diacritics
String get removeDiacritics {
const withDiacritics =
'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ';
const withoutDiacritics =
'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd';
var result = toLowerCase();
for (var i = 0; i < withDiacritics.length; i++) {
result = result.replaceAll(withDiacritics[i], withoutDiacritics[i]);
}
return result;
}
/// Convert to URL-friendly slug
String get slugify {
var slug = removeDiacritics;
slug = slug.toLowerCase();
slug = slug.replaceAll(RegExp(r'[^\w\s-]'), '');
slug = slug.replaceAll(RegExp(r'[-\s]+'), '-');
return slug;
}
/// Mask email (e.g., "j***@example.com")
String get maskEmail {
if (!isEmail) return this;
final parts = split('@');
final name = parts[0];
final maskedName = name.length > 2
? '${name[0]}${'*' * (name.length - 1)}'
: name;
return '$maskedName@${parts[1]}';
}
/// Mask phone number (e.g., "0xxx xxx ***")
String get maskPhone {
final cleaned = replaceAll(RegExp(r'\D'), '');
if (cleaned.length < 10) return this;
return '${cleaned.substring(0, 4)} ${cleaned.substring(4, 7)} ***';
}
}
// ============================================================================
// DateTime Extensions
// ============================================================================
extension DateTimeExtensions on DateTime {
/// Check if date is today
bool get isToday {
final now = DateTime.now();
return year == now.year && month == now.month && day == now.day;
}
/// Check if date is yesterday
bool get isYesterday {
final yesterday = DateTime.now().subtract(const Duration(days: 1));
return year == yesterday.year &&
month == yesterday.month &&
day == yesterday.day;
}
/// Check if date is tomorrow
bool get isTomorrow {
final tomorrow = DateTime.now().add(const Duration(days: 1));
return year == tomorrow.year &&
month == tomorrow.month &&
day == tomorrow.day;
}
/// Check if date is in the past
bool get isPast => isBefore(DateTime.now());
/// Check if date is in the future
bool get isFuture => isAfter(DateTime.now());
/// Get start of day (00:00:00)
DateTime get startOfDay => DateTime(year, month, day);
/// Get end of day (23:59:59)
DateTime get endOfDay => DateTime(year, month, day, 23, 59, 59, 999);
/// Get start of month
DateTime get startOfMonth => DateTime(year, month, 1);
/// Get end of month
DateTime get endOfMonth => DateTime(year, month + 1, 0, 23, 59, 59, 999);
/// Get start of year
DateTime get startOfYear => DateTime(year, 1, 1);
/// Get end of year
DateTime get endOfYear => DateTime(year, 12, 31, 23, 59, 59, 999);
/// Add days
DateTime addDays(int days) => add(Duration(days: days));
/// Subtract days
DateTime subtractDays(int days) => subtract(Duration(days: days));
/// Add months
DateTime addMonths(int months) => DateTime(year, month + months, day);
/// Subtract months
DateTime subtractMonths(int months) => DateTime(year, month - months, day);
/// Add years
DateTime addYears(int years) => DateTime(year + years, month, day);
/// Subtract years
DateTime subtractYears(int years) => DateTime(year - years, month, day);
/// Get age in years from this date
int get ageInYears {
final today = DateTime.now();
var age = today.year - year;
if (today.month < month || (today.month == month && today.day < day)) {
age--;
}
return age;
}
/// Get difference in days from now
int get daysFromNow => DateTime.now().difference(this).inDays;
/// Get difference in hours from now
int get hoursFromNow => DateTime.now().difference(this).inHours;
/// Get difference in minutes from now
int get minutesFromNow => DateTime.now().difference(this).inMinutes;
/// Copy with new values
DateTime copyWith({
int? year,
int? month,
int? day,
int? hour,
int? minute,
int? second,
int? millisecond,
int? microsecond,
}) {
return DateTime(
year ?? this.year,
month ?? this.month,
day ?? this.day,
hour ?? this.hour,
minute ?? this.minute,
second ?? this.second,
millisecond ?? this.millisecond,
microsecond ?? this.microsecond,
);
}
}
// ============================================================================
// Duration Extensions
// ============================================================================
extension DurationExtensions on Duration {
/// Format duration as readable string (e.g., "2 giờ 30 phút")
String get formatted {
final hours = inHours;
final minutes = inMinutes.remainder(60);
final seconds = inSeconds.remainder(60);
if (hours > 0) {
if (minutes > 0) {
return '$hours giờ $minutes phút';
}
return '$hours giờ';
} else if (minutes > 0) {
if (seconds > 0) {
return '$minutes phút $seconds giây';
}
return '$minutes phút';
} else {
return '$seconds giây';
}
}
/// Format as HH:MM:SS
String get hhmmss {
final hours = inHours;
final minutes = inMinutes.remainder(60);
final seconds = inSeconds.remainder(60);
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
/// Format as MM:SS
String get mmss {
final minutes = inMinutes.remainder(60);
final seconds = inSeconds.remainder(60);
return '${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
}
// ============================================================================
// List Extensions
// ============================================================================
extension ListExtensions<T> on List<T> {
/// Get first element or null if list is empty
T? get firstOrNull => isEmpty ? null : first;
/// Get last element or null if list is empty
T? get lastOrNull => isEmpty ? null : last;
/// Get element at index or null if out of bounds
T? elementAtOrNull(int index) {
if (index < 0 || index >= length) return null;
return this[index];
}
/// Group list by key
Map<K, List<T>> groupBy<K>(K Function(T) keySelector) {
final map = <K, List<T>>{};
for (final element in this) {
final key = keySelector(element);
if (!map.containsKey(key)) {
map[key] = [];
}
map[key]!.add(element);
}
return map;
}
/// Get distinct elements
List<T> get distinct => toSet().toList();
/// Get distinct elements by key
List<T> distinctBy<K>(K Function(T) keySelector) {
final seen = <K>{};
return where((element) => seen.add(keySelector(element))).toList();
}
/// Chunk list into smaller lists of specified size
List<List<T>> chunk(int size) {
final chunks = <List<T>>[];
for (var i = 0; i < length; i += size) {
chunks.add(sublist(i, (i + size) > length ? length : (i + size)));
}
return chunks;
}
}
// ============================================================================
// Map Extensions
// ============================================================================
extension MapExtensions<K, V> on Map<K, V> {
/// Get value or default if key doesn't exist
V getOrDefault(K key, V defaultValue) {
return containsKey(key) ? this[key] as V : defaultValue;
}
/// Get value or null if key doesn't exist
V? getOrNull(K key) {
return containsKey(key) ? this[key] : null;
}
}
// ============================================================================
// BuildContext Extensions
// ============================================================================
extension BuildContextExtensions on BuildContext {
/// Get screen size
Size get screenSize => MediaQuery.of(this).size;
/// Get screen width
double get screenWidth => MediaQuery.of(this).size.width;
/// Get screen height
double get screenHeight => MediaQuery.of(this).size.height;
/// Check if screen is small (<600dp)
bool get isSmallScreen => MediaQuery.of(this).size.width < 600;
/// Check if screen is medium (600-960dp)
bool get isMediumScreen {
final width = MediaQuery.of(this).size.width;
return width >= 600 && width < 960;
}
/// Check if screen is large (>=960dp)
bool get isLargeScreen => MediaQuery.of(this).size.width >= 960;
/// Get theme
ThemeData get theme => Theme.of(this);
/// Get text theme
TextTheme get textTheme => Theme.of(this).textTheme;
/// Get color scheme
ColorScheme get colorScheme => Theme.of(this).colorScheme;
/// Get primary color
Color get primaryColor => Theme.of(this).primaryColor;
/// Check if dark mode is enabled
bool get isDarkMode => Theme.of(this).brightness == Brightness.dark;
/// Get safe area padding
EdgeInsets get safeAreaPadding => MediaQuery.of(this).padding;
/// Get bottom safe area padding (for devices with notch)
double get bottomSafeArea => MediaQuery.of(this).padding.bottom;
/// Get top safe area padding (for status bar)
double get topSafeArea => MediaQuery.of(this).padding.top;
/// Show snackbar
void showSnackBar(String message, {Duration? duration}) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
content: Text(message),
duration: duration ?? const Duration(seconds: 3),
),
);
}
/// Show error snackbar
void showErrorSnackBar(String message, {Duration? duration}) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: colorScheme.error,
duration: duration ?? const Duration(seconds: 4),
),
);
}
/// Show success snackbar
void showSuccessSnackBar(String message, {Duration? duration}) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.green,
duration: duration ?? const Duration(seconds: 3),
),
);
}
/// Hide keyboard
void hideKeyboard() {
FocusScope.of(this).unfocus();
}
/// Navigate to route
Future<T?> push<T>(Widget page) {
return Navigator.of(this).push<T>(
MaterialPageRoute(builder: (_) => page),
);
}
/// Navigate and replace current route
Future<T?> pushReplacement<T>(Widget page) {
return Navigator.of(this).pushReplacement<T, void>(
MaterialPageRoute(builder: (_) => page),
);
}
/// Pop current route
void pop<T>([T? result]) {
Navigator.of(this).pop(result);
}
/// Pop until first route
void popUntilFirst() {
Navigator.of(this).popUntil((route) => route.isFirst);
}
}
// ============================================================================
// Num Extensions
// ============================================================================
extension NumExtensions on num {
/// Check if number is positive
bool get isPositive => this > 0;
/// Check if number is negative
bool get isNegative => this < 0;
/// Check if number is zero
bool get isZero => this == 0;
/// Clamp number between min and max
num clampTo(num min, num max) => clamp(min, max);
/// Round to specified decimal places
double roundToDecimal(int places) {
final mod = math.pow(10.0, places);
return ((this * mod).round().toDouble() / mod);
}
}

View File

@@ -0,0 +1,371 @@
/// Data Formatters for Vietnamese Locale
///
/// Provides formatting utilities for currency, dates, phone numbers,
/// and other data types commonly used in the app.
library;
import 'package:intl/intl.dart';
/// Currency formatter for Vietnamese Dong (VND)
class CurrencyFormatter {
CurrencyFormatter._();
/// Format amount as Vietnamese currency (e.g., "100,000 ₫")
static String format(double amount, {bool showSymbol = true}) {
final formatter = NumberFormat.currency(
locale: 'vi_VN',
symbol: showSymbol ? '' : '',
decimalDigits: 0,
);
return formatter.format(amount);
}
/// Format amount with custom precision
static String formatWithDecimals(
double amount, {
int decimalDigits = 2,
bool showSymbol = true,
}) {
final formatter = NumberFormat.currency(
locale: 'vi_VN',
symbol: showSymbol ? '' : '',
decimalDigits: decimalDigits,
);
return formatter.format(amount);
}
/// Format as compact currency (e.g., "1.5M ₫")
static String formatCompact(double amount, {bool showSymbol = true}) {
final formatter = NumberFormat.compactCurrency(
locale: 'vi_VN',
symbol: showSymbol ? '' : '',
decimalDigits: 1,
);
return formatter.format(amount);
}
/// Parse currency string to double
static double? parse(String value) {
try {
// Remove currency symbol and spaces
final cleaned = value.replaceAll(RegExp(r'[₫\s,]'), '');
return double.tryParse(cleaned);
} catch (e) {
return null;
}
}
}
/// Date and time formatter
class DateFormatter {
DateFormatter._();
/// Format date as "dd/MM/yyyy" (Vietnamese format)
static String formatDate(DateTime date) {
final formatter = DateFormat('dd/MM/yyyy', 'vi_VN');
return formatter.format(date);
}
/// Format date as "dd-MM-yyyy"
static String formatDateDash(DateTime date) {
final formatter = DateFormat('dd-MM-yyyy', 'vi_VN');
return formatter.format(date);
}
/// Format time as "HH:mm"
static String formatTime(DateTime date) {
final formatter = DateFormat('HH:mm', 'vi_VN');
return formatter.format(date);
}
/// Format date and time as "dd/MM/yyyy HH:mm"
static String formatDateTime(DateTime date) {
final formatter = DateFormat('dd/MM/yyyy HH:mm', 'vi_VN');
return formatter.format(date);
}
/// Format date as "dd/MM/yyyy lúc HH:mm"
static String formatDateTimeVN(DateTime date) {
final formatter = DateFormat('dd/MM/yyyy', 'vi_VN');
final timeFormatter = DateFormat('HH:mm', 'vi_VN');
return '${formatter.format(date)} lúc ${timeFormatter.format(date)}';
}
/// Format as relative time (e.g., "2 giờ trước")
static String formatRelative(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inSeconds < 60) {
return 'Vừa xong';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes} phút trước';
} else if (difference.inHours < 24) {
return '${difference.inHours} giờ trước';
} else if (difference.inDays < 7) {
return '${difference.inDays} ngày trước';
} else if (difference.inDays < 30) {
final weeks = (difference.inDays / 7).floor();
return '$weeks tuần trước';
} else if (difference.inDays < 365) {
final months = (difference.inDays / 30).floor();
return '$months tháng trước';
} else {
final years = (difference.inDays / 365).floor();
return '$years năm trước';
}
}
/// Format as day of week (e.g., "Thứ Hai")
static String formatDayOfWeek(DateTime date) {
final formatter = DateFormat('EEEE', 'vi_VN');
return formatter.format(date);
}
/// Format as month and year (e.g., "Tháng 10 năm 2024")
static String formatMonthYear(DateTime date) {
final formatter = DateFormat('MMMM yyyy', 'vi_VN');
return formatter.format(date);
}
/// Format as full date with day of week (e.g., "Thứ Hai, 17/10/2024")
static String formatFullDate(DateTime date) {
final dayOfWeek = formatDayOfWeek(date);
final dateStr = formatDate(date);
return '$dayOfWeek, $dateStr';
}
/// Parse date string in format "dd/MM/yyyy"
static DateTime? parseDate(String dateStr) {
try {
final formatter = DateFormat('dd/MM/yyyy');
return formatter.parse(dateStr);
} catch (e) {
return null;
}
}
/// Parse datetime string in format "dd/MM/yyyy HH:mm"
static DateTime? parseDateTime(String dateTimeStr) {
try {
final formatter = DateFormat('dd/MM/yyyy HH:mm');
return formatter.parse(dateTimeStr);
} catch (e) {
return null;
}
}
}
/// Phone number formatter for Vietnamese phone numbers
class PhoneFormatter {
PhoneFormatter._();
/// Format phone number as "(0xxx) xxx xxx"
static String format(String phone) {
// Remove all non-digit characters
final cleaned = phone.replaceAll(RegExp(r'\D'), '');
if (cleaned.isEmpty) return '';
// Handle Vietnamese phone number formats
if (cleaned.startsWith('84')) {
// +84 format
final local = cleaned.substring(2);
if (local.length >= 9) {
return '(+84${local.substring(0, 2)}) ${local.substring(2, 5)} ${local.substring(5)}';
}
} else if (cleaned.startsWith('0')) {
// 0xxx format
if (cleaned.length >= 10) {
return '(${cleaned.substring(0, 4)}) ${cleaned.substring(4, 7)} ${cleaned.substring(7)}';
}
}
return phone; // Return original if format doesn't match
}
/// Format as international number (+84xxx xxx xxx)
static String formatInternational(String phone) {
final cleaned = phone.replaceAll(RegExp(r'\D'), '');
if (cleaned.isEmpty) return '';
if (cleaned.startsWith('0')) {
// Convert 0xxx to +84xxx
final local = cleaned.substring(1);
if (local.length >= 9) {
return '+84${local.substring(0, 2)} ${local.substring(2, 5)} ${local.substring(5)}';
}
} else if (cleaned.startsWith('84')) {
final local = cleaned.substring(2);
if (local.length >= 9) {
return '+84${local.substring(0, 2)} ${local.substring(2, 5)} ${local.substring(5)}';
}
}
return phone;
}
/// Remove formatting to get clean phone number
static String clean(String phone) {
return phone.replaceAll(RegExp(r'\D'), '');
}
/// Convert to E.164 format (+84xxxxxxxxx)
static String toE164(String phone) {
final cleaned = clean(phone);
if (cleaned.startsWith('0')) {
return '+84${cleaned.substring(1)}';
} else if (cleaned.startsWith('84')) {
return '+$cleaned';
} else if (cleaned.startsWith('+84')) {
return cleaned;
}
return '+84$cleaned';
}
/// Mask phone number (e.g., "0xxx xxx ***")
static String mask(String phone) {
final cleaned = clean(phone);
if (cleaned.length >= 10) {
return '${cleaned.substring(0, 4)} ${cleaned.substring(4, 7)} ***';
}
return phone;
}
}
/// Number formatter
class NumberFormatter {
NumberFormatter._();
/// Format number with thousand separators
static String format(num number, {int decimalDigits = 0}) {
final formatter = NumberFormat('#,###', 'vi_VN');
if (decimalDigits > 0) {
return formatter.format(number);
}
return formatter.format(number.round());
}
/// Format as percentage
static String formatPercentage(
double value, {
int decimalDigits = 0,
bool showSymbol = true,
}) {
final formatter = NumberFormat.percentPattern('vi_VN');
formatter.maximumFractionDigits = decimalDigits;
formatter.minimumFractionDigits = decimalDigits;
final result = formatter.format(value / 100);
return showSymbol ? result : result.replaceAll('%', '');
}
/// Format as compact number (e.g., "1.5K")
static String formatCompact(num number) {
final formatter = NumberFormat.compact(locale: 'vi_VN');
return formatter.format(number);
}
/// Format file size
static String formatBytes(int bytes, {int decimals = 2}) {
if (bytes <= 0) return '0 B';
const suffixes = ['B', 'KB', 'MB', 'GB', 'TB'];
final i = (bytes.bitLength - 1) ~/ 10;
final value = bytes / (1 << (i * 10));
return '${value.toStringAsFixed(decimals)} ${suffixes[i]}';
}
/// Format duration (e.g., "1:30:45")
static String formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} else {
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
}
/// Text formatter utilities
class TextFormatter {
TextFormatter._();
/// Capitalize first letter
static String capitalize(String text) {
if (text.isEmpty) return text;
return text[0].toUpperCase() + text.substring(1);
}
/// Capitalize each word
static String capitalizeWords(String text) {
if (text.isEmpty) return text;
return text.split(' ').map((word) => capitalize(word)).join(' ');
}
/// Truncate text with ellipsis
static String truncate(String text, int maxLength, {String ellipsis = '...'}) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - ellipsis.length) + ellipsis;
}
/// Remove diacritics from Vietnamese text
static String removeDiacritics(String text) {
const withDiacritics = 'àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ';
const withoutDiacritics = 'aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiioooooooooooooooooouuuuuuuuuuuyyyyyd';
var result = text.toLowerCase();
for (var i = 0; i < withDiacritics.length; i++) {
result = result.replaceAll(withDiacritics[i], withoutDiacritics[i]);
}
return result;
}
/// Create URL-friendly slug
static String slugify(String text) {
var slug = removeDiacritics(text);
slug = slug.toLowerCase();
slug = slug.replaceAll(RegExp(r'[^\w\s-]'), '');
slug = slug.replaceAll(RegExp(r'[-\s]+'), '-');
return slug;
}
}
/// Loyalty tier formatter
class LoyaltyFormatter {
LoyaltyFormatter._();
/// Format tier name in Vietnamese
static String formatTier(String tier) {
switch (tier.toLowerCase()) {
case 'diamond':
return 'Kim Cương';
case 'platinum':
return 'Bạch Kim';
case 'gold':
return 'Vàng';
default:
return tier;
}
}
/// Format points with label
static String formatPoints(int points) {
return '${NumberFormatter.format(points)} điểm';
}
/// Format points progress (e.g., "1,200 / 5,000 điểm")
static String formatPointsProgress(int current, int target) {
return '${NumberFormatter.format(current)} / ${NumberFormatter.format(target)} điểm';
}
}

View File

@@ -0,0 +1,274 @@
import 'package:flutter/widgets.dart';
import 'package:worker/generated/l10n/app_localizations.dart';
/// Extension for easy access to AppLocalizations
///
/// This extension provides convenient access to localization strings
/// throughout the app without having to write `AppLocalizations.of(context)!`
/// every time.
///
/// Usage:
/// ```dart
/// // Instead of:
/// Text(AppLocalizations.of(context)!.login)
///
/// // You can use:
/// Text(context.l10n.login)
/// ```
extension L10nExtension on BuildContext {
/// Get the current AppLocalizations instance
///
/// This getter provides quick access to all localized strings.
/// It will throw an error if called before the app is initialized,
/// which helps catch localization issues during development.
AppLocalizations get l10n => AppLocalizations.of(this)!;
/// Get the current locale language code (e.g., 'vi', 'en')
String get languageCode => Localizations.localeOf(this).languageCode;
/// Check if the current locale is Vietnamese
bool get isVietnamese => languageCode == 'vi';
/// Check if the current locale is English
bool get isEnglish => languageCode == 'en';
}
/// Helper class for common localization patterns
///
/// This class provides utility methods for formatting dates, times,
/// currencies, and other locale-specific data.
class L10nHelper {
const L10nHelper._();
/// Format a DateTime to localized date string (DD/MM/YYYY for Vietnamese, MM/DD/YYYY for English)
///
/// Example:
/// ```dart
/// final dateStr = L10nHelper.formatDate(context, DateTime.now());
/// // Vietnamese: "17/10/2025"
/// // English: "10/17/2025"
/// ```
static String formatDate(BuildContext context, DateTime date) {
final day = date.day.toString().padLeft(2, '0');
final month = date.month.toString().padLeft(2, '0');
final year = date.year.toString();
return context.l10n.formatDate(day, month, year);
}
/// Format a DateTime to localized date-time string
///
/// Example:
/// ```dart
/// final dateTimeStr = L10nHelper.formatDateTime(context, DateTime.now());
/// // Vietnamese: "17/10/2025 lúc 14:30"
/// // English: "10/17/2025 at 14:30"
/// ```
static String formatDateTime(BuildContext context, DateTime dateTime) {
final day = dateTime.day.toString().padLeft(2, '0');
final month = dateTime.month.toString().padLeft(2, '0');
final year = dateTime.year.toString();
final hour = dateTime.hour.toString().padLeft(2, '0');
final minute = dateTime.minute.toString().padLeft(2, '0');
return context.l10n.formatDateTime(day, month, year, hour, minute);
}
/// Format a number as Vietnamese Dong currency
///
/// Example:
/// ```dart
/// final price = L10nHelper.formatCurrency(context, 1500000);
/// // Returns: "1.500.000 ₫"
/// ```
static String formatCurrency(BuildContext context, double amount) {
final formatted = context.isVietnamese
? _formatNumberVietnamese(amount)
: _formatNumberEnglish(amount);
return context.l10n.formatCurrency(formatted);
}
/// Format number with Vietnamese grouping (dots)
static String _formatNumberVietnamese(double number) {
final parts = number.toStringAsFixed(0).split('.');
final intPart = parts[0];
// Add dots every 3 digits from right
final buffer = StringBuffer();
for (var i = 0; i < intPart.length; i++) {
if (i > 0 && (intPart.length - i) % 3 == 0) {
buffer.write('.');
}
buffer.write(intPart[i]);
}
return buffer.toString();
}
/// Format number with English grouping (commas)
static String _formatNumberEnglish(double number) {
final parts = number.toStringAsFixed(0).split('.');
final intPart = parts[0];
// Add commas every 3 digits from right
final buffer = StringBuffer();
for (var i = 0; i < intPart.length; i++) {
if (i > 0 && (intPart.length - i) % 3 == 0) {
buffer.write(',');
}
buffer.write(intPart[i]);
}
return buffer.toString();
}
/// Format a relative time (e.g., "5 minutes ago", "2 days ago")
///
/// Example:
/// ```dart
/// final relativeTime = L10nHelper.formatRelativeTime(
/// context,
/// DateTime.now().subtract(Duration(minutes: 5)),
/// );
/// // Returns: "5 phút trước" (Vietnamese) or "5 minutes ago" (English)
/// ```
static String formatRelativeTime(BuildContext context, DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inSeconds < 60) {
return context.l10n.justNow;
} else if (difference.inMinutes < 60) {
return context.l10n.minutesAgo(difference.inMinutes);
} else if (difference.inHours < 24) {
return context.l10n.hoursAgo(difference.inHours);
} else if (difference.inDays < 7) {
return context.l10n.daysAgo(difference.inDays);
} else if (difference.inDays < 30) {
return context.l10n.weeksAgo((difference.inDays / 7).floor());
} else if (difference.inDays < 365) {
return context.l10n.monthsAgo((difference.inDays / 30).floor());
} else {
return context.l10n.yearsAgo((difference.inDays / 365).floor());
}
}
/// Get localized order status string
static String getOrderStatus(BuildContext context, String status) {
switch (status.toLowerCase()) {
case 'pending':
return context.l10n.pending;
case 'processing':
return context.l10n.processing;
case 'shipping':
return context.l10n.shipping;
case 'completed':
return context.l10n.completed;
case 'cancelled':
return context.l10n.cancelled;
default:
return status;
}
}
/// Get localized project status string
static String getProjectStatus(BuildContext context, String status) {
switch (status.toLowerCase()) {
case 'planning':
return context.l10n.planningProjects;
case 'in_progress':
case 'inprogress':
return context.l10n.inProgressProjects;
case 'completed':
return context.l10n.completedProjects;
default:
return status;
}
}
/// Get localized member tier string
static String getMemberTier(BuildContext context, String tier) {
switch (tier.toLowerCase()) {
case 'diamond':
return context.l10n.diamond;
case 'platinum':
return context.l10n.platinum;
case 'gold':
return context.l10n.gold;
default:
return tier;
}
}
/// Get localized user type string
static String getUserType(BuildContext context, String userType) {
switch (userType.toLowerCase()) {
case 'contractor':
return context.l10n.contractor;
case 'architect':
return context.l10n.architect;
case 'distributor':
return context.l10n.distributor;
case 'broker':
return context.l10n.broker;
default:
return userType;
}
}
/// Get localized password strength string
static String getPasswordStrength(BuildContext context, String strength) {
switch (strength.toLowerCase()) {
case 'weak':
return context.l10n.weak;
case 'medium':
return context.l10n.medium;
case 'strong':
return context.l10n.strong;
case 'very_strong':
case 'verystrong':
return context.l10n.veryStrong;
default:
return strength;
}
}
/// Format points with proper pluralization
///
/// Example:
/// ```dart
/// final pointsText = L10nHelper.formatPoints(context, 100);
/// // Returns: "+100 điểm" (Vietnamese) or "+100 points" (English)
/// ```
static String formatPoints(BuildContext context, int points,
{bool showSign = true}) {
if (showSign && points > 0) {
return context.l10n.earnedPoints(points);
} else if (showSign && points < 0) {
return context.l10n.spentPoints(points.abs());
} else {
return context.l10n.pointsBalance(points);
}
}
/// Format item count with pluralization
static String formatItemCount(BuildContext context, int count) {
return context.l10n.itemsInCart(count);
}
/// Format order count with pluralization
static String formatOrderCount(BuildContext context, int count) {
return context.l10n.ordersCount(count);
}
/// Format project count with pluralization
static String formatProjectCount(BuildContext context, int count) {
return context.l10n.projectsCount(count);
}
/// Format days remaining with pluralization
static String formatDaysRemaining(BuildContext context, int days) {
return context.l10n.daysRemaining(days);
}
}

View File

@@ -0,0 +1,136 @@
import 'package:flutter/widgets.dart';
import 'package:worker/generated/l10n/app_localizations.dart';
/// Extension on [BuildContext] for easy access to localizations
///
/// Usage:
/// ```dart
/// Text(context.l10n.login)
/// ```
///
/// This provides a shorter and more convenient way to access localized strings
/// compared to the verbose `AppLocalizations.of(context)!` syntax.
extension LocalizationExtension on BuildContext {
/// Get the current app localizations
///
/// Returns the [AppLocalizations] instance for the current context.
/// This will never be null because the app always has a default locale.
AppLocalizations get l10n => AppLocalizations.of(this);
}
/// Extension on [AppLocalizations] for additional formatting utilities
extension LocalizationUtilities on AppLocalizations {
/// Format currency in Vietnamese Dong
///
/// Example: 100000 -> "100.000 ₫"
String formatCurrency(double amount) {
final formatter = _getCurrencyFormatter();
return formatter.format(amount);
}
/// Format points display with formatted number
///
/// Example: 1500 -> "1.500 điểm" or "1,500 points"
String formatPointsDisplay(int points) {
// Use the generated method which already handles the formatting
return pointsBalance(points);
}
/// Format large numbers with thousand separators
///
/// Example: 1000000 -> "1.000.000"
String formatNumber(num number) {
return _formatNumber(number);
}
/// Get currency formatter based on locale
_CurrencyFormatter _getCurrencyFormatter() {
if (localeName.startsWith('vi')) {
return const _VietnameseCurrencyFormatter();
} else {
return const _EnglishCurrencyFormatter();
}
}
/// Format number with thousand separators
String _formatNumber(num number) {
final parts = number.toString().split('.');
final integerPart = parts[0];
final decimalPart = parts.length > 1 ? parts[1] : '';
// Add thousand separators
final buffer = StringBuffer();
final reversedInteger = integerPart.split('').reversed.join();
for (var i = 0; i < reversedInteger.length; i++) {
if (i > 0 && i % 3 == 0) {
buffer.write(localeName.startsWith('vi') ? '.' : ',');
}
buffer.write(reversedInteger[i]);
}
final formattedInteger = buffer.toString().split('').reversed.join();
if (decimalPart.isNotEmpty) {
return '$formattedInteger.$decimalPart';
}
return formattedInteger;
}
}
/// Abstract currency formatter
abstract class _CurrencyFormatter {
const _CurrencyFormatter();
String format(double amount);
}
/// Vietnamese currency formatter
///
/// Format: 100.000 ₫
class _VietnameseCurrencyFormatter extends _CurrencyFormatter {
const _VietnameseCurrencyFormatter();
@override
String format(double amount) {
final rounded = amount.round();
final parts = rounded.toString().split('').reversed.join();
final buffer = StringBuffer();
for (var i = 0; i < parts.length; i++) {
if (i > 0 && i % 3 == 0) {
buffer.write('.');
}
buffer.write(parts[i]);
}
final formatted = buffer.toString().split('').reversed.join();
return '$formatted';
}
}
/// English currency formatter
///
/// Format: ₫100,000
class _EnglishCurrencyFormatter extends _CurrencyFormatter {
const _EnglishCurrencyFormatter();
@override
String format(double amount) {
final rounded = amount.round();
final parts = rounded.toString().split('').reversed.join();
final buffer = StringBuffer();
for (var i = 0; i < parts.length; i++) {
if (i > 0 && i % 3 == 0) {
buffer.write(',');
}
buffer.write(parts[i]);
}
final formatted = buffer.toString().split('').reversed.join();
return '$formatted';
}
}

View File

@@ -0,0 +1,308 @@
/// QR Code Generator Utility
///
/// Provides QR code generation functionality for member cards,
/// referral codes, and other QR code use cases.
library;
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
/// QR Code Generator
class QRGenerator {
QRGenerator._();
/// Generate QR code widget for member ID
///
/// Used in member cards to display user's member ID as QR code
static Widget generateMemberQR({
required String memberId,
double size = 80.0,
Color? foregroundColor,
Color? backgroundColor,
int version = QrVersions.auto,
int errorCorrectionLevel = QrErrorCorrectLevel.M,
}) {
return QrImageView(
data: 'MEMBER:$memberId',
version: version,
size: size,
errorCorrectionLevel: errorCorrectionLevel,
backgroundColor: backgroundColor ?? Colors.white,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: foregroundColor ?? Colors.black,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: foregroundColor ?? Colors.black,
),
padding: EdgeInsets.zero,
gapless: true,
);
}
/// Generate QR code widget for referral code
///
/// Used to share referral codes via QR scanning
static Widget generateReferralQR({
required String referralCode,
double size = 200.0,
Color? foregroundColor,
Color? backgroundColor,
}) {
return QrImageView(
data: 'REFERRAL:$referralCode',
version: QrVersions.auto,
size: size,
errorCorrectionLevel: QrErrorCorrectLevel.H,
backgroundColor: backgroundColor ?? Colors.white,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: foregroundColor ?? Colors.black,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: foregroundColor ?? Colors.black,
),
padding: const EdgeInsets.all(16),
gapless: true,
embeddedImageStyle: const QrEmbeddedImageStyle(
size: Size(48, 48),
),
);
}
/// Generate QR code widget for order tracking
///
/// Used to display order number as QR code for easy tracking
static Widget generateOrderQR({
required String orderNumber,
double size = 150.0,
Color? foregroundColor,
Color? backgroundColor,
}) {
return QrImageView(
data: 'ORDER:$orderNumber',
version: QrVersions.auto,
size: size,
errorCorrectionLevel: QrErrorCorrectLevel.M,
backgroundColor: backgroundColor ?? Colors.white,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: foregroundColor ?? Colors.black,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: foregroundColor ?? Colors.black,
),
padding: const EdgeInsets.all(12),
gapless: true,
);
}
/// Generate QR code widget for product info
///
/// Used to encode product SKU or URL for quick access
static Widget generateProductQR({
required String productId,
double size = 120.0,
Color? foregroundColor,
Color? backgroundColor,
}) {
return QrImageView(
data: 'PRODUCT:$productId',
version: QrVersions.auto,
size: size,
errorCorrectionLevel: QrErrorCorrectLevel.M,
backgroundColor: backgroundColor ?? Colors.white,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: foregroundColor ?? Colors.black,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: foregroundColor ?? Colors.black,
),
padding: const EdgeInsets.all(10),
gapless: true,
);
}
/// Generate QR code widget with custom data
///
/// Generic QR code generator for any string data
static Widget generateCustomQR({
required String data,
double size = 200.0,
Color? foregroundColor,
Color? backgroundColor,
int errorCorrectionLevel = QrErrorCorrectLevel.M,
EdgeInsets padding = const EdgeInsets.all(16),
bool gapless = true,
}) {
return QrImageView(
data: data,
version: QrVersions.auto,
size: size,
errorCorrectionLevel: errorCorrectionLevel,
backgroundColor: backgroundColor ?? Colors.white,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: foregroundColor ?? Colors.black,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: foregroundColor ?? Colors.black,
),
padding: padding,
gapless: gapless,
);
}
/// Generate QR code widget with embedded logo
///
/// Used for branded QR codes with app logo in center
static Widget generateQRWithLogo({
required String data,
required Widget embeddedImage,
double size = 250.0,
Color? foregroundColor,
Color? backgroundColor,
Size embeddedImageSize = const Size(64, 64),
}) {
return QrImageView(
data: data,
version: QrVersions.auto,
size: size,
errorCorrectionLevel: QrErrorCorrectLevel.H, // High correction for logo
backgroundColor: backgroundColor ?? Colors.white,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: foregroundColor ?? Colors.black,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: foregroundColor ?? Colors.black,
),
padding: const EdgeInsets.all(20),
gapless: true,
embeddedImage: embeddedImage is AssetImage
? (embeddedImage as AssetImage).assetName as ImageProvider
: null,
embeddedImageStyle: QrEmbeddedImageStyle(
size: embeddedImageSize,
),
);
}
/// Parse QR code data and extract type and value
///
/// Returns a map with 'type' and 'value' keys
static Map<String, String>? parseQRData(String data) {
try {
if (data.contains(':')) {
final parts = data.split(':');
if (parts.length == 2) {
return {
'type': parts[0].toUpperCase(),
'value': parts[1],
};
}
}
// If no type prefix, return as generic data
return {
'type': 'GENERIC',
'value': data,
};
} catch (e) {
return null;
}
}
/// Validate QR code data format
static bool isValidQRData(String data, {String? expectedType}) {
if (data.isEmpty) return false;
final parsed = parseQRData(data);
if (parsed == null) return false;
if (expectedType != null) {
return parsed['type'] == expectedType.toUpperCase();
}
return true;
}
/// Generate QR data string with type prefix
static String generateQRData(String type, String value) {
return '${type.toUpperCase()}:$value';
}
}
/// QR Code Types
class QRCodeType {
QRCodeType._();
static const String member = 'MEMBER';
static const String referral = 'REFERRAL';
static const String order = 'ORDER';
static const String product = 'PRODUCT';
static const String payment = 'PAYMENT';
static const String url = 'URL';
static const String generic = 'GENERIC';
}
/// QR Code Scanner Result
class QRScanResult {
final String type;
final String value;
final String rawData;
const QRScanResult({
required this.type,
required this.value,
required this.rawData,
});
/// Check if scan result is of expected type
bool isType(String expectedType) {
return type.toUpperCase() == expectedType.toUpperCase();
}
/// Check if result is a member QR code
bool get isMember => isType(QRCodeType.member);
/// Check if result is a referral QR code
bool get isReferral => isType(QRCodeType.referral);
/// Check if result is an order QR code
bool get isOrder => isType(QRCodeType.order);
/// Check if result is a product QR code
bool get isProduct => isType(QRCodeType.product);
/// Check if result is a URL QR code
bool get isUrl => isType(QRCodeType.url);
factory QRScanResult.fromRawData(String rawData) {
final parsed = QRGenerator.parseQRData(rawData);
if (parsed != null) {
return QRScanResult(
type: parsed['type']!,
value: parsed['value']!,
rawData: rawData,
);
}
return QRScanResult(
type: QRCodeType.generic,
value: rawData,
rawData: rawData,
);
}
@override
String toString() => 'QRScanResult(type: $type, value: $value)';
}

View File

@@ -0,0 +1,540 @@
/// Form Validators for Vietnamese Locale
///
/// Provides validation utilities for forms with Vietnamese-specific
/// validations for phone numbers, email, passwords, etc.
library;
/// Form field validators
class Validators {
Validators._();
// ========================================================================
// Required Field Validators
// ========================================================================
/// Validate required field
static String? required(String? value, {String? fieldName}) {
if (value == null || value.trim().isEmpty) {
return fieldName != null
? '$fieldName là bắt buộc'
: 'Trường này là bắt buộc';
}
return null;
}
// ========================================================================
// Phone Number Validators
// ========================================================================
/// Validate Vietnamese phone number
///
/// Accepts formats:
/// - 0xxx xxx xxx (10 digits starting with 0)
/// - +84xxx xxx xxx (starts with +84)
/// - 84xxx xxx xxx (starts with 84)
static String? phone(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập số điện thoại';
}
final cleaned = value.replaceAll(RegExp(r'\D'), '');
// Check if starts with valid Vietnamese mobile prefix
final vietnamesePattern = RegExp(r'^(0|\+?84)(3|5|7|8|9)[0-9]{8}$');
if (!vietnamesePattern.hasMatch(value.replaceAll(RegExp(r'[^\d+]'), ''))) {
return 'Số điện thoại không hợp lệ';
}
if (cleaned.length < 10 || cleaned.length > 11) {
return 'Số điện thoại phải có 10 chữ số';
}
return null;
}
/// Validate phone number (optional)
static String? phoneOptional(String? value) {
if (value == null || value.trim().isEmpty) {
return null; // Optional, so null is valid
}
return phone(value);
}
// ========================================================================
// Email Validators
// ========================================================================
/// Validate email address
static String? email(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập email';
}
final emailRegex = RegExp(
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
);
if (!emailRegex.hasMatch(value)) {
return 'Email không hợp lệ';
}
return null;
}
/// Validate email (optional)
static String? emailOptional(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
return email(value);
}
// ========================================================================
// Password Validators
// ========================================================================
/// Validate password strength
///
/// Requirements:
/// - At least 8 characters
/// - At least 1 uppercase letter
/// - At least 1 lowercase letter
/// - At least 1 number
/// - At least 1 special character
static String? password(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập mật khẩu';
}
if (value.length < 8) {
return 'Mật khẩu phải có ít nhất 8 ký tự';
}
if (!RegExp(r'[A-Z]').hasMatch(value)) {
return 'Mật khẩu phải có ít nhất 1 chữ hoa';
}
if (!RegExp(r'[a-z]').hasMatch(value)) {
return 'Mật khẩu phải có ít nhất 1 chữ thường';
}
if (!RegExp(r'[0-9]').hasMatch(value)) {
return 'Mật khẩu phải có ít nhất 1 số';
}
if (!RegExp(r'[!@#$%^&*(),.?":{}|<>]').hasMatch(value)) {
return 'Mật khẩu phải có ít nhất 1 ký tự đặc biệt';
}
return null;
}
/// Validate password confirmation
static String? confirmPassword(String? value, String? password) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng xác nhận mật khẩu';
}
if (value != password) {
return 'Mật khẩu không khớp';
}
return null;
}
/// Simple password validator (minimum length only)
static String? passwordSimple(String? value, {int minLength = 6}) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập mật khẩu';
}
if (value.length < minLength) {
return 'Mật khẩu phải có ít nhất $minLength ký tự';
}
return null;
}
// ========================================================================
// OTP Validators
// ========================================================================
/// Validate OTP code
static String? otp(String? value, {int length = 6}) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập mã OTP';
}
if (value.length != length) {
return 'Mã OTP phải có $length chữ số';
}
if (!RegExp(r'^[0-9]+$').hasMatch(value)) {
return 'Mã OTP chỉ được chứa số';
}
return null;
}
// ========================================================================
// Text Length Validators
// ========================================================================
/// Validate minimum length
static String? minLength(String? value, int min, {String? fieldName}) {
if (value == null || value.trim().isEmpty) {
return fieldName != null
? '$fieldName là bắt buộc'
: 'Trường này là bắt buộc';
}
if (value.length < min) {
return fieldName != null
? '$fieldName phải có ít nhất $min ký tự'
: 'Phải có ít nhất $min ký tự';
}
return null;
}
/// Validate maximum length
static String? maxLength(String? value, int max, {String? fieldName}) {
if (value != null && value.length > max) {
return fieldName != null
? '$fieldName không được vượt quá $max ký tự'
: 'Không được vượt quá $max ký tự';
}
return null;
}
/// Validate length range
static String? lengthRange(
String? value,
int min,
int max, {
String? fieldName,
}) {
if (value == null || value.trim().isEmpty) {
return fieldName != null
? '$fieldName là bắt buộc'
: 'Trường này là bắt buộc';
}
if (value.length < min || value.length > max) {
return fieldName != null
? '$fieldName phải có từ $min đến $max ký tự'
: 'Phải có từ $min đến $max ký tự';
}
return null;
}
// ========================================================================
// Number Validators
// ========================================================================
/// Validate number
static String? number(String? value, {String? fieldName}) {
if (value == null || value.trim().isEmpty) {
return fieldName != null
? '$fieldName là bắt buộc'
: 'Trường này là bắt buộc';
}
if (double.tryParse(value) == null) {
return fieldName != null
? '$fieldName phải là số'
: 'Giá trị phải là số';
}
return null;
}
/// Validate integer
static String? integer(String? value, {String? fieldName}) {
if (value == null || value.trim().isEmpty) {
return fieldName != null
? '$fieldName là bắt buộc'
: 'Trường này là bắt buộc';
}
if (int.tryParse(value) == null) {
return fieldName != null
? '$fieldName phải là số nguyên'
: 'Giá trị phải là số nguyên';
}
return null;
}
/// Validate positive number
static String? positiveNumber(String? value, {String? fieldName}) {
final numberError = number(value, fieldName: fieldName);
if (numberError != null) return numberError;
final num = double.parse(value!);
if (num <= 0) {
return fieldName != null
? '$fieldName phải lớn hơn 0'
: 'Giá trị phải lớn hơn 0';
}
return null;
}
/// Validate number range
static String? numberRange(
String? value,
double min,
double max, {
String? fieldName,
}) {
final numberError = number(value, fieldName: fieldName);
if (numberError != null) return numberError;
final num = double.parse(value!);
if (num < min || num > max) {
return fieldName != null
? '$fieldName phải từ $min đến $max'
: 'Giá trị phải từ $min đến $max';
}
return null;
}
// ========================================================================
// Date Validators
// ========================================================================
/// Validate date format (dd/MM/yyyy)
static String? date(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập ngày';
}
final dateRegex = RegExp(r'^\d{2}/\d{2}/\d{4}$');
if (!dateRegex.hasMatch(value)) {
return 'Định dạng ngày không hợp lệ (dd/MM/yyyy)';
}
try {
final parts = value.split('/');
final day = int.parse(parts[0]);
final month = int.parse(parts[1]);
final year = int.parse(parts[2]);
final date = DateTime(year, month, day);
if (date.day != day || date.month != month || date.year != year) {
return 'Ngày không hợp lệ';
}
} catch (e) {
return 'Ngày không hợp lệ';
}
return null;
}
/// Validate age (must be at least 18 years old)
static String? age(String? value, {int minAge = 18}) {
final dateError = date(value);
if (dateError != null) return dateError;
try {
final parts = value!.split('/');
final birthDate = DateTime(
int.parse(parts[2]),
int.parse(parts[1]),
int.parse(parts[0]),
);
final today = DateTime.now();
final age = today.year -
birthDate.year -
(today.month > birthDate.month ||
(today.month == birthDate.month && today.day >= birthDate.day)
? 0
: 1);
if (age < minAge) {
return 'Bạn phải từ $minAge tuổi trở lên';
}
return null;
} catch (e) {
return 'Ngày sinh không hợp lệ';
}
}
// ========================================================================
// Address Validators
// ========================================================================
/// Validate Vietnamese address
static String? address(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập địa chỉ';
}
if (value.length < 10) {
return 'Địa chỉ quá ngắn';
}
return null;
}
// ========================================================================
// Tax ID Validators
// ========================================================================
/// Validate Vietnamese Tax ID (Mã số thuế)
/// Format: 10 or 13 digits
static String? taxId(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập mã số thuế';
}
final cleaned = value.replaceAll(RegExp(r'\D'), '');
if (cleaned.length != 10 && cleaned.length != 13) {
return 'Mã số thuế phải có 10 hoặc 13 chữ số';
}
return null;
}
/// Validate tax ID (optional)
static String? taxIdOptional(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
return taxId(value);
}
// ========================================================================
// URL Validators
// ========================================================================
/// Validate URL
static String? url(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập URL';
}
final urlRegex = RegExp(
r'^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$',
);
if (!urlRegex.hasMatch(value)) {
return 'URL không hợp lệ';
}
return null;
}
// ========================================================================
// Combination Validators
// ========================================================================
/// Combine multiple validators
static String? Function(String?) combine(
List<String? Function(String?)> validators,
) {
return (String? value) {
for (final validator in validators) {
final error = validator(value);
if (error != null) return error;
}
return null;
};
}
// ========================================================================
// Custom Pattern Validators
// ========================================================================
/// Validate against custom regex pattern
static String? pattern(
String? value,
RegExp pattern,
String errorMessage,
) {
if (value == null || value.trim().isEmpty) {
return 'Trường này là bắt buộc';
}
if (!pattern.hasMatch(value)) {
return errorMessage;
}
return null;
}
// ========================================================================
// Match Validators
// ========================================================================
/// Validate that value matches another value
static String? match(String? value, String? matchValue, String fieldName) {
if (value == null || value.trim().isEmpty) {
return 'Vui lòng nhập $fieldName';
}
if (value != matchValue) {
return '$fieldName không khớp';
}
return null;
}
}
/// Password strength enum
enum PasswordStrength {
weak,
medium,
strong,
veryStrong,
}
/// Password strength calculator
class PasswordStrengthCalculator {
/// Calculate password strength
static PasswordStrength calculate(String password) {
if (password.isEmpty) return PasswordStrength.weak;
var score = 0;
// Length check
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (password.length >= 16) score++;
// Character variety check
if (RegExp(r'[a-z]').hasMatch(password)) score++;
if (RegExp(r'[A-Z]').hasMatch(password)) score++;
if (RegExp(r'[0-9]').hasMatch(password)) score++;
if (RegExp(r'[!@#$%^&*(),.?":{}|<>]').hasMatch(password)) score++;
// Return strength based on score
if (score <= 2) return PasswordStrength.weak;
if (score <= 4) return PasswordStrength.medium;
if (score <= 6) return PasswordStrength.strong;
return PasswordStrength.veryStrong;
}
/// Get strength label in Vietnamese
static String getLabel(PasswordStrength strength) {
switch (strength) {
case PasswordStrength.weak:
return 'Yếu';
case PasswordStrength.medium:
return 'Trung bình';
case PasswordStrength.strong:
return 'Mạnh';
case PasswordStrength.veryStrong:
return 'Rất mạnh';
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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