Compare commits

...

16 Commits

Author SHA1 Message Date
Phuoc Nguyen
8ff7b3b505 update text + slider 2025-12-03 17:20:22 +07:00
Phuoc Nguyen
2a14f82b72 fix design request 2025-12-03 17:12:21 +07:00
Phuoc Nguyen
2dadcc5ce1 update 2025-12-03 16:10:39 +07:00
Phuoc Nguyen
27798cc234 update cart/favorite 2025-12-03 15:53:46 +07:00
Phuoc Nguyen
e1c9f818d2 update filter products 2025-12-03 14:33:08 +07:00
Phuoc Nguyen
cae04b3ae7 add firebase, add screen flow 2025-12-03 11:07:33 +07:00
Phuoc Nguyen
9fb4ba621b fix 2025-12-03 09:04:35 +07:00
Phuoc Nguyen
19d9a3dc2d update loaing 2025-12-02 18:09:20 +07:00
Phuoc Nguyen
fc9b5e967f update perf 2025-12-02 17:32:20 +07:00
Phuoc Nguyen
211ebdf1d8 build android 2025-12-02 16:14:14 +07:00
Phuoc Nguyen
359c31a4d4 update invoice 2025-12-02 15:58:10 +07:00
Phuoc Nguyen
49a41d24eb update theme 2025-12-02 15:20:54 +07:00
Phuoc Nguyen
12bd70479c update payment 2025-12-01 16:07:49 +07:00
Phuoc Nguyen
e62c466155 fix order, update qr 2025-12-01 15:28:07 +07:00
Phuoc Nguyen
250c453413 update theme selection 2025-12-01 11:31:26 +07:00
Phuoc Nguyen
4ecb236532 update payment 2025-12-01 10:03:24 +07:00
189 changed files with 11830 additions and 5111 deletions

View File

@@ -420,7 +420,7 @@ ref.watch(userProvider).when(
data: (user) => UserView(user),
loading: () => CircularProgressIndicator(),
loading: () => const CustomLoadingIndicator(),
error: (error, stack) => ErrorView(error),
@@ -443,7 +443,7 @@ switch (userState) {
case AsyncLoading():
return CircularProgressIndicator();
return const CustomLoadingIndicator();
}

256
APP_SETTINGS.md Normal file
View File

@@ -0,0 +1,256 @@
# App Settings & Theme System
## Overview
The app uses a centralized `AppSettingsBox` (Hive) for storing all app-level settings. This includes theme preferences, language settings, notification preferences, and other user configurations.
---
## AppSettingsBox
**Location**: `lib/core/database/app_settings_box.dart`
### Initialization
```dart
// In main.dart - call before runApp()
await AppSettingsBox.init();
```
### Storage Keys
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| **Theme** |
| `seed_color_id` | String | `'blue'` | Selected theme color ID |
| `theme_mode` | int | `0` | 0=system, 1=light, 2=dark |
| **Language** |
| `language_code` | String | `'vi'` | Language code (vi, en) |
| **Notifications** |
| `notifications_enabled` | bool | `true` | Master notification toggle |
| `order_notifications` | bool | `true` | Order status notifications |
| `promotion_notifications` | bool | `true` | Promotion notifications |
| `chat_notifications` | bool | `true` | Chat message notifications |
| **User Preferences** |
| `onboarding_completed` | bool | `false` | Onboarding flow completed |
| `biometric_enabled` | bool | `false` | Biometric login enabled |
| `remember_login` | bool | `false` | Remember login credentials |
| **App State** |
| `last_sync_time` | String | - | Last data sync timestamp |
| `app_version` | String | - | Last launched app version |
| `first_launch_date` | String | - | First app launch date |
### Usage
```dart
// Generic get/set
AppSettingsBox.get<String>('key', defaultValue: 'default');
await AppSettingsBox.set('key', value);
// Helper methods
AppSettingsBox.getSeedColorId(); // Returns 'blue', 'teal', etc.
await AppSettingsBox.setSeedColorId('teal');
AppSettingsBox.getThemeModeIndex(); // Returns 0, 1, or 2
await AppSettingsBox.setThemeModeIndex(1);
AppSettingsBox.getLanguageCode(); // Returns 'vi' or 'en'
await AppSettingsBox.setLanguageCode('en');
AppSettingsBox.areNotificationsEnabled(); // Returns true/false
AppSettingsBox.isOnboardingCompleted();
AppSettingsBox.isBiometricEnabled();
```
---
## Theme System
### Architecture
```
colors.dart → Seed color options & status colors
app_theme.dart → ThemeData generation from seed color
theme_provider.dart → Riverpod state management
```
### Available Seed Colors
| ID | Name | Color |
|----|------|-------|
| `blue` | Xanh dương | `#005B9A` (default) |
| `teal` | Xanh ngọc | `#009688` |
| `green` | Xanh lá | `#4CAF50` |
| `purple` | Tím | `#673AB7` |
| `indigo` | Chàm | `#3F51B5` |
| `orange` | Cam | `#FF5722` |
| `red` | Đỏ | `#E53935` |
| `pink` | Hồng | `#E91E63` |
### Providers
```dart
// Main theme settings provider (persisted)
themeSettingsProvider
// Convenience providers
currentSeedColorProvider // Color - current seed color
seedColorOptionsProvider // List<SeedColorOption> - all options
```
### Usage in App
```dart
// app.dart - Dynamic theme
class MyApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(themeSettingsProvider);
return MaterialApp(
theme: AppTheme.lightTheme(settings.seedColor),
darkTheme: AppTheme.darkTheme(settings.seedColor),
themeMode: settings.themeMode,
// ...
);
}
}
```
### Changing Theme
```dart
// Change seed color
ref.read(themeSettingsProvider.notifier).setSeedColor('teal');
// Change theme mode
ref.read(themeSettingsProvider.notifier).setThemeMode(ThemeMode.dark);
// Toggle light/dark
ref.read(themeSettingsProvider.notifier).toggleThemeMode();
```
### Color Picker Widget Example
```dart
class ColorPickerWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final options = ref.watch(seedColorOptionsProvider);
final current = ref.watch(themeSettingsProvider);
return Wrap(
spacing: 8,
children: options.map((option) {
final isSelected = option.id == current.seedColorId;
return GestureDetector(
onTap: () => ref
.read(themeSettingsProvider.notifier)
.setSeedColor(option.id),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: option.color,
shape: BoxShape.circle,
border: isSelected
? Border.all(color: Colors.white, width: 3)
: null,
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
);
}
}
```
---
## Using ColorScheme
With the `fromSeed()` approach, always use `Theme.of(context).colorScheme` for colors:
```dart
// Via context extension (recommended)
final cs = context.colorScheme;
// Common color usage
cs.primary // Main brand color (buttons, links)
cs.onPrimary // Text/icons on primary color
cs.primaryContainer // Softer brand background
cs.onPrimaryContainer // Text on primaryContainer
cs.secondary // Secondary accent
cs.tertiary // Third accent color
cs.surface // Card/container backgrounds
cs.onSurface // Primary text color
cs.onSurfaceVariant // Secondary text color
cs.outline // Borders, dividers
cs.outlineVariant // Lighter borders
cs.error // Error states
cs.onError // Text on error
// Example widget
Container(
color: cs.primaryContainer,
child: Text(
'Hello',
style: TextStyle(color: cs.onPrimaryContainer),
),
)
```
### Status Colors (Fixed)
These colors don't change with theme (from backend):
```dart
AppColors.success // #28a745 - Green
AppColors.warning // #ffc107 - Yellow
AppColors.danger // #dc3545 - Red
AppColors.info // #17a2b8 - Blue
```
---
## Files Reference
| File | Purpose |
|------|---------|
| `lib/core/database/app_settings_box.dart` | Hive storage for all app settings |
| `lib/core/theme/colors.dart` | Seed colors, status colors, gradients |
| `lib/core/theme/app_theme.dart` | ThemeData generation |
| `lib/core/theme/theme_provider.dart` | Riverpod providers for theme |
| `lib/core/theme/typography.dart` | Text styles |
---
## Initialization Order
```dart
// main.dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// 1. Initialize Hive
await Hive.initFlutter();
// 2. Initialize AppSettingsBox
await AppSettingsBox.init();
// 3. Initialize other boxes...
runApp(
ProviderScope(
child: MyApp(),
),
);
}
```

478
CLAUDE.md
View File

@@ -29,6 +29,13 @@ All Dart code examples, patterns, and snippets are maintained in **CODE_EXAMPLES
- Localization setup
- Deployment configurations
### 🎨 App Settings & Theme:
See **[APP_SETTINGS.md](APP_SETTINGS.md)** for:
- AppSettingsBox (Hive) - centralized app settings storage
- Theme system with dynamic seed colors
- ColorScheme usage guide
- Available seed color options
---
## 🤖 SUBAGENT DELEGATION SYSTEM 🤖
@@ -123,441 +130,46 @@ When working with Hive boxes, always use `Box<dynamic>` in data sources and appl
## Worker App Project Structure
**See [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md)** for the complete project structure with all features, folders, and files.
### Vietnamese Currency Formatting
**IMPORTANT: Always use `toVNCurrency()` extension for displaying Vietnamese currency.**
```dart
// Import the extension
import 'package:worker/core/utils/extensions.dart';
// Usage examples:
final price = 1500000;
Text(price.toVNCurrency()); // Output: "1.500.000 đ"
final total = 25000000.5;
Text(total.toVNCurrency()); // Output: "25.000.001 đ" (rounded)
// In widgets:
Text(
product.price.toVNCurrency(),
style: AppTextStyle.bodyLarge.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.primary,
),
)
// For order totals, cart summaries, payment amounts:
Text('Tổng tiền: ${order.totalAmount.toVNCurrency()}')
```
lib/
core/
constants/
api_constants.dart # API endpoints, timeouts
app_constants.dart # App config, defaults, loyalty tiers
ui_constants.dart # Spacing, sizes, colors
storage_constants.dart # Hive box names, keys
theme/
app_theme.dart # Material 3 theme (primary blue #005B9A)
colors.dart # Brand color schemes
typography.dart # Roboto text styles
network/
dio_client.dart # HTTP client setup
api_interceptor.dart # Auth token, logging interceptors
network_info.dart # Connectivity status
errors/
exceptions.dart # Custom exceptions
failures.dart # Failure classes
utils/
formatters.dart # Currency, date, phone formatters
validators.dart # Form validation (Vietnamese phone, email)
extensions.dart # Dart extensions
qr_generator.dart # QR code generation for member cards
widgets/
custom_button.dart # Primary, secondary buttons
loading_indicator.dart # Loading states
error_widget.dart # Error displays
empty_state.dart # Empty list UI
bottom_nav_bar.dart # Main bottom navigation
floating_chat_button.dart # FAB for chat
features/
auth/
data/
datasources/
auth_remote_datasource.dart # Login, OTP, register APIs
auth_local_datasource.dart # Token storage
models/
user_model.dart # User with tier info
otp_response_model.dart
repositories/
auth_repository_impl.dart
domain/
entities/
user.dart # id, name, phone, email, tier, points
repositories/
auth_repository.dart
usecases/
login_with_phone.dart
verify_otp.dart
register_user.dart
logout.dart
get_current_user.dart
presentation/
providers/
auth_provider.dart
otp_timer_provider.dart
pages/
login_page.dart # Phone input
otp_verification_page.dart # 6-digit OTP
register_page.dart # Full registration form
widgets/
phone_input_field.dart
otp_input_field.dart # Auto-focus 6 digits
user_type_selector.dart # Contractor/Architect/etc
**DO NOT use:**
- `NumberFormat` directly for VND
- Manual string formatting like `'${price.toString()} đ'`
- Other currency formatters
home/
data/
datasources/
member_card_local_datasource.dart
models/
member_card_model.dart
presentation/
providers/
member_card_provider.dart
pages:
home_page.dart # Main dashboard
widgets:
diamond_member_card.dart # Gradient card with QR
platinum_member_card.dart
gold_member_card.dart
quick_action_grid.dart
loyalty/
data/
datasources:
loyalty_remote_datasource.dart
loyalty_local_datasource.dart
models:
loyalty_points_model.dart
loyalty_transaction_model.dart
reward_model.dart
gift_model.dart
referral_model.dart
repositories:
loyalty_repository_impl.dart
domain:
entities:
loyalty_points.dart # currentPoints, tier, nextTierPoints
loyalty_transaction.dart # id, type, amount, description, date
reward.dart # id, title, pointsCost, image, expiry
gift.dart # id, code, status, validFrom, validTo
referral.dart # code, link, totalReferrals, pointsEarned
repositories:
loyalty_repository.dart
usecases:
get_loyalty_points.dart
get_points_history.dart
redeem_reward.dart
get_available_rewards.dart
get_my_gifts.dart
get_referral_info.dart
share_referral.dart
presentation:
providers:
loyalty_points_provider.dart
points_history_provider.dart
rewards_provider.dart
gifts_provider.dart
referral_provider.dart
pages:
loyalty_page.dart # Progress bar, tier info
rewards_page.dart # Grid of redeemable rewards
points_history_page.dart # Transaction list
referral_page.dart # Referral link & code
my_gifts_page.dart # Tabs: Active/Used/Expired
widgets:
tier_progress_bar.dart
points_badge.dart
reward_card.dart
gift_card.dart
referral_share_sheet.dart
products/
data:
datasources:
product_remote_datasource.dart
product_local_datasource.dart
models:
product_model.dart # Tile/construction products
category_model.dart
repositories:
product_repository_impl.dart
domain:
entities:
product.dart # id, name, sku, price, images, category
category.dart
repositories:
product_repository.dart
usecases:
get_all_products.dart
search_products.dart
get_products_by_category.dart
get_product_details.dart
presentation:
providers:
products_provider.dart
product_search_provider.dart
categories_provider.dart
pages:
products_page.dart # Grid with search & filters
product_detail_page.dart
widgets:
product_grid.dart
product_card.dart
product_search_bar.dart
category_filter_chips.dart
cart/
data:
datasources:
cart_local_datasource.dart # Hive persistence
models:
cart_item_model.dart
repositories:
cart_repository_impl.dart
domain:
entities:
cart_item.dart # productId, quantity, price
repositories:
cart_repository.dart
usecases:
add_to_cart.dart
remove_from_cart.dart
update_quantity.dart
clear_cart.dart
get_cart_items.dart
calculate_cart_total.dart
presentation:
providers:
cart_provider.dart
cart_total_provider.dart
pages:
cart_page.dart
checkout_page.dart
order_success_page.dart
widgets:
cart_item_card.dart
cart_summary.dart
quantity_selector.dart
payment_method_selector.dart
orders/
data:
datasources:
order_remote_datasource.dart
order_local_datasource.dart
models:
order_model.dart
order_item_model.dart
payment_model.dart
repositories:
order_repository_impl.dart
domain:
entities:
order.dart # orderNumber, items, total, status
order_item.dart
payment.dart
repositories:
order_repository.dart
usecases:
create_order.dart
get_orders.dart
get_order_details.dart
get_payments.dart
presentation:
providers:
orders_provider.dart
order_filter_provider.dart
payments_provider.dart
pages:
orders_page.dart # Tabs by status
order_detail_page.dart
payments_page.dart
widgets:
order_card.dart
order_status_badge.dart
order_timeline.dart
payment_card.dart
projects/
data:
datasources:
project_remote_datasource.dart
project_local_datasource.dart
models:
project_model.dart
quote_model.dart
repositories:
project_repository_impl.dart
domain:
entities:
project.dart # name, client, location, progress, status
quote.dart # number, client, amount, validity, status
repositories:
project_repository.dart
usecases:
create_project.dart
get_projects.dart
update_project_progress.dart
create_quote.dart
get_quotes.dart
presentation:
providers:
projects_provider.dart
project_form_provider.dart
quotes_provider.dart
pages:
projects_page.dart # List with progress bars
project_create_page.dart # Form
project_detail_page.dart
quotes_page.dart
quote_create_page.dart
widgets:
project_card.dart
project_progress_bar.dart
quote_card.dart
project_form.dart
chat/
data:
datasources:
chat_remote_datasource.dart # WebSocket/REST
chat_local_datasource.dart
models:
message_model.dart
chat_room_model.dart
repositories:
chat_repository_impl.dart
domain:
entities:
message.dart # id, text, senderId, timestamp, isRead
chat_room.dart
repositories:
chat_repository.dart
usecases:
send_message.dart
get_messages.dart
mark_as_read.dart
presentation:
providers:
chat_provider.dart
messages_provider.dart
typing_indicator_provider.dart
pages:
chat_page.dart
widgets:
message_bubble.dart
message_input.dart
typing_indicator.dart
chat_app_bar.dart
account/
data:
datasources:
profile_remote_datasource.dart
profile_local_datasource.dart
address_datasource.dart
models:
profile_model.dart
address_model.dart
repositories:
profile_repository_impl.dart
address_repository_impl.dart
domain:
entities:
profile.dart # Extended user info
address.dart # Delivery addresses
repositories:
profile_repository.dart
address_repository.dart
usecases:
get_profile.dart
update_profile.dart
upload_avatar.dart
change_password.dart
get_addresses.dart
add_address.dart
update_address.dart
delete_address.dart
presentation:
providers:
profile_provider.dart
avatar_provider.dart
addresses_provider.dart
pages:
account_page.dart # Menu
profile_edit_page.dart
addresses_page.dart
address_form_page.dart
password_change_page.dart
widgets:
profile_header.dart
account_menu_item.dart
address_card.dart
avatar_picker.dart
promotions/
data:
datasources:
promotion_remote_datasource.dart
models:
promotion_model.dart
repositories:
promotion_repository_impl.dart
domain:
entities:
promotion.dart # title, description, discount, validity
repositories:
promotion_repository.dart
usecases:
get_active_promotions.dart
presentation:
providers:
promotions_provider.dart
pages:
promotions_page.dart
widgets:
promotion_card.dart
promotion_banner.dart
notifications/
data:
datasources:
notification_remote_datasource.dart
notification_local_datasource.dart
models:
notification_model.dart
repositories:
notification_repository_impl.dart
domain:
entities:
notification.dart # title, body, type, isRead, timestamp
repositories:
notification_repository.dart
usecases:
get_notifications.dart
mark_as_read.dart
clear_all.dart
presentation:
providers:
notifications_provider.dart
notification_badge_provider.dart
pages:
notifications_page.dart # Tabs: All/Orders/System/Promos
widgets:
notification_card.dart
notification_badge.dart
shared/
widgets/
custom_app_bar.dart
gradient_card.dart # For member cards
status_badge.dart
price_display.dart
vietnamese_phone_field.dart
date_picker_field.dart
main.dart
app.dart # Root widget with ProviderScope
test/
unit/
features/
auth/
loyalty/
products/
cart/
orders/
projects/
widget/
widgets/
integration/
```
**The `toVNCurrency()` extension:**
- Formats with dot (.) as thousand separator
- Appends " đ" suffix
- Rounds to nearest integer
- Handles both `int` and `double` values
---
@@ -1505,5 +1117,5 @@ All recent implementations follow:
- ✅ AppBar standardization
- ✅ CachedNetworkImage for all remote images
- ✅ Proper error handling
- ✅ Loading states (CircularProgressIndicator)
- ✅ Loading states (CustomLoadingIndicator)
- ✅ Empty states with helpful messages

437
PROJECT_STRUCTURE.md Normal file
View File

@@ -0,0 +1,437 @@
# Worker App Project Structure
```
lib/
core/
constants/
api_constants.dart # API endpoints, timeouts
app_constants.dart # App config, defaults, loyalty tiers
ui_constants.dart # Spacing, sizes, colors
storage_constants.dart # Hive box names, keys
theme/
app_theme.dart # Material 3 theme (primary blue #005B9A)
colors.dart # Brand color schemes
typography.dart # Roboto text styles
network/
dio_client.dart # HTTP client setup
api_interceptor.dart # Auth token, logging interceptors
network_info.dart # Connectivity status
errors/
exceptions.dart # Custom exceptions
failures.dart # Failure classes
utils/
formatters.dart # Currency, date, phone formatters
validators.dart # Form validation (Vietnamese phone, email)
extensions.dart # Dart extensions
qr_generator.dart # QR code generation for member cards
widgets/
custom_button.dart # Primary, secondary buttons
loading_indicator.dart # Loading states
error_widget.dart # Error displays
empty_state.dart # Empty list UI
bottom_nav_bar.dart # Main bottom navigation
floating_chat_button.dart # FAB for chat
features/
auth/
data/
datasources/
auth_remote_datasource.dart # Login, OTP, register APIs
auth_local_datasource.dart # Token storage
models/
user_model.dart # User with tier info
otp_response_model.dart
repositories/
auth_repository_impl.dart
domain/
entities/
user.dart # id, name, phone, email, tier, points
repositories/
auth_repository.dart
usecases/
login_with_phone.dart
verify_otp.dart
register_user.dart
logout.dart
get_current_user.dart
presentation/
providers/
auth_provider.dart
otp_timer_provider.dart
pages/
login_page.dart # Phone input
otp_verification_page.dart # 6-digit OTP
register_page.dart # Full registration form
widgets/
phone_input_field.dart
otp_input_field.dart # Auto-focus 6 digits
user_type_selector.dart # Contractor/Architect/etc
home/
data/
datasources/
member_card_local_datasource.dart
models/
member_card_model.dart
presentation/
providers/
member_card_provider.dart
pages:
home_page.dart # Main dashboard
widgets:
diamond_member_card.dart # Gradient card with QR
platinum_member_card.dart
gold_member_card.dart
quick_action_grid.dart
loyalty/
data/
datasources:
loyalty_remote_datasource.dart
loyalty_local_datasource.dart
models:
loyalty_points_model.dart
loyalty_transaction_model.dart
reward_model.dart
gift_model.dart
referral_model.dart
repositories:
loyalty_repository_impl.dart
domain:
entities:
loyalty_points.dart # currentPoints, tier, nextTierPoints
loyalty_transaction.dart # id, type, amount, description, date
reward.dart # id, title, pointsCost, image, expiry
gift.dart # id, code, status, validFrom, validTo
referral.dart # code, link, totalReferrals, pointsEarned
repositories:
loyalty_repository.dart
usecases:
get_loyalty_points.dart
get_points_history.dart
redeem_reward.dart
get_available_rewards.dart
get_my_gifts.dart
get_referral_info.dart
share_referral.dart
presentation:
providers:
loyalty_points_provider.dart
points_history_provider.dart
rewards_provider.dart
gifts_provider.dart
referral_provider.dart
pages:
loyalty_page.dart # Progress bar, tier info
rewards_page.dart # Grid of redeemable rewards
points_history_page.dart # Transaction list
referral_page.dart # Referral link & code
my_gifts_page.dart # Tabs: Active/Used/Expired
widgets:
tier_progress_bar.dart
points_badge.dart
reward_card.dart
gift_card.dart
referral_share_sheet.dart
products/
data:
datasources:
product_remote_datasource.dart
product_local_datasource.dart
models:
product_model.dart # Tile/construction products
category_model.dart
repositories:
product_repository_impl.dart
domain:
entities:
product.dart # id, name, sku, price, images, category
category.dart
repositories:
product_repository.dart
usecases:
get_all_products.dart
search_products.dart
get_products_by_category.dart
get_product_details.dart
presentation:
providers:
products_provider.dart
product_search_provider.dart
categories_provider.dart
pages:
products_page.dart # Grid with search & filters
product_detail_page.dart
widgets:
product_grid.dart
product_card.dart
product_search_bar.dart
category_filter_chips.dart
cart/
data:
datasources:
cart_local_datasource.dart # Hive persistence
models:
cart_item_model.dart
repositories:
cart_repository_impl.dart
domain:
entities:
cart_item.dart # productId, quantity, price
repositories:
cart_repository.dart
usecases:
add_to_cart.dart
remove_from_cart.dart
update_quantity.dart
clear_cart.dart
get_cart_items.dart
calculate_cart_total.dart
presentation:
providers:
cart_provider.dart
cart_total_provider.dart
pages:
cart_page.dart
checkout_page.dart
order_success_page.dart
widgets:
cart_item_card.dart
cart_summary.dart
quantity_selector.dart
payment_method_selector.dart
orders/
data:
datasources:
order_remote_datasource.dart
order_local_datasource.dart
models:
order_model.dart
order_item_model.dart
payment_model.dart
repositories:
order_repository_impl.dart
domain:
entities:
order.dart # orderNumber, items, total, status
order_item.dart
payment.dart
repositories:
order_repository.dart
usecases:
create_order.dart
get_orders.dart
get_order_details.dart
get_payments.dart
presentation:
providers:
orders_provider.dart
order_filter_provider.dart
payments_provider.dart
pages:
orders_page.dart # Tabs by status
order_detail_page.dart
payments_page.dart
widgets:
order_card.dart
order_status_badge.dart
order_timeline.dart
payment_card.dart
projects/
data:
datasources:
project_remote_datasource.dart
project_local_datasource.dart
models:
project_model.dart
quote_model.dart
repositories:
project_repository_impl.dart
domain:
entities:
project.dart # name, client, location, progress, status
quote.dart # number, client, amount, validity, status
repositories:
project_repository.dart
usecases:
create_project.dart
get_projects.dart
update_project_progress.dart
create_quote.dart
get_quotes.dart
presentation:
providers:
projects_provider.dart
project_form_provider.dart
quotes_provider.dart
pages:
projects_page.dart # List with progress bars
project_create_page.dart # Form
project_detail_page.dart
quotes_page.dart
quote_create_page.dart
widgets:
project_card.dart
project_progress_bar.dart
quote_card.dart
project_form.dart
chat/
data:
datasources:
chat_remote_datasource.dart # WebSocket/REST
chat_local_datasource.dart
models:
message_model.dart
chat_room_model.dart
repositories:
chat_repository_impl.dart
domain:
entities:
message.dart # id, text, senderId, timestamp, isRead
chat_room.dart
repositories:
chat_repository.dart
usecases:
send_message.dart
get_messages.dart
mark_as_read.dart
presentation:
providers:
chat_provider.dart
messages_provider.dart
typing_indicator_provider.dart
pages:
chat_page.dart
widgets:
message_bubble.dart
message_input.dart
typing_indicator.dart
chat_app_bar.dart
account/
data:
datasources:
profile_remote_datasource.dart
profile_local_datasource.dart
address_datasource.dart
models:
profile_model.dart
address_model.dart
repositories:
profile_repository_impl.dart
address_repository_impl.dart
domain:
entities:
profile.dart # Extended user info
address.dart # Delivery addresses
repositories:
profile_repository.dart
address_repository.dart
usecases:
get_profile.dart
update_profile.dart
upload_avatar.dart
change_password.dart
get_addresses.dart
add_address.dart
update_address.dart
delete_address.dart
presentation:
providers:
profile_provider.dart
avatar_provider.dart
addresses_provider.dart
pages:
account_page.dart # Menu
profile_edit_page.dart
addresses_page.dart
address_form_page.dart
password_change_page.dart
widgets:
profile_header.dart
account_menu_item.dart
address_card.dart
avatar_picker.dart
promotions/
data:
datasources:
promotion_remote_datasource.dart
models:
promotion_model.dart
repositories:
promotion_repository_impl.dart
domain:
entities:
promotion.dart # title, description, discount, validity
repositories:
promotion_repository.dart
usecases:
get_active_promotions.dart
presentation:
providers:
promotions_provider.dart
pages:
promotions_page.dart
widgets:
promotion_card.dart
promotion_banner.dart
notifications/
data:
datasources:
notification_remote_datasource.dart
notification_local_datasource.dart
models:
notification_model.dart
repositories:
notification_repository_impl.dart
domain:
entities:
notification.dart # title, body, type, isRead, timestamp
repositories:
notification_repository.dart
usecases:
get_notifications.dart
mark_as_read.dart
clear_all.dart
presentation:
providers:
notifications_provider.dart
notification_badge_provider.dart
pages:
notifications_page.dart # Tabs: All/Orders/System/Promos
widgets:
notification_card.dart
notification_badge.dart
shared/
widgets/
custom_app_bar.dart
gradient_card.dart # For member cards
status_badge.dart
price_display.dart
vietnamese_phone_field.dart
date_picker_field.dart
main.dart
app.dart # Root widget with ProviderScope
test/
unit/
features/
auth/
loyalty/
products/
cart/
orders/
projects/
widget/
widgets/
integration/
```

View File

@@ -1,10 +1,23 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
// START: FlutterFire Configuration
id("com.google.gms.google-services")
// END: FlutterFire Configuration
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// Load keystore properties for release signing
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.dbiz.partner"
compileSdk = flutter.compileSdkVersion
@@ -30,11 +43,18 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "147309310656",
"project_id": "dbiz-partner",
"storage_bucket": "dbiz-partner.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:147309310656:android:86613d8ffc85576fdc7325",
"android_client_info": {
"package_name": "com.dbiz.partner"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyA60iGPuHOQMJUA0m5aSimzevPAiiaB4pE"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -1,4 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<application
android:label="worker"
android:name="${applicationName}"

View File

@@ -20,6 +20,9 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
// START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.3.15") apply false
// END: FlutterFire Configuration
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}

View File

@@ -8,12 +8,14 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
{
"item_id": "Bình giữ nhiệt Euroutile",
"amount": 3000000,
"quantity" : 5.78
"quantity" : 5.78,
"conversion_of_sm: 1.5
},
{
"item_id": "Gạch ốp Signature SIG.P-8806",
"amount": 4000000,
"quantity" : 33
"quantity" : 33,
"conversion_of_sm: 1.5
}
]
}'

97
docs/invoice.sh Normal file
View File

@@ -0,0 +1,97 @@
#get list of invoices
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.invoice.get_list' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Content-Type: application/json' \
--data '{
"limit_page_length" : 0,
"limit_start" : 0
}'
#response
{
"message": [
{
"name": "ACC-SINV-2025-00041",
"posting_date": "2025-12-02",
"status": "Chưa thanh toán",
"status_color": "Danger",
"order_id": null,
"grand_total": 486400.0
},
{
"name": "ACC-SINV-2025-00026",
"posting_date": "2025-11-25",
"status": "Đã trả",
"status_color": "Success",
"order_id": "SAL-ORD-2025-00119",
"grand_total": 1153433.6
},
{
"name": "ACC-SINV-2025-00025",
"posting_date": "2025-11-24",
"status": "Đã trả",
"status_color": "Success",
"order_id": "SAL-ORD-2025-00104",
"grand_total": 3580257.894
}
]
}
#get invoice detail
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.invoice.get_detail' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Content-Type: application/json' \
--data '{
"name" : "ACC-SINV-2025-00041"
}'
#response
{
"message": {
"name": "ACC-SINV-2025-00041",
"posting_date": "2025-12-02",
"status": "Chưa thanh toán",
"status_color": "Danger",
"customer_name": "Ha Duy Lam",
"order_id": null,
"seller_info": {
"phone": "0243 543 0726",
"email": "info@viglacera.com.vn",
"fax": "(024) 3553 6671",
"tax_code": "0105908818",
"company_name": "Công Ty Cổ Phần Kinh Doanh Gạch Ốp Lát Viglacera",
"address_line1": "Tầng 2 tòa nhà Viglacera, số 1 đại lộ Thăng Long",
"city_code": "01",
"ward_code": "00637",
"city_name": "Thành phố Hà Nội",
"ward_name": "Phường Đại Mỗ"
},
"buyer_info": {
"name": "phuoc-thanh toán",
"address_title": "phuoc",
"address_line1": "123 tt",
"phone": "0985225855",
"email": null,
"fax": null,
"tax_code": null,
"city_code": "75",
"ward_code": "25252",
"city_name": "Tỉnh Đồng Nai",
"ward_name": "Xã Phú Riềng"
},
"items": [
{
"item_name": "Hội An HOA E01",
"item_code": "HOA E01",
"qty": 1.0,
"rate": 486400.0,
"amount": 486400.0
}
],
"total": 486400.0,
"discount_amount": 0.0,
"grand_total": 486400.0
}
}

View File

@@ -219,7 +219,7 @@ class _CartPageState extends ConsumerState<CartPage> {
}
: null,
child: _isSyncing
? CircularProgressIndicator() // Show loading while syncing
? const CustomLoadingIndicator() // Show loading while syncing
: Text('Tiến hành đặt hàng'),
);
}

View File

@@ -768,5 +768,5 @@ end
- ✅ Vietnamese localization
- ✅ CachedNetworkImage for all remote images
- ✅ Proper error handling
- ✅ Loading states (CircularProgressIndicator)
- ✅ Loading states (CustomLoadingIndicator)
- ✅ Empty states with helpful messages

View File

@@ -257,7 +257,7 @@ int stars = apiRatingToStars(0.8); // 4
- Added date formatting function (`_formatDate`)
**States**:
1. **Loading**: Shows CircularProgressIndicator
1. **Loading**: Shows CustomLoadingIndicator
2. **Error**: Shows error icon and message
3. **Empty**: Shows "Chưa có đánh giá nào" with call-to-action
4. **Data**: Shows rating overview and review list
@@ -553,7 +553,7 @@ Widget build(BuildContext context, WidgetRef ref) {
},
);
},
loading: () => CircularProgressIndicator(),
loading: () => const CustomLoadingIndicator(),
error: (error, stack) => Text('Error: $error'),
);
}

View File

@@ -416,7 +416,7 @@ RatingProvider CountProvider in UI components)
```
1. Initial State (Loading)
├─► productReviewsProvider returns AsyncValue.loading()
└─► UI shows CircularProgressIndicator
└─► UI shows CustomLoadingIndicator
2. Loading State → Data State
├─► API call succeeds

View File

@@ -60,7 +60,7 @@ class ReviewsListPage extends ConsumerWidget {
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
child: const CustomLoadingIndicator(),
),
error: (error, stack) => Center(
child: Text('Error: $error'),
@@ -263,7 +263,7 @@ class _SimpleReviewFormState extends ConsumerState<SimpleReviewForm> {
child: ElevatedButton(
onPressed: _isSubmitting ? null : _submitReview,
child: _isSubmitting
? const CircularProgressIndicator()
? const const CustomLoadingIndicator()
: const Text('Submit Review'),
),
),
@@ -351,7 +351,7 @@ class _PaginatedReviewsListState
padding: const EdgeInsets.all(16),
child: Center(
child: _isLoading
? const CircularProgressIndicator()
? const const CustomLoadingIndicator()
: ElevatedButton(
onPressed: _loadMoreReviews,
child: const Text('Load More'),
@@ -430,7 +430,7 @@ class RefreshableReviewsList extends ConsumerWidget {
Center(
child: Padding(
padding: EdgeInsets.all(40),
child: CircularProgressIndicator(),
child: const CustomLoadingIndicator(),
),
),
],
@@ -540,7 +540,7 @@ class _FilteredReviewsListState extends ConsumerState<FilteredReviewsList> {
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
child: const CustomLoadingIndicator(),
),
error: (error, stack) => Center(
child: Text('Error: $error'),
@@ -662,7 +662,7 @@ class ReviewsWithRetry extends ConsumerWidget {
);
},
loading: () => const Center(
child: CircularProgressIndicator(),
child: const CustomLoadingIndicator(),
),
error: (error, stack) => Center(
child: Column(

View File

@@ -30,7 +30,7 @@ final reviewsAsync = ref.watch(productReviewsProvider(itemId));
reviewsAsync.when(
data: (reviews) => /* show reviews */,
loading: () => CircularProgressIndicator(),
loading: () => const CustomLoadingIndicator(),
error: (error, stack) => /* show error */,
);
```

68
docs/payment.sh Normal file
View File

@@ -0,0 +1,68 @@
#get list payments
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.payment.get_list' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Content-Type: application/json' \
--data '{
"limit_page_length" : 0,
"limit_start" : 0
}'
#response
{
"message": [
{
"name": "ACC-PAY-2025-00020",
"posting_date": "2025-11-25",
"paid_amount": 1130365.328,
"mode_of_payment": null,
"invoice_id": null,
"order_id": "SAL-ORD-2025-00120"
},
{
"name": "ACC-PAY-2025-00019",
"posting_date": "2025-11-25",
"paid_amount": 1153434.0,
"mode_of_payment": "Chuyển khoản",
"invoice_id": "ACC-SINV-2025-00026",
"order_id": null
},
{
"name": "ACC-PAY-2025-00018",
"posting_date": "2025-11-24",
"paid_amount": 2580258.0,
"mode_of_payment": null,
"invoice_id": "ACC-SINV-2025-00025",
"order_id": null
},
{
"name": "ACC-PAY-2025-00017",
"posting_date": "2025-11-24",
"paid_amount": 1000000.0,
"mode_of_payment": null,
"invoice_id": "ACC-SINV-2025-00025",
"order_id": null
}
]
}
#get payment detail
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.payment.get_detail' \
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
--header 'Content-Type: application/json' \
--data '{
"name" : "ACC-PAY-2025-00020"
}'
#response
{
"message": {
"name": "ACC-PAY-2025-00020",
"posting_date": "2025-11-25",
"paid_amount": 1130365.328,
"mode_of_payment": null,
"invoice_id": null,
"order_id": "SAL-ORD-2025-00120"
}
}

View File

@@ -56,7 +56,7 @@ curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
--data '{
"doctype": "Item Group",
"fields": ["item_group_name","name"],
"filters": {"is_group": 0},
"filters": {"is_group": 0, "custom_published" : 1},
"limit_page_length": 0
}'

1
firebase.json Normal file
View File

@@ -0,0 +1 @@
{"flutter":{"platforms":{"android":{"default":{"projectId":"dbiz-partner","appId":"1:147309310656:android:86613d8ffc85576fdc7325","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"dbiz-partner","appId":"1:147309310656:ios:aa59724d2c6b4620dc7325","uploadDebugSymbols":false,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"dbiz-partner","configurations":{"android":"1:147309310656:android:86613d8ffc85576fdc7325","ios":"1:147309310656:ios:aa59724d2c6b4620dc7325"}}}}}}

632
html/invoice-detail.html Normal file
View File

@@ -0,0 +1,632 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chi tiết Hóa đơn - EuroTile Worker</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
.invoice-container {
max-width: 800px;
margin: 0 auto;
background: #f8fafc;
min-height: 100vh;
padding-bottom: 100px;
}
.invoice-content {
padding: 20px;
}
.invoice-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 16px;
}
/* Invoice Header */
.invoice-header-section {
text-align: center;
padding-bottom: 24px;
border-bottom: 2px solid #e5e7eb;
margin-bottom: 24px;
}
.company-logo {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
border-radius: 12px;
margin: 0 auto 16px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 32px;
font-weight: 700;
}
.invoice-title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin-bottom: 8px;
}
.invoice-number {
font-size: 20px;
font-weight: 600;
color: #2563eb;
margin-bottom: 12px;
}
.invoice-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
font-size: 14px;
}
.invoice-meta-item {
text-align: center;
}
.invoice-meta-label {
color: #6b7280;
margin-bottom: 4px;
}
.invoice-meta-value {
color: #1f2937;
font-weight: 600;
}
/* Company Info */
.company-info-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 24px;
}
.company-info-block h3 {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.company-info-block p {
font-size: 14px;
color: #6b7280;
line-height: 1.6;
margin-bottom: 6px;
}
.company-info-block p strong {
color: #1f2937;
font-weight: 600;
}
/* Products Table */
.products-section h3 {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.products-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.products-table thead {
background: #f8fafc;
}
.products-table th {
padding: 12px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #6b7280;
border-bottom: 2px solid #e5e7eb;
}
.products-table th:last-child,
.products-table td:last-child {
text-align: right;
}
.products-table td {
padding: 12px;
font-size: 14px;
color: #1f2937;
border-bottom: 1px solid #f3f4f6;
}
.products-table tbody tr:hover {
background: #f8fafc;
}
.product-name {
font-weight: 600;
}
.product-sku {
font-size: 12px;
color: #9ca3af;
}
/* Summary */
.invoice-summary {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
margin-top: 20px;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
font-size: 15px;
}
.summary-row.total {
border-top: 2px solid #e5e7eb;
padding-top: 16px;
margin-top: 8px;
}
.summary-label {
color: #6b7280;
}
.summary-value {
font-weight: 600;
color: #1f2937;
}
.summary-row.total .summary-label,
.summary-row.total .summary-value {
font-size: 18px;
font-weight: 700;
}
.summary-row.total .summary-value {
color: #dc2626;
}
/* Notes */
.invoice-notes {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
.invoice-notes h4 {
font-size: 14px;
font-weight: 600;
color: #1f2937;
margin-bottom: 8px;
}
.invoice-notes p {
font-size: 13px;
color: #6b7280;
line-height: 1.6;
}
/* Status Badge */
.status-badge {
display: inline-block;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
}
.status-paid {
background: #d1fae5;
color: #065f46;
}
.status-unpaid {
background: #fef3c7;
color: #d97706;
}
.status-partial {
background: #e0e7ff;
color: #3730a3;
}
/* Sticky Footer Actions */
.invoice-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top: 2px solid #e5e7eb;
padding: 16px 20px;
box-shadow: 0 -4px 16px rgba(0,0,0,0.08);
z-index: 50;
}
.invoice-actions-content {
max-width: 800px;
margin: 0 auto;
display: flex;
gap: 12px;
}
.btn {
flex: 1;
padding: 14px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 15px;
cursor: pointer;
transition: all 0.3s ease;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.4);
}
.btn-secondary {
background: white;
color: #374151;
border: 2px solid #e5e7eb;
}
.btn-secondary:hover {
border-color: #2563eb;
color: #2563eb;
transform: translateY(-2px);
}
/* Toast Notification */
.toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: #1f2937;
color: white;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
z-index: 1000;
display: none;
animation: slideDown 0.3s ease;
}
.toast.show {
display: block;
}
.toast.success {
background: #065f46;
}
.toast.error {
background: #dc2626;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@media (max-width: 768px) {
.invoice-content {
padding: 15px;
}
.invoice-card {
padding: 16px;
}
.company-info-section {
grid-template-columns: 1fr;
gap: 16px;
}
.products-table {
font-size: 12px;
}
.products-table th,
.products-table td {
padding: 8px 6px;
}
.invoice-actions-content {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>
</head>
<body>
<div class="page-wrapper">
<!-- Header -->
<div class="header">
<a href="invoice-list.html" class="back-button">
<i class="fas fa-arrow-left"></i>
</a>
<h1 class="header-title">Chi tiết Hóa đơn</h1>
<button class="header-action-btn" onclick="shareInvoice()">
<i class="fas fa-share-alt"></i>
</button>
</div>
<div class="invoice-container">
<div class="invoice-content">
<!-- Invoice Header Card -->
<div class="invoice-card">
<div class="invoice-header-section">
<div class="company-logo">
<i class="fas fa-file-invoice-dollar"></i>
</div>
<h1 class="invoice-title">HÓA ĐƠN GTGT</h1>
<div class="invoice-number">#INV20240001</div>
<span class="status-badge status-paid">Đã thanh toán</span>
<div class="invoice-meta">
<!--<div class="invoice-meta-item">
<div class="invoice-meta-label">Mẫu số:</div>
<div class="invoice-meta-value">01GTKT0/001</div>
</div>-->
<!--<div class="invoice-meta-item">
<div class="invoice-meta-label">Ký hiệu:</div>
<div class="invoice-meta-value">AA/24E</div>
</div>-->
<div class="invoice-meta-item">
<div class="invoice-meta-label">Ngày xuất:</div>
<div class="invoice-meta-value">03/08/2024</div>
</div>
<div class="invoice-meta-item">
<div class="invoice-meta-label">Đơn hàng:</div>
<div class="invoice-meta-value">#DH001234</div>
</div>
</div>
</div>
<!-- Company Information -->
<div class="company-info-section">
<div class="company-info-block">
<h3>
<i class="fas fa-building text-blue-600"></i>
Đơn vị bán hàng
</h3>
<p><strong>Công ty:</strong> CÔNG TY CP EUROTILE VIỆT NAM</p>
<p><strong>Mã số thuế:</strong> 0301234567</p>
<p><strong>Địa chỉ:</strong> 123 Đường Nguyễn Văn Linh, Quận 7, TP.HCM</p>
<p><strong>Điện thoại:</strong> (028) 1900 1234</p>
<p><strong>Email:</strong> sales@eurotile.vn</p>
</div>
<div class="company-info-block">
<h3>
<i class="fas fa-user-tie text-green-600"></i>
Đơn vị mua hàng
</h3>
<p><strong>Người mua hàng:</strong> Lê Hoàng Hiệp </p>
<p><strong>Tên đơn vị:</strong> Công ty TNHH Xây dựng Minh Long</p>
<p><strong>Mã số thuế:</strong> 0134000687</p>
<p><strong>Địa chỉ:</strong> 11 Đường Hoàng Hữu Nam, Phường Linh Chiểu, TP. Thủ Đức, TP.HCM</p>
<p><strong>Điện thoại:</strong> 0339797979</p>
<p><strong>Email:</strong> minhlong.org@gmail.com</p>
</div>
</div>
</div>
<!-- Products Section -->
<div class="invoice-card products-section">
<h3>
<i class="fas fa-box-open"></i>
Chi tiết hàng hóa
</h3>
<table class="products-table">
<thead>
<tr>
<th style="width: 40px;">#</th>
<th>Tên hàng hóa</th>
<!--<th style="width: 80px;">ĐVT</th>-->
<th style="width: 80px;">Số lượng</th>
<th style="width: 110px;">Đơn giá</th>
<th style="width: 120px;">Thành tiền</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>
<div class="product-name">Gạch Eurotile MỘC LAM E03</div>
<div class="product-sku">SKU: ET-ML-E03-60x60</div>
</td>
<!--<td>m²</td>-->
<td>30,12</td>
<td>285.000đ</td>
<td><strong>8.550.000đ</strong></td>
</tr>
<tr>
<td>2</td>
<td>
<div class="product-name">Gạch Eurotile STONE GREY S02</div>
<div class="product-sku">SKU: ET-SG-S02-80x80</div>
</td>
<!--<td>m²</td>-->
<td>20,24</td>
<td>217.500đ</td>
<td><strong>4.350.000đ</strong></td>
</tr>
</tbody>
</table>
<!-- Invoice Summary -->
<div class="invoice-summary">
<div class="summary-row">
<span class="summary-label">Tổng tiền hàng:</span>
<span class="summary-value">12.900.000đ</span>
</div>
<div class="summary-row">
<span class="summary-label">Chiết khấu VIP (1%):</span>
<span class="summary-value" style="color: #059669;">-129.000đ</span>
</div>
<!--<div class="summary-row">
<span class="summary-label">Tiền trước thuế:</span>
<span class="summary-value">12.771.000đ</span>
</div>-->
<!--<div class="summary-row">
<span class="summary-label">Thuế GTGT (0%):</span>
<span class="summary-value">0đ</span>
</div>-->
<div class="summary-row total">
<span class="summary-label">TỔNG THANH TOÁN:</span>
<span class="summary-value">12.771.000đ</span>
</div>
</div>
<!-- Notes -->
<!--<div class="invoice-notes">
<h4>Ghi chú:</h4>
<p>- Số tiền viết bằng chữ: <strong>Mười hai triệu bảy trăm bảy mươi mốt nghìn đồng chẵn.</strong></p>
<p>- Hình thức thanh toán: Chuyển khoản ngân hàng</p>
<p>- Hóa đơn điện tử đã được ký số và có giá trị pháp lý</p>
</div>-->
</div>
<!-- Action Buttons -->
<div id="actionButtons" class="action-buttons">
<button class="btn btn-secondary" onclick="contactSupport()">
<i class="fas fa-comments"></i>
Liên hệ hỗ trợ
</button>
</div>
</div>
</div>
<!-- Sticky Footer Actions -->
<!--<div class="invoice-actions">
<div class="invoice-actions-content">
<button class="btn btn-secondary" onclick="downloadPDF()">
<i class="fas fa-download"></i>
Tải xuống PDF
</button>
<button class="btn btn-primary" onclick="sendEmail()">
<i class="fas fa-envelope"></i>
Gửi qua Email
</button>
</div>
</div>-->
</div>
<!-- Toast Notification -->
<div id="toast" class="toast"></div>
<script>
// Show toast notification
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// Download PDF function
function downloadPDF() {
showToast('Đang tải xuống hóa đơn PDF...', 'success');
// Simulate PDF download
setTimeout(() => {
showToast('Hóa đơn đã được tải xuống thành công!', 'success');
// In a real app, this would trigger actual PDF download
// window.location.href = '/api/invoices/INV20240001/download';
}, 1500);
}
// Send Email function
function sendEmail() {
showToast('Đang gửi hóa đơn qua email...', 'success');
// Simulate email sending
setTimeout(() => {
showToast('Hóa đơn đã được gửi đến email: minhlong.org@gmail.com', 'success');
// In a real app, this would call API to send email
// fetch('/api/invoices/INV20240001/send-email', { method: 'POST' })
}, 1500);
}
// Share invoice function
function shareInvoice() {
if (navigator.share) {
navigator.share({
title: 'Hóa đơn #INV20240001',
text: 'Chi tiết hóa đơn EuroTile',
url: window.location.href
}).catch(err => console.log('Error sharing:', err));
} else {
// Fallback to copy link
navigator.clipboard.writeText(window.location.href);
showToast('Đã sao chép link hóa đơn!', 'success');
}
}
// Get invoice ID from URL parameter
const urlParams = new URLSearchParams(window.location.search);
const invoiceId = urlParams.get('id') || 'INV20240001';
// Update page with invoice ID (in real app, would fetch from API)
document.title = `Chi tiết Hóa đơn #${invoiceId} - EuroTile Worker`;
document.querySelector('.invoice-number').textContent = `#${invoiceId}`;
</script>
</body>
</html>

351
html/invoice-list.html Normal file
View File

@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hóa đơn đã mua - EuroTile Worker</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
.invoices-container {
max-width: 480px;
margin: 0 auto;
background: #f8fafc;
min-height: calc(100vh - 120px);
padding: 20px;
}
.invoice-card {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.invoice-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
border-color: #2563eb;
}
.invoice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.invoice-codes {
flex: 1;
}
.invoice-id {
font-weight: 700;
color: #1f2937;
font-size: 16px;
margin-bottom: 4px;
}
.invoice-date {
font-size: 13px;
color: #6b7280;
}
.invoice-status {
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-paid {
background: #d1fae5;
color: #065f46;
}
.status-unpaid {
background: #fef3c7;
color: #d97706;
}
.status-partial {
background: #e0e7ff;
color: #3730a3;
}
.invoice-details {
padding: 12px 0;
border-top: 1px solid #f3f4f6;
border-bottom: 1px solid #f3f4f6;
margin-bottom: 12px;
}
.invoice-detail-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
}
.invoice-detail-row:last-child {
margin-bottom: 0;
}
.invoice-detail-label {
color: #6b7280;
}
.invoice-detail-value {
color: #1f2937;
font-weight: 600;
}
.invoice-detail-value.total {
color: #dc2626;
font-size: 16px;
font-weight: 700;
}
.invoice-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.invoice-company {
font-size: 13px;
color: #6b7280;
display: flex;
align-items: center;
gap: 6px;
}
.invoice-arrow {
color: #9ca3af;
font-size: 16px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: #9ca3af;
}
.empty-state i {
font-size: 64px;
margin-bottom: 20px;
color: #d1d5db;
}
.empty-state h3 {
font-size: 18px;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
.empty-state p {
font-size: 14px;
line-height: 1.5;
}
@media (max-width: 768px) {
.invoices-container {
padding: 15px;
}
.invoice-card {
padding: 14px;
}
}
</style>
</head>
<body>
<div class="page-wrapper">
<!-- Header -->
<div class="header">
<a href="account.html" class="back-button">
<i class="fas fa-arrow-left"></i>
</a>
<h1 class="header-title">Hóa đơn đã mua</h1>
<div style="width: 32px;"></div>
</div>
<div class="invoices-container">
<!-- Invoice Card 1 - Paid -->
<div class="invoice-card" onclick="window.location.href='invoice-detail.html?id=INV20240001'">
<div class="invoice-header">
<div class="invoice-codes">
<div class="invoice-id">#INV20240001</div>
<div class="invoice-date">Ngày xuất: 03/08/2024</div>
</div>
<span class="invoice-status status-paid">Đã thanh toán</span>
</div>
<div class="invoice-details">
<div class="invoice-detail-row">
<span class="invoice-detail-label">Đơn hàng:</span>
<span class="invoice-detail-value">#DH001234</span>
</div>
<div class="invoice-detail-row">
<span class="invoice-detail-label">Tổng tiền:</span>
<span class="invoice-detail-value total">12.771.000đ</span>
</div>
</div>
<!--<div class="invoice-footer">
<div class="invoice-company">
<i class="fas fa-building"></i>
<span>Lê Hoàng Hiệp</span>
</div>
<i class="fas fa-chevron-right invoice-arrow"></i>
</div>-->
</div>
<!-- Invoice Card 2 - Partial -->
<div class="invoice-card" onclick="window.location.href='invoice-detail.html?id=INV20240002'">
<div class="invoice-header">
<div class="invoice-codes">
<div class="invoice-id">#INV20240002</div>
<div class="invoice-date">Ngày xuất: 15/07/2024</div>
</div>
<span class="invoice-status status-partial">Thanh toán 1 phần</span>
</div>
<div class="invoice-details">
<div class="invoice-detail-row">
<span class="invoice-detail-label">Đơn hàng:</span>
<span class="invoice-detail-value">#DH001198</span>
</div>
<div class="invoice-detail-row">
<span class="invoice-detail-label">Tổng tiền:</span>
<span class="invoice-detail-value total">85.600.000đ</span>
</div>
</div>
<!--<div class="invoice-footer">
<div class="invoice-company">
<i class="fas fa-building"></i>
<span>Công ty TNHH Xây dựng Minh Long</span>
</div>
<i class="fas fa-chevron-right invoice-arrow"></i>
</div>-->
</div>
<!-- Invoice Card 3 - Paid -->
<div class="invoice-card" onclick="window.location.href='invoice-detail.html?id=INV20240003'">
<div class="invoice-header">
<div class="invoice-codes">
<div class="invoice-id">#INV20240003</div>
<div class="invoice-date">Ngày xuất: 25/06/2024</div>
</div>
<span class="invoice-status status-paid">Đã thanh toán</span>
</div>
<div class="invoice-details">
<div class="invoice-detail-row">
<span class="invoice-detail-label">Đơn hàng:</span>
<span class="invoice-detail-value">#DH001087</span>
</div>
<div class="invoice-detail-row">
<span class="invoice-detail-label">Tổng tiền:</span>
<span class="invoice-detail-value total">42.500.000đ</span>
</div>
</div>
<!--<div class="invoice-footer">
<div class="invoice-company">
<i class="fas fa-building"></i>
<span>Công ty TNHH Xây dựng Minh Long</span>
</div>
<i class="fas fa-chevron-right invoice-arrow"></i>
</div>-->
</div>
<!-- Invoice Card 4 - Unpaid -->
<div class="invoice-card" onclick="window.location.href='invoice-detail.html?id=INV20240004'">
<div class="invoice-header">
<div class="invoice-codes">
<div class="invoice-id">#INV20240004</div>
<div class="invoice-date">Ngày xuất: 10/06/2024</div>
</div>
<span class="invoice-status status-unpaid">Chưa thanh toán</span>
</div>
<div class="invoice-details">
<div class="invoice-detail-row">
<span class="invoice-detail-label">Đơn hàng:</span>
<span class="invoice-detail-value">#DH000945</span>
</div>
<div class="invoice-detail-row">
<span class="invoice-detail-label">Tổng tiền:</span>
<span class="invoice-detail-value total">28.300.000đ</span>
</div>
</div>
<!--<div class="invoice-footer">
<div class="invoice-company">
<i class="fas fa-building"></i>
<span>Công ty TNHH Xây dựng Minh Long</span>
</div>
<i class="fas fa-chevron-right invoice-arrow"></i>
</div>-->
</div>
<!-- Invoice Card 5 - Paid -->
<div class="invoice-card" onclick="window.location.href='invoice-detail.html?id=INV20240005'">
<div class="invoice-header">
<div class="invoice-codes">
<div class="invoice-id">#INV20240005</div>
<div class="invoice-date">Ngày xuất: 15/05/2024</div>
</div>
<span class="invoice-status status-paid">Đã thanh toán</span>
</div>
<div class="invoice-details">
<div class="invoice-detail-row">
<span class="invoice-detail-label">Đơn hàng:</span>
<span class="invoice-detail-value">#DH000821</span>
</div>
<div class="invoice-detail-row">
<span class="invoice-detail-label">Tổng tiền:</span>
<span class="invoice-detail-value total">56.750.000đ</span>
</div>
</div>
<!--<div class="invoice-footer">
<div class="invoice-company">
<i class="fas fa-building"></i>
<span>Công ty TNHH Xây dựng Minh Long</span>
</div>
<i class="fas fa-chevron-right invoice-arrow"></i>
</div>-->
</div>
</div>
</div>
<script>
// Add animation to cards on page load
document.addEventListener('DOMContentLoaded', function() {
const cards = document.querySelectorAll('.invoice-card');
cards.forEach((card, index) => {
card.style.opacity = '0';
card.style.transform = 'translateY(20px)';
card.style.transition = 'all 0.5s ease';
setTimeout(() => {
card.style.opacity = '1';
card.style.transform = 'translateY(0)';
}, index * 100);
});
});
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '13.0'
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -39,7 +39,7 @@ end
# OneSignal Notification Service Extension (OUTSIDE Runner target)
target 'OneSignalNotificationServiceExtension' do
use_frameworks!
pod 'OneSignalXCFramework', '>= 5.0.0', '< 6.0'
pod 'OneSignalXCFramework', '5.2.14'
end
post_install do |installer|
@@ -48,7 +48,7 @@ post_install do |installer|
# Ensure consistent deployment target
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
end
end

View File

@@ -35,65 +35,132 @@ PODS:
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/CoreOnly (12.4.0):
- FirebaseCore (~> 12.4.0)
- Firebase/Messaging (12.4.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.4.0)
- firebase_analytics (12.0.4):
- firebase_core
- FirebaseAnalytics (= 12.4.0)
- Flutter
- firebase_core (4.2.1):
- Firebase/CoreOnly (= 12.4.0)
- Flutter
- firebase_messaging (16.0.4):
- Firebase/Messaging (= 12.4.0)
- firebase_core
- Flutter
- FirebaseAnalytics (12.4.0):
- FirebaseAnalytics/Default (= 12.4.0)
- FirebaseCore (~> 12.4.0)
- FirebaseInstallations (~> 12.4.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseAnalytics/Default (12.4.0):
- FirebaseCore (~> 12.4.0)
- FirebaseInstallations (~> 12.4.0)
- GoogleAppMeasurement/Default (= 12.4.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- FirebaseCore (12.4.0):
- FirebaseCoreInternal (~> 12.4.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.4.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.4.0):
- FirebaseCore (~> 12.4.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.4.0):
- FirebaseCore (~> 12.4.0)
- FirebaseInstallations (~> 12.4.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Reachability (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- Flutter (1.0.0)
- flutter_secure_storage (6.0.0):
- Flutter
- GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleMLKit/BarcodeScanning (6.0.0):
- GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 5.0.0)
- GoogleMLKit/MLKitCore (6.0.0):
- MLKitCommon (~> 11.0.0)
- GoogleToolboxForMac/Defines (4.2.1)
- GoogleToolboxForMac/Logger (4.2.1):
- GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleUtilities/Environment (7.13.3):
- GoogleAdsOnDeviceConversion (3.1.0):
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Core (12.4.0):
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/Default (12.4.0):
- GoogleAdsOnDeviceConversion (~> 3.1.0)
- GoogleAppMeasurement/Core (= 12.4.0)
- GoogleAppMeasurement/IdentitySupport (= 12.4.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleAppMeasurement/IdentitySupport (12.4.0):
- GoogleAppMeasurement/Core (= 12.4.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/MethodSwizzler (~> 8.1)
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (7.13.3)
- GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/MethodSwizzler (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilitiesComponents (1.1.0):
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- GTMSessionFetcher/Core (3.5.0)
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- image_picker_ios (0.0.1):
- Flutter
- integration_test (0.0.1):
- Flutter
- MLImage (1.0.0-beta5)
- MLKitBarcodeScanning (5.0.0):
- MLKitCommon (~> 11.0)
- MLKitVision (~> 7.0)
- MLKitCommon (11.0.0):
- GoogleDataTransport (< 10.0, >= 9.4.1)
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GoogleUtilities/UserDefaults (< 8.0, >= 7.13.0)
- GoogleUtilitiesComponents (~> 1.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitVision (7.0.0):
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta5)
- MLKitCommon (~> 11.0)
- mobile_scanner (5.2.3):
- mobile_scanner (7.0.0):
- Flutter
- GoogleMLKit/BarcodeScanning (~> 6.0.0)
- nanopb (2.30910.0):
- nanopb/decode (= 2.30910.0)
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- FlutterMacOS
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- onesignal_flutter (5.3.4):
- Flutter
- OneSignalXCFramework (= 5.2.14)
@@ -149,9 +216,9 @@ PODS:
- Flutter
- FlutterMacOS
- PromisesObjC (2.4.0)
- SDWebImage (5.21.3):
- SDWebImage/Core (= 5.21.3)
- SDWebImage/Core (5.21.3)
- SDWebImage (5.21.4):
- SDWebImage/Core (= 5.21.4)
- SDWebImage/Core (5.21.4)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -167,13 +234,16 @@ PODS:
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- onesignal_flutter (from `.symlinks/plugins/onesignal_flutter/ios`)
- OneSignalXCFramework (< 6.0, >= 5.0.0)
- OneSignalXCFramework (= 5.2.14)
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -185,16 +255,16 @@ SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleAdsOnDeviceConversion
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GoogleUtilitiesComponents
- GTMSessionFetcher
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
- MLKitVision
- nanopb
- OneSignalXCFramework
- PromisesObjC
@@ -206,6 +276,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/connectivity_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter:
:path: Flutter
flutter_secure_storage:
@@ -215,7 +291,7 @@ EXTERNAL SOURCES:
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/ios"
:path: ".symlinks/plugins/mobile_scanner/darwin"
onesignal_flutter:
:path: ".symlinks/plugins/onesignal_flutter/ios"
open_file_ios:
@@ -236,34 +312,37 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
firebase_analytics: 2b372cc13c077de5f1ac37e232bacd5bacb41963
firebase_core: e6b8bb503b7d1d9856e698d4f193f7b414e6bf1f
firebase_messaging: fc7b6af84f4cd885a4999f51ea69ef20f380d70d
FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
FirebaseCoreInternal: d7f5a043c2cd01a08103ab586587c1468047bca6
FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2
FirebaseMessaging: d33971b7bb252745ea6cd31ab190d1a1df4b8ed5
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1
GoogleAppMeasurement: 1e718274b7e015cefd846ac1fcf7820c70dc017d
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
MLKitBarcodeScanning: 10ca0845a6d15f2f6e911f682a1998b68b973e8b
MLKitCommon: afec63980417d29ffbb4790529a1b0a2291699e1
MLKitVision: e858c5f125ecc288e4a31127928301eaba9ae0c1
mobile_scanner: 96e91f2e1fb396bb7df8da40429ba8dfad664740
nanopb: 438bc412db1928dac798aa6fd75726007be04262
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
onesignal_flutter: f69ff09eeaf41cea4b7a841de5a61e79e7fd9a5a
OneSignalXCFramework: 7112f3e89563e41ebc23fe807788f11985ac541c
open_file_ios: 461db5853723763573e140de3193656f91990d9e
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
PODFILE CHECKSUM: 41022e80ca79dfdcc337fcf6a6cca3b7d3cb6958
PODFILE CHECKSUM: d8ed968486e3d2e023338569c014e9285ba7bc53
COCOAPODS: 1.16.2

View File

@@ -10,6 +10,7 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
41D57CDF80C517B01729B4E6 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 1D94728EBD25906AAA78A0D5 /* GoogleService-Info.plist */; };
48D410762ED7067500A8B931 /* OneSignalNotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 48D4106F2ED7067500A8B931 /* OneSignalNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
58215889146B2DBBD9C81410 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2545A56CA7C5FCC88F0D6DF7 /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
@@ -67,6 +68,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
18121E1016DEC4038E74F1F0 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
1D94728EBD25906AAA78A0D5 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
23D173C6FEE4F53025C06238 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
2545A56CA7C5FCC88F0D6DF7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
@@ -175,6 +177,7 @@
331C8082294A63A400263BE5 /* RunnerTests */,
D39C332D04678D8C49EEA401 /* Pods */,
E0C416BADC6D23D3F5D8CCA9 /* Frameworks */,
1D94728EBD25906AAA78A0D5 /* GoogleService-Info.plist */,
);
sourceTree = "<group>";
};
@@ -365,6 +368,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
41D57CDF80C517B01729B4E6 /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -73,6 +73,12 @@
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-FIRDebugEnabled"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"

View File

@@ -7,6 +7,11 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// #if DEBUG
// var args = ProcessInfo.processInfo.arguments
// args.append("-FIRDebugEnabled")
// ProcessInfo.processInfo.setValue(args, forKey: "arguments")
// #endif
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyAMgNFpkK0ss_uzNl51OqGyQHd0vFc9SxQ</string>
<key>GCM_SENDER_ID</key>
<string>147309310656</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.dbiz.partner</string>
<key>PROJECT_ID</key>
<string>dbiz-partner</string>
<key>STORAGE_BUCKET</key>
<string>dbiz-partner.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:147309310656:ios:aa59724d2c6b4620dc7325</string>
</dict>
</plist>

View File

@@ -34,6 +34,8 @@
<string>Ứng dụng cần quyền truy cập microphone để ghi âm video nếu cần</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Ứng dụng cần quyền truy cập thư viện ảnh để chọn ảnh giấy tờ xác thực và mã QR từ thiết bị của bạn</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Ứng dụng sử dụng vị trí để cải thiện trải nghiệm và đề xuất showroom gần bạn</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>

View File

@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/app_theme.dart';
import 'package:worker/core/theme/theme_provider.dart';
import 'package:worker/generated/l10n/app_localizations.dart';
/// Root application widget for Worker Mobile App
@@ -22,6 +23,9 @@ class WorkerApp extends ConsumerWidget {
// Watch router provider to get auth-aware router
final router = ref.watch(routerProvider);
// Watch theme settings for dynamic theming
final themeSettings = ref.watch(themeSettingsProvider);
return MaterialApp.router(
// ==================== App Configuration ====================
debugShowCheckedModeBanner: false,
@@ -33,10 +37,10 @@ class WorkerApp extends ConsumerWidget {
routerConfig: router,
// ==================== Theme Configuration ====================
// Material 3 theme with brand colors (Primary Blue: #005B9A)
theme: AppTheme.lightTheme(),
darkTheme: AppTheme.darkTheme(),
themeMode: ThemeMode.light, // TODO: Make this configurable from settings
// Material 3 theme with dynamic seed color from settings
theme: AppTheme.lightTheme(themeSettings.seedColor),
darkTheme: AppTheme.darkTheme(themeSettings.seedColor),
themeMode: themeSettings.themeMode,
// ==================== Localization Configuration ====================
// Support for Vietnamese (primary) and English (secondary)
localizationsDelegates: const [

View File

@@ -271,6 +271,30 @@ class ApiConstants {
/// GET /payments/{paymentId}
static const String getPaymentDetails = '/payments';
/// Get payment list (Frappe API)
/// POST /api/method/building_material.building_material.api.payment.get_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
static const String getPaymentList =
'/building_material.building_material.api.payment.get_list';
/// Get payment detail (Frappe API)
/// POST /api/method/building_material.building_material.api.payment.get_detail
/// Body: { "name": "ACC-PAY-2025-00020" }
static const String getPaymentDetail =
'/building_material.building_material.api.payment.get_detail';
/// Get invoice list (Frappe API)
/// POST /api/method/building_material.building_material.api.invoice.get_list
/// Body: { "limit_start": 0, "limit_page_length": 0 }
static const String getInvoiceList =
'/building_material.building_material.api.invoice.get_list';
/// Get invoice detail (Frappe API)
/// POST /api/method/building_material.building_material.api.invoice.get_detail
/// Body: { "name": "ACC-SINV-2025-00041" }
static const String getInvoiceDetail =
'/building_material.building_material.api.invoice.get_detail';
// ============================================================================
// Project Endpoints (Frappe ERPNext)
// ============================================================================

View File

@@ -0,0 +1,161 @@
import 'package:hive_ce/hive.dart';
/// Central app settings storage using Hive
///
/// This box stores all app-level settings including:
/// - Theme settings (seed color, theme mode)
/// - Language preferences
/// - Notification settings
/// - User preferences
///
/// See APP_SETTINGS.md for complete documentation.
class AppSettingsBox {
AppSettingsBox._();
static const String boxName = 'app_settings';
// ==================== Keys ====================
// Theme Settings
static const String seedColorId = 'seed_color_id';
static const String themeMode = 'theme_mode';
// Language Settings
static const String languageCode = 'language_code';
// Notification Settings
static const String notificationsEnabled = 'notifications_enabled';
static const String orderNotifications = 'order_notifications';
static const String promotionNotifications = 'promotion_notifications';
static const String chatNotifications = 'chat_notifications';
// User Preferences
static const String onboardingCompleted = 'onboarding_completed';
static const String biometricEnabled = 'biometric_enabled';
static const String rememberLogin = 'remember_login';
// App State
static const String lastSyncTime = 'last_sync_time';
static const String appVersion = 'app_version';
static const String firstLaunchDate = 'first_launch_date';
// ==================== Box Instance ====================
static Box<dynamic>? _box;
/// Get the app settings box instance
static Box<dynamic> get box {
if (_box == null || !_box!.isOpen) {
throw StateError(
'AppSettingsBox not initialized. Call AppSettingsBox.init() first.',
);
}
return _box!;
}
/// Initialize the app settings box - call before runApp()
static Future<void> init() async {
_box = await Hive.openBox<dynamic>(boxName);
}
/// Close the box
static Future<void> close() async {
await _box?.close();
_box = null;
}
// ==================== Generic Getters/Setters ====================
/// Get a value from the box
static T? get<T>(String key, {T? defaultValue}) {
return box.get(key, defaultValue: defaultValue) as T?;
}
/// Set a value in the box
static Future<void> set<T>(String key, T value) async {
await box.put(key, value);
}
/// Remove a value from the box
static Future<void> remove(String key) async {
await box.delete(key);
}
/// Check if a key exists
static bool has(String key) {
return box.containsKey(key);
}
/// Clear all settings
static Future<void> clear() async {
await box.clear();
}
// ==================== Theme Helpers ====================
/// Get seed color ID
static String getSeedColorId() {
return get<String>(seedColorId, defaultValue: 'blue') ?? 'blue';
}
/// Set seed color ID
static Future<void> setSeedColorId(String colorId) async {
await set(seedColorId, colorId);
}
/// Get theme mode index (0=system, 1=light, 2=dark)
static int getThemeModeIndex() {
return get<int>(themeMode, defaultValue: 0) ?? 0;
}
/// Set theme mode index
static Future<void> setThemeModeIndex(int index) async {
await set(themeMode, index);
}
// ==================== Language Helpers ====================
/// Get language code (vi, en)
static String getLanguageCode() {
return get<String>(languageCode, defaultValue: 'vi') ?? 'vi';
}
/// Set language code
static Future<void> setLanguageCode(String code) async {
await set(languageCode, code);
}
// ==================== Notification Helpers ====================
/// Check if notifications are enabled
static bool areNotificationsEnabled() {
return get<bool>(notificationsEnabled, defaultValue: true) ?? true;
}
/// Set notifications enabled
static Future<void> setNotificationsEnabled(bool enabled) async {
await set(notificationsEnabled, enabled);
}
// ==================== User Preference Helpers ====================
/// Check if onboarding is completed
static bool isOnboardingCompleted() {
return get<bool>(onboardingCompleted, defaultValue: false) ?? false;
}
/// Set onboarding completed
static Future<void> setOnboardingCompleted(bool completed) async {
await set(onboardingCompleted, completed);
}
/// Check if biometric is enabled
static bool isBiometricEnabled() {
return get<bool>(biometricEnabled, defaultValue: false) ?? false;
}
/// Set biometric enabled
static Future<void> setBiometricEnabled(bool enabled) async {
await set(biometricEnabled, enabled);
}
}

View File

@@ -10,6 +10,7 @@ library;
import 'dart:developer' as developer;
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -569,10 +570,10 @@ Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
@riverpod
LoggingInterceptor loggingInterceptor(Ref ref) {
// Only enable logging in debug mode
const bool isDebug = true; // TODO: Replace with kDebugMode from Flutter
const bool isDebug = kDebugMode; // TODO: Replace with kDebugMode from Flutter
return LoggingInterceptor(
enableRequestLogging: false,
enableRequestLogging: true,
enableResponseLogging: isDebug,
enableErrorLogging: isDebug,
);

View File

@@ -189,7 +189,7 @@ final class LoggingInterceptorProvider
}
String _$loggingInterceptorHash() =>
r'6afa480caa6fcd723dab769bb01601b8a37e20fd';
r'79e90e0eb78663d2645d2d7c467e01bc18a30551';
/// Provider for ErrorTransformerInterceptor

View File

@@ -13,6 +13,7 @@ import 'dart:developer' as developer;
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:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

View File

@@ -31,7 +31,7 @@ Future<User> user(UserRef ref, String id) async {
final userAsync = ref.watch(userProvider('123'));
userAsync.when(
data: (user) => Text(user.name),
loading: () => CircularProgressIndicator(),
loading: () => const CustomLoadingIndicator(),
error: (e, _) => Text('Error: $e'),
);
```
@@ -202,7 +202,7 @@ final newValue = ref.refresh(userProvider);
```dart
asyncValue.when(
data: (value) => Text(value),
loading: () => CircularProgressIndicator(),
loading: () => const CustomLoadingIndicator(),
error: (error, stack) => Text('Error: $error'),
);
```
@@ -215,7 +215,7 @@ switch (asyncValue) {
case AsyncError(:final error):
return Text('Error: $error');
case AsyncLoading():
return CircularProgressIndicator();
return const CustomLoadingIndicator();
}
```

View File

@@ -30,7 +30,7 @@ Connectivity connectivity(Ref ref) {
/// final connectivityState = ref.watch(connectivityStreamProvider);
/// connectivityState.when(
/// data: (status) => Text('Status: $status'),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
@@ -83,7 +83,7 @@ Future<ConnectivityStatus> currentConnectivity(Ref ref) async {
/// final isOnlineAsync = ref.watch(isOnlineProvider);
/// isOnlineAsync.when(
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```

View File

@@ -65,7 +65,7 @@ String _$connectivityHash() => r'6d67af0ea4110f6ee0246dd332f90f8901380eda';
/// final connectivityState = ref.watch(connectivityStreamProvider);
/// connectivityState.when(
/// data: (status) => Text('Status: $status'),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
@@ -81,7 +81,7 @@ const connectivityStreamProvider = ConnectivityStreamProvider._();
/// final connectivityState = ref.watch(connectivityStreamProvider);
/// connectivityState.when(
/// data: (status) => Text('Status: $status'),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
@@ -104,7 +104,7 @@ final class ConnectivityStreamProvider
/// final connectivityState = ref.watch(connectivityStreamProvider);
/// connectivityState.when(
/// data: (status) => Text('Status: $status'),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
@@ -219,7 +219,7 @@ String _$currentConnectivityHash() =>
/// final isOnlineAsync = ref.watch(isOnlineProvider);
/// isOnlineAsync.when(
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
@@ -235,7 +235,7 @@ const isOnlineProvider = IsOnlineProvider._();
/// final isOnlineAsync = ref.watch(isOnlineProvider);
/// isOnlineAsync.when(
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```
@@ -251,7 +251,7 @@ final class IsOnlineProvider
/// final isOnlineAsync = ref.watch(isOnlineProvider);
/// isOnlineAsync.when(
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, _) => Text('Error: $error'),
/// );
/// ```

View File

@@ -428,7 +428,7 @@ final version = ref.watch(appVersionProvider);
final userData = ref.watch(userDataProvider);
userData.when(
data: (data) => Text(data),
loading: () => CircularProgressIndicator(),
loading: () => const CustomLoadingIndicator(),
error: (error, stack) => Text('Error: $error'),
);
@@ -466,7 +466,7 @@ switch (profileState) {
case AsyncError(:final error):
return Text('Error: $error');
case AsyncLoading():
return CircularProgressIndicator();
return const CustomLoadingIndicator();
}
*/

View File

@@ -7,6 +7,7 @@ library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/services/analytics_service.dart';
import 'package:worker/features/account/domain/entities/address.dart';
import 'package:worker/features/account/presentation/pages/address_form_page.dart';
@@ -51,6 +52,9 @@ import 'package:worker/features/showrooms/presentation/pages/design_request_crea
import 'package:worker/features/showrooms/presentation/pages/design_request_detail_page.dart';
import 'package:worker/features/showrooms/presentation/pages/model_house_detail_page.dart';
import 'package:worker/features/showrooms/presentation/pages/model_houses_page.dart';
import 'package:worker/features/account/presentation/pages/theme_settings_page.dart';
import 'package:worker/features/invoices/presentation/pages/invoices_page.dart';
import 'package:worker/features/invoices/presentation/pages/invoice_detail_page.dart';
/// Router Provider
///
@@ -61,7 +65,7 @@ final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
// Initial route - start with splash screen
initialLocation: RouteNames.splash,
observers: [AnalyticsService.observer],
// Redirect based on auth state
redirect: (context, state) {
final isLoading = authState.isLoading;
@@ -128,16 +132,22 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(
path: RouteNames.splash,
name: RouteNames.splash,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const SplashPage()),
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
name: RouteNames.splash,
child: const SplashPage(),
),
),
// Authentication Routes
GoRoute(
path: RouteNames.login,
name: RouteNames.login,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const LoginPage()),
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
name: RouteNames.login,
child: const LoginPage(),
),
),
GoRoute(
path: RouteNames.forgotPassword,
@@ -189,16 +199,22 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(
path: RouteNames.home,
name: RouteNames.home,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const MainScaffold()),
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
name: 'home',
child: const MainScaffold(),
),
),
// Products Route (full screen, no bottom nav)
GoRoute(
path: RouteNames.products,
name: RouteNames.products,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const ProductsPage()),
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
name: 'products',
child: const ProductsPage(),
),
),
// Product Detail Route
@@ -209,6 +225,7 @@ final routerProvider = Provider<GoRouter>((ref) {
final productId = state.pathParameters['id'];
return MaterialPage(
key: state.pageKey,
name: 'product_detail',
child: ProductDetailPage(productId: productId ?? ''),
);
},
@@ -221,6 +238,7 @@ final routerProvider = Provider<GoRouter>((ref) {
final productId = state.pathParameters['id'];
return MaterialPage(
key: state.pageKey,
name: 'write_review',
child: WriteReviewPage(productId: productId ?? ''),
);
},
@@ -236,6 +254,7 @@ final routerProvider = Provider<GoRouter>((ref) {
final promotionId = state.pathParameters['id'];
return MaterialPage(
key: state.pageKey,
name: 'promotion_detail',
child: PromotionDetailPage(promotionId: promotionId),
);
},
@@ -245,8 +264,11 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute(
path: RouteNames.cart,
name: RouteNames.cart,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const CartPage()),
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
name: 'cart',
child: const CartPage(),
),
),
// Checkout Route
@@ -349,14 +371,9 @@ final routerProvider = Provider<GoRouter>((ref) {
name: RouteNames.paymentQr,
pageBuilder: (context, state) {
final orderId = state.uri.queryParameters['orderId'] ?? '';
final amountStr = state.uri.queryParameters['amount'] ?? '0';
final amount = double.tryParse(amountStr) ?? 0.0;
return MaterialPage(
key: state.pageKey,
child: PaymentQrPage(
orderId: orderId,
amount: amount,
),
child: PaymentQrPage(orderId: orderId),
);
},
),
@@ -484,6 +501,35 @@ final routerProvider = Provider<GoRouter>((ref) {
MaterialPage(key: state.pageKey, child: const ChangePasswordPage()),
),
// Theme Settings Route
GoRoute(
path: RouteNames.themeSettings,
name: RouteNames.themeSettings,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const ThemeSettingsPage()),
),
// Invoices Route
GoRoute(
path: RouteNames.invoices,
name: RouteNames.invoices,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const InvoicesPage()),
),
// Invoice Detail Route
GoRoute(
path: RouteNames.invoiceDetail,
name: RouteNames.invoiceDetail,
pageBuilder: (context, state) {
final invoiceId = state.pathParameters['id'];
return MaterialPage(
key: state.pageKey,
child: InvoiceDetailPage(invoiceId: invoiceId ?? ''),
);
},
),
// Chat List Route
GoRoute(
path: RouteNames.chat,
@@ -631,8 +677,13 @@ class RouteNames {
static const String addresses = '$account/addresses';
static const String addressForm = '$addresses/form';
static const String changePassword = '$account/change-password';
static const String themeSettings = '$account/theme-settings';
static const String settings = '$account/settings';
// Invoice Routes
static const String invoices = '/invoices';
static const String invoiceDetail = '$invoices/:id';
// Promotions & Notifications Routes
static const String promotions = '/promotions';
static const String promotionDetail = '$promotions/:id';

View File

@@ -0,0 +1,362 @@
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// Firebase Analytics service for tracking user events across the app.
///
/// Usage:
/// ```dart
/// // Log add to cart event
/// AnalyticsService.logAddToCart(
/// productId: 'SKU123',
/// productName: 'Gạch men 60x60',
/// price: 150000,
/// quantity: 2,
/// );
/// ```
class AnalyticsService {
AnalyticsService._();
static final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;
/// Get the analytics instance for NavigatorObserver
static FirebaseAnalytics get instance => _analytics;
/// Get the observer for automatic screen tracking in GoRouter
static FirebaseAnalyticsObserver get observer => FirebaseAnalyticsObserver(
analytics: _analytics,
nameExtractor: (settings) {
// GoRouter uses the path as the route name
final name = settings.name;
if (name != null && name.isNotEmpty && name != '/') {
return name;
}
return settings.name ?? '/';
},
routeFilter: (route) => route is PageRoute,
);
/// Log screen view manually
static Future<void> logScreenView({
required String screenName,
String? screenClass,
}) async {
try {
await _analytics.logScreenView(
screenName: screenName,
screenClass: screenClass,
);
debugPrint('📊 Analytics: screen_view - $screenName');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
// ============================================================================
// E-commerce Events
// ============================================================================
/// Log view item event - when user views product detail
static Future<void> logViewItem({
required String productId,
required String productName,
required double price,
String? brand,
String? category,
}) async {
try {
await _analytics.logViewItem(
currency: 'VND',
value: price,
items: [
AnalyticsEventItem(
itemId: productId,
itemName: productName,
price: price,
itemBrand: brand,
itemCategory: category,
),
],
);
debugPrint('📊 Analytics: view_item - $productName');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Log add to cart event
static Future<void> logAddToCart({
required String productId,
required String productName,
required double price,
required int quantity,
String? brand,
}) async {
try {
await _analytics.logAddToCart(
currency: 'VND',
value: price * quantity,
items: [
AnalyticsEventItem(
itemId: productId,
itemName: productName,
price: price,
quantity: quantity,
itemBrand: brand,
),
],
);
debugPrint('📊 Analytics: add_to_cart - $productName x$quantity');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Log remove from cart event
static Future<void> logRemoveFromCart({
required String productId,
required String productName,
required double price,
required int quantity,
}) async {
try {
await _analytics.logRemoveFromCart(
currency: 'VND',
value: price * quantity,
items: [
AnalyticsEventItem(
itemId: productId,
itemName: productName,
price: price,
quantity: quantity,
),
],
);
debugPrint('📊 Analytics: remove_from_cart - $productName');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Log view cart event
static Future<void> logViewCart({
required double cartValue,
required List<AnalyticsEventItem> items,
}) async {
try {
await _analytics.logViewCart(
currency: 'VND',
value: cartValue,
items: items,
);
debugPrint('📊 Analytics: view_cart - ${items.length} items');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Log begin checkout event
static Future<void> logBeginCheckout({
required double value,
required List<AnalyticsEventItem> items,
String? coupon,
}) async {
try {
await _analytics.logBeginCheckout(
currency: 'VND',
value: value,
items: items,
coupon: coupon,
);
debugPrint('📊 Analytics: begin_checkout - $value VND');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Log purchase event - when order is completed
static Future<void> logPurchase({
required String orderId,
required double value,
required List<AnalyticsEventItem> items,
double? shipping,
double? tax,
String? coupon,
}) async {
try {
await _analytics.logPurchase(
currency: 'VND',
transactionId: orderId,
value: value,
items: items,
shipping: shipping,
tax: tax,
coupon: coupon,
);
debugPrint('📊 Analytics: purchase - Order $orderId');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
// ============================================================================
// Search & Discovery Events
// ============================================================================
/// Log search event
static Future<void> logSearch({
required String searchTerm,
}) async {
try {
await _analytics.logSearch(searchTerm: searchTerm);
debugPrint('📊 Analytics: search - $searchTerm');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Log select item event - when user taps on a product in list
static Future<void> logSelectItem({
required String productId,
required String productName,
String? listName,
}) async {
try {
await _analytics.logSelectItem(
itemListName: listName,
items: [
AnalyticsEventItem(
itemId: productId,
itemName: productName,
),
],
);
debugPrint('📊 Analytics: select_item - $productName');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
// ============================================================================
// Loyalty & Rewards Events
// ============================================================================
/// Log earn points event
static Future<void> logEarnPoints({
required int points,
required String source,
}) async {
try {
await _analytics.logEarnVirtualCurrency(
virtualCurrencyName: 'loyalty_points',
value: points.toDouble(),
);
debugPrint('📊 Analytics: earn_points - $points from $source');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Log spend points event - when user redeems points
static Future<void> logSpendPoints({
required int points,
required String itemName,
}) async {
try {
await _analytics.logSpendVirtualCurrency(
virtualCurrencyName: 'loyalty_points',
value: points.toDouble(),
itemName: itemName,
);
debugPrint('📊 Analytics: spend_points - $points for $itemName');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
// ============================================================================
// User Events
// ============================================================================
/// Log login event
static Future<void> logLogin({
String? method,
}) async {
try {
await _analytics.logLogin(loginMethod: method ?? 'phone');
debugPrint('📊 Analytics: login - $method');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Log sign up event
static Future<void> logSignUp({
String? method,
}) async {
try {
await _analytics.logSignUp(signUpMethod: method ?? 'phone');
debugPrint('📊 Analytics: sign_up - $method');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Log share event
static Future<void> logShare({
required String contentType,
required String itemId,
String? method,
}) async {
try {
await _analytics.logShare(
contentType: contentType,
itemId: itemId,
method: method ?? 'unknown',
);
debugPrint('📊 Analytics: share - $contentType $itemId');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
// ============================================================================
// Custom Events
// ============================================================================
/// Log custom event
static Future<void> logEvent({
required String name,
Map<String, Object>? parameters,
}) async {
try {
await _analytics.logEvent(name: name, parameters: parameters);
debugPrint('📊 Analytics: $name');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Set user ID for analytics
static Future<void> setUserId(String? userId) async {
try {
await _analytics.setUserId(id: userId);
debugPrint('📊 Analytics: setUserId - $userId');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
/// Set user property
static Future<void> setUserProperty({
required String name,
required String? value,
}) async {
try {
await _analytics.setUserProperty(name: name, value: value);
debugPrint('📊 Analytics: setUserProperty - $name: $value');
} catch (e) {
debugPrint('📊 Analytics error: $e');
}
}
}

View File

@@ -87,7 +87,7 @@ class FrappeAuthService {
}
}
final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeLogin}';
const url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeLogin}';
// Build cookie header
final storedSession = await getStoredSession();

View File

@@ -5,61 +5,57 @@ 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
/// Uses ColorScheme.fromSeed() to auto-generate harmonious colors from brand seed color
class AppTheme {
// Prevent instantiation
AppTheme._();
// ==================== Light Theme ====================
/// Light theme configuration
static ThemeData lightTheme() {
/// [seedColor] - Optional custom seed color, defaults to AppColors.defaultSeedColor
static ThemeData lightTheme([Color? seedColor]) {
final seed = seedColor ?? AppColors.defaultSeedColor;
final ColorScheme colorScheme = ColorScheme.fromSeed(
seedColor: AppColors.primaryBlue,
seedColor: seed,
brightness: Brightness.light,
primary: AppColors.primaryBlue,
secondary: AppColors.lightBlue,
tertiary: AppColors.accentCyan,
error: AppColors.danger,
surface: AppColors.white,
);
).copyWith(primary: seed);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
fontFamily: AppTypography.fontFamily,
// ==================== App Bar Theme ====================
// AppBar uses colorScheme colors
appBarTheme: AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
centerTitle: false,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
titleTextStyle: AppTypography.titleLarge.copyWith(
color: AppColors.white,
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
iconTheme: const IconThemeData(color: AppColors.white, size: 24),
systemOverlayStyle: SystemUiOverlayStyle.light,
iconTheme: IconThemeData(color: colorScheme.onSurface, size: 24),
systemOverlayStyle: SystemUiOverlayStyle.dark,
),
// ==================== Card Theme ====================
cardTheme: const CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
// Card Theme
cardTheme: CardThemeData(
elevation: 1,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
clipBehavior: Clip.antiAlias,
color: AppColors.white,
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: colorScheme.surface,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
// ==================== Elevated Button Theme ====================
// Elevated Button Theme
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
elevation: 2,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 1,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: AppTypography.buttonText,
@@ -67,21 +63,21 @@ class AppTheme {
),
),
// ==================== Text Button Theme ====================
// Text Button Theme
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
foregroundColor: colorScheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: AppTypography.buttonText,
),
),
// ==================== Outlined Button Theme ====================
// Outlined Button Theme
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
side: const BorderSide(color: AppColors.primaryBlue, width: 1.5),
foregroundColor: colorScheme.primary,
side: BorderSide(color: colorScheme.outline, width: 1),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: AppTypography.buttonText,
@@ -89,252 +85,203 @@ class AppTheme {
),
),
// ==================== Input Decoration Theme ====================
// Input Decoration Theme
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
fillColor: colorScheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.grey100, width: 1),
borderSide: BorderSide(color: colorScheme.outline, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.grey100, width: 1),
borderSide: BorderSide(color: colorScheme.outline, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 1),
borderSide: BorderSide(color: colorScheme.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 2),
borderSide: BorderSide(color: colorScheme.error, width: 2),
),
labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger),
labelStyle: AppTypography.bodyMedium.copyWith(
color: colorScheme.onSurfaceVariant,
),
hintStyle: AppTypography.bodyMedium.copyWith(
color: colorScheme.onSurfaceVariant,
),
errorStyle: AppTypography.bodySmall.copyWith(color: colorScheme.error),
),
// ==================== Bottom Navigation Bar Theme ====================
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.white,
selectedItemColor: AppColors.primaryBlue,
unselectedItemColor: AppColors.grey500,
selectedIconTheme: IconThemeData(
size: 28,
color: AppColors.primaryBlue,
// Bottom Navigation Bar Theme
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: colorScheme.surface,
selectedItemColor: colorScheme.primary,
unselectedItemColor: colorScheme.onSurfaceVariant,
selectedIconTheme: IconThemeData(size: 28, color: colorScheme.primary),
unselectedIconTheme: IconThemeData(
size: 24,
color: colorScheme.onSurfaceVariant,
),
unselectedIconTheme: IconThemeData(size: 24, color: AppColors.grey500),
selectedLabelStyle: TextStyle(
selectedLabelStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
fontFamily: AppTypography.fontFamily,
),
unselectedLabelStyle: TextStyle(
unselectedLabelStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
fontFamily: AppTypography.fontFamily,
),
type: BottomNavigationBarType.fixed,
elevation: 8,
elevation: 3,
),
// ==================== Floating Action Button Theme ====================
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: AppColors.accentCyan,
foregroundColor: AppColors.white,
elevation: 6,
shape: CircleBorder(),
iconSize: 24,
// Floating Action Button Theme
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colorScheme.primaryContainer,
foregroundColor: colorScheme.onPrimaryContainer,
elevation: 3,
shape: const CircleBorder(),
),
// ==================== Chip Theme ====================
// Chip Theme
chipTheme: ChipThemeData(
backgroundColor: AppColors.grey50,
selectedColor: AppColors.primaryBlue,
disabledColor: AppColors.grey100,
secondarySelectedColor: AppColors.lightBlue,
backgroundColor: colorScheme.surfaceContainerLow,
selectedColor: colorScheme.primaryContainer,
disabledColor: colorScheme.surfaceContainerLowest,
labelStyle: AppTypography.labelMedium,
secondaryLabelStyle: AppTypography.labelMedium.copyWith(
color: AppColors.white,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
// ==================== 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,
),
),
// Dialog Theme
dialogTheme: DialogThemeData(
backgroundColor: colorScheme.surface,
elevation: 3,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
titleTextStyle: AppTypography.headlineMedium.copyWith(
color: colorScheme.onSurface,
),
contentTextStyle: AppTypography.bodyLarge.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
// ==================== Snackbar Theme ====================
// Snackbar Theme
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.grey900,
backgroundColor: colorScheme.inverseSurface,
contentTextStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.white,
color: colorScheme.onInverseSurface,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
behavior: SnackBarBehavior.floating,
elevation: 4,
elevation: 3,
),
// ==================== Divider Theme ====================
dividerTheme: const DividerThemeData(
color: AppColors.grey100,
// Divider Theme
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant,
thickness: 1,
space: 1,
),
// ==================== Icon Theme ====================
iconTheme: const IconThemeData(color: AppColors.grey900, size: 24),
// Icon Theme
iconTheme: IconThemeData(color: colorScheme.onSurface, size: 24),
// ==================== List Tile Theme ====================
// List Tile Theme
listTileTheme: ListTileThemeData(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
titleTextStyle: AppTypography.titleMedium.copyWith(
color: AppColors.grey900,
color: colorScheme.onSurface,
),
subtitleTextStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
iconColor: AppColors.grey500,
iconColor: colorScheme.onSurfaceVariant,
),
// ==================== Switch Theme ====================
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.primaryBlue;
}
return AppColors.grey500;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.lightBlue;
}
return AppColors.grey100;
}),
// Progress Indicator Theme
progressIndicatorTheme: ProgressIndicatorThemeData(
color: colorScheme.primary,
linearTrackColor: colorScheme.surfaceContainerHighest,
circularTrackColor: colorScheme.surfaceContainerHighest,
),
// ==================== Checkbox Theme ====================
checkboxTheme: CheckboxThemeData(
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.primaryBlue;
}
return AppColors.white;
}),
checkColor: WidgetStateProperty.all(AppColors.white),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
// ==================== Radio Theme ====================
radioTheme: RadioThemeData(
fillColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.primaryBlue;
}
return AppColors.grey500;
}),
),
// ==================== Progress Indicator Theme ====================
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.primaryBlue,
linearTrackColor: AppColors.grey100,
circularTrackColor: AppColors.grey100,
),
// ==================== Badge Theme ====================
// Badge Theme
badgeTheme: const BadgeThemeData(
backgroundColor: AppColors.danger,
textColor: AppColors.white,
textColor: Colors.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,
),
// Tab Bar Theme
tabBarTheme: TabBarThemeData(
labelColor: colorScheme.primary,
unselectedLabelColor: colorScheme.onSurfaceVariant,
indicatorColor: colorScheme.primary,
labelStyle: AppTypography.labelLarge,
unselectedLabelStyle: AppTypography.labelLarge,
),
);
}
// ==================== Dark Theme ====================
/// Dark theme configuration
static ThemeData darkTheme() {
/// [seedColor] - Optional custom seed color, defaults to AppColors.defaultSeedColor
static ThemeData darkTheme([Color? seedColor]) {
final seed = seedColor ?? AppColors.defaultSeedColor;
final ColorScheme colorScheme = ColorScheme.fromSeed(
seedColor: AppColors.primaryBlue,
seedColor: seed,
brightness: Brightness.dark,
primary: AppColors.lightBlue,
secondary: AppColors.accentCyan,
tertiary: AppColors.primaryBlue,
error: AppColors.danger,
surface: const Color(0xFF1E1E1E),
);
).copyWith(primary: seed);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
fontFamily: AppTypography.fontFamily,
// ==================== App Bar Theme ====================
// AppBar Theme
appBarTheme: AppBarTheme(
elevation: 0,
centerTitle: true,
backgroundColor: const Color(0xFF1E1E1E),
foregroundColor: AppColors.white,
centerTitle: false,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
titleTextStyle: AppTypography.titleLarge.copyWith(
color: AppColors.white,
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
iconTheme: const IconThemeData(color: AppColors.white, size: 24),
iconTheme: IconThemeData(color: colorScheme.onSurface, size: 24),
systemOverlayStyle: SystemUiOverlayStyle.light,
),
// ==================== Card Theme ====================
cardTheme: const CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
// Card Theme
cardTheme: CardThemeData(
elevation: 1,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
clipBehavior: Clip.antiAlias,
color: Color(0xFF1E1E1E),
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: colorScheme.surfaceContainer,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
// ==================== Elevated Button Theme ====================
// Elevated Button Theme
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.lightBlue,
foregroundColor: AppColors.white,
elevation: 2,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 1,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: AppTypography.buttonText,
@@ -342,78 +289,89 @@ class AppTheme {
),
),
// ==================== Input Decoration Theme ====================
// Input Decoration Theme
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF2A2A2A),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1),
borderSide: BorderSide(color: colorScheme.outline, width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF3A3A3A), width: 1),
borderSide: BorderSide(color: colorScheme.outline, width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.lightBlue, width: 2),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 1),
borderSide: BorderSide(color: colorScheme.error, width: 1),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.danger, width: 2),
borderSide: BorderSide(color: colorScheme.error, width: 2),
),
labelStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
hintStyle: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
errorStyle: AppTypography.bodySmall.copyWith(color: AppColors.danger),
labelStyle: AppTypography.bodyMedium.copyWith(
color: colorScheme.onSurfaceVariant,
),
hintStyle: AppTypography.bodyMedium.copyWith(
color: colorScheme.onSurfaceVariant,
),
errorStyle: AppTypography.bodySmall.copyWith(color: colorScheme.error),
),
// ==================== 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(
// Bottom Navigation Bar Theme
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: colorScheme.surface,
selectedItemColor: colorScheme.primary,
unselectedItemColor: colorScheme.onSurfaceVariant,
selectedIconTheme: IconThemeData(size: 28, color: colorScheme.primary),
unselectedIconTheme: IconThemeData(
size: 24,
color: colorScheme.onSurfaceVariant,
),
selectedLabelStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
fontFamily: AppTypography.fontFamily,
),
unselectedLabelStyle: TextStyle(
unselectedLabelStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
fontFamily: AppTypography.fontFamily,
),
type: BottomNavigationBarType.fixed,
elevation: 8,
elevation: 3,
),
// ==================== Floating Action Button Theme ====================
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: AppColors.accentCyan,
foregroundColor: AppColors.white,
elevation: 6,
shape: CircleBorder(),
iconSize: 24,
// Floating Action Button Theme
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: colorScheme.primaryContainer,
foregroundColor: colorScheme.onPrimaryContainer,
elevation: 3,
shape: const CircleBorder(),
),
// ==================== Snackbar Theme ====================
// Snackbar Theme
snackBarTheme: SnackBarThemeData(
backgroundColor: const Color(0xFF2A2A2A),
backgroundColor: colorScheme.inverseSurface,
contentTextStyle: AppTypography.bodyMedium.copyWith(
color: AppColors.white,
color: colorScheme.onInverseSurface,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
behavior: SnackBarBehavior.floating,
elevation: 4,
elevation: 3,
),
// Badge Theme
badgeTheme: const BadgeThemeData(
backgroundColor: AppColors.danger,
textColor: Colors.white,
smallSize: 6,
largeSize: 16,
),
);
}

View File

@@ -1,66 +1,164 @@
import 'package:flutter/material.dart';
/// Seed color option for theme customization
class SeedColorOption {
const SeedColorOption({
required this.id,
required this.name,
required this.color,
});
final String id;
final String name;
final Color color;
}
/// 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.
/// Uses Material 3 ColorScheme.fromSeed() for primary/surface colors.
/// Only status colors and tier gradients are defined here as they come from backend.
///
/// ## Usage Guide:
/// - For themed colors, use `Theme.of(context).colorScheme.xxx`
/// - For status colors, use `AppColors.success/warning/danger/info`
/// - For tier gradients, use `AppColors.xxxGradient`
///
/// ## ColorScheme Quick Reference:
/// ```dart
/// final cs = Theme.of(context).colorScheme;
/// cs.primary // Brand color for buttons, links
/// cs.onPrimary // Text/icons on primary
/// cs.primaryContainer // Softer brand backgrounds
/// cs.surface // Card/container backgrounds
/// cs.onSurface // Primary text color
/// cs.onSurfaceVariant // Secondary text color
/// cs.outline // Borders
/// cs.error // Error states
/// ```
class AppColors {
// Primary Colors
/// Main brand color - Used for primary buttons, app bar, etc.
static const primaryBlue = Color(0xFF005B9A);
AppColors._();
/// Light variant of primary color - Used for highlights and accents
static const lightBlue = Color(0xFF38B6FF);
// ==================== Brand Seed Colors ====================
/// Default brand color - Used as seed for ColorScheme.fromSeed()
static const Color defaultSeedColor = Color(0xFF005B9A);
/// Accent color for special actions - Used for FAB, links, etc.
static const accentCyan = Color(0xFF35C6F4);
/// Available seed colors for theme customization
/// User can select one of these to change the app's color scheme
static const List<SeedColorOption> seedColorOptions = [
SeedColorOption(
id: 'blue',
name: 'Xanh dương',
color: Color(0xFF005B9A),
),
SeedColorOption(
id: 'teal',
name: 'Xanh ngọc',
color: Color(0xFF009688),
),
SeedColorOption(
id: 'green',
name: 'Xanh lá',
color: Color(0xFF4CAF50),
),
SeedColorOption(
id: 'purple',
name: 'Tím',
color: Color(0xFF673AB7),
),
SeedColorOption(
id: 'indigo',
name: 'Chàm',
color: Color(0xFF3F51B5),
),
SeedColorOption(
id: 'orange',
name: 'Cam',
color: Color(0xFFFF5722),
),
SeedColorOption(
id: 'red',
name: 'Đỏ',
color: Color(0xFFE53935),
),
SeedColorOption(
id: 'pink',
name: 'Hồng',
color: Color(0xFFE91E63),
),
];
// Status Colors
/// Get seed color by ID, returns default if not found
static Color getSeedColorById(String? id) {
if (id == null) return defaultSeedColor;
return seedColorOptions
.firstWhere(
(option) => option.id == id,
orElse: () => seedColorOptions.first,
)
.color;
}
// ==================== Convenience Aliases (for backward compatibility) ====================
// DEPRECATED: Prefer using Theme.of(context).colorScheme instead
// These are kept for gradual migration
/// @Deprecated('Use Theme.of(context).colorScheme.primary instead')
static const Color primaryBlue = defaultSeedColor;
/// Alias for backward compatibility
static const Color seedColor = defaultSeedColor;
/// @Deprecated('Use Theme.of(context).colorScheme.primaryContainer')
static const Color lightBlue = Color(0xFF38B6FF);
/// @Deprecated('Use Theme.of(context).colorScheme.tertiary')
static const Color accentCyan = Color(0xFF35C6F4);
/// @Deprecated('Use Colors.white or colorScheme.surface instead')
static const Color white = Colors.white;
/// @Deprecated('Use Theme.of(context).colorScheme.surfaceContainerLowest')
static const Color grey50 = Color(0xFFf8f9fa);
/// @Deprecated('Use Theme.of(context).colorScheme.outline')
static const Color grey100 = Color(0xFFe9ecef);
/// @Deprecated('Use Theme.of(context).colorScheme.onSurfaceVariant')
static const Color grey500 = Color(0xFF6c757d);
/// @Deprecated('Use Theme.of(context).colorScheme.onSurface')
static const Color grey900 = Color(0xFF343a40);
// ==================== Status Colors (from backend) ====================
/// Success state - Used for completed actions, positive feedback
static const success = Color(0xFF28a745);
static const Color success = Color(0xFF28a745);
/// Warning state - Used for caution messages, pending states
static const warning = Color(0xFFffc107);
static const Color warning = Color(0xFFffc107);
/// Danger/Error state - Used for errors, destructive actions
static const danger = Color(0xFFdc3545);
static const Color danger = Color(0xFFdc3545);
/// Info state - Used for informational messages
static const info = Color(0xFF17a2b8);
static const Color 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
// ==================== Tier Gradients for Membership Cards ====================
/// Diamond tier gradient (purple-blue)
static const diamondGradient = LinearGradient(
static const LinearGradient diamondGradient = LinearGradient(
colors: [Color(0xFF4A00E0), Color(0xFF8E2DE2)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
/// Platinum tier gradient (grey-silver)
static const platinumGradient = LinearGradient(
static const LinearGradient platinumGradient = LinearGradient(
colors: [Color(0xFF7F8C8D), Color(0xFFBDC3C7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
/// Gold tier gradient (yellow-orange)
static const goldGradient = LinearGradient(
static const LinearGradient goldGradient = LinearGradient(
colors: [Color(0xFFf7b733), Color(0xFFfc4a1a)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/database/app_settings_box.dart';
import 'package:worker/core/theme/colors.dart';
part 'theme_provider.g.dart';
/// Theme settings state
class ThemeSettings {
const ThemeSettings({
required this.seedColorId,
required this.themeMode,
});
final String seedColorId;
final ThemeMode themeMode;
/// Get the actual Color from the seed color ID
Color get seedColor => AppColors.getSeedColorById(seedColorId);
/// Get the SeedColorOption from the ID
SeedColorOption get seedColorOption => AppColors.seedColorOptions.firstWhere(
(option) => option.id == seedColorId,
orElse: () => AppColors.seedColorOptions.first,
);
ThemeSettings copyWith({
String? seedColorId,
ThemeMode? themeMode,
}) {
return ThemeSettings(
seedColorId: seedColorId ?? this.seedColorId,
themeMode: themeMode ?? this.themeMode,
);
}
}
/// Provider for managing theme settings with Hive persistence
/// Uses AppSettingsBox for storage
@Riverpod(keepAlive: true)
class ThemeSettingsNotifier extends _$ThemeSettingsNotifier {
@override
ThemeSettings build() {
return _loadFromSettings();
}
ThemeSettings _loadFromSettings() {
return ThemeSettings(
seedColorId: AppSettingsBox.getSeedColorId(),
themeMode: ThemeMode.values[AppSettingsBox.getThemeModeIndex()],
);
}
/// Update seed color
Future<void> setSeedColor(String colorId) async {
await AppSettingsBox.setSeedColorId(colorId);
state = state.copyWith(seedColorId: colorId);
}
/// Update theme mode (light/dark/system)
Future<void> setThemeMode(ThemeMode mode) async {
await AppSettingsBox.setThemeModeIndex(mode.index);
state = state.copyWith(themeMode: mode);
}
/// Toggle between light and dark mode
Future<void> toggleThemeMode() async {
final newMode =
state.themeMode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
await setThemeMode(newMode);
}
}
/// Provider for the current seed color (convenience provider)
@riverpod
Color currentSeedColor(Ref ref) {
return ref.watch(
themeSettingsProvider.select((settings) => settings.seedColor),
);
}
/// Provider for available seed color options
@riverpod
List<SeedColorOption> seedColorOptions(Ref ref) {
return AppColors.seedColorOptions;
}

View File

@@ -0,0 +1,171 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'theme_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for managing theme settings with Hive persistence
/// Uses AppSettingsBox for storage
@ProviderFor(ThemeSettingsNotifier)
const themeSettingsProvider = ThemeSettingsNotifierProvider._();
/// Provider for managing theme settings with Hive persistence
/// Uses AppSettingsBox for storage
final class ThemeSettingsNotifierProvider
extends $NotifierProvider<ThemeSettingsNotifier, ThemeSettings> {
/// Provider for managing theme settings with Hive persistence
/// Uses AppSettingsBox for storage
const ThemeSettingsNotifierProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'themeSettingsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$themeSettingsNotifierHash();
@$internal
@override
ThemeSettingsNotifier create() => ThemeSettingsNotifier();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(ThemeSettings value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<ThemeSettings>(value),
);
}
}
String _$themeSettingsNotifierHash() =>
r'5befe194684b8c1857302c9573f5eee38199fa97';
/// Provider for managing theme settings with Hive persistence
/// Uses AppSettingsBox for storage
abstract class _$ThemeSettingsNotifier extends $Notifier<ThemeSettings> {
ThemeSettings build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<ThemeSettings, ThemeSettings>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ThemeSettings, ThemeSettings>,
ThemeSettings,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Provider for the current seed color (convenience provider)
@ProviderFor(currentSeedColor)
const currentSeedColorProvider = CurrentSeedColorProvider._();
/// Provider for the current seed color (convenience provider)
final class CurrentSeedColorProvider
extends $FunctionalProvider<Color, Color, Color>
with $Provider<Color> {
/// Provider for the current seed color (convenience provider)
const CurrentSeedColorProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentSeedColorProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentSeedColorHash();
@$internal
@override
$ProviderElement<Color> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
Color create(Ref ref) {
return currentSeedColor(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Color value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Color>(value),
);
}
}
String _$currentSeedColorHash() => r'c6807df84f2ac257b2650b2f1aa04d2572cbde37';
/// Provider for available seed color options
@ProviderFor(seedColorOptions)
const seedColorOptionsProvider = SeedColorOptionsProvider._();
/// Provider for available seed color options
final class SeedColorOptionsProvider
extends
$FunctionalProvider<
List<SeedColorOption>,
List<SeedColorOption>,
List<SeedColorOption>
>
with $Provider<List<SeedColorOption>> {
/// Provider for available seed color options
const SeedColorOptionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'seedColorOptionsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$seedColorOptionsHash();
@$internal
@override
$ProviderElement<List<SeedColorOption>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
List<SeedColorOption> create(Ref ref) {
return seedColorOptions(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<SeedColorOption> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<SeedColorOption>>(value),
);
}
}
String _$seedColorOptionsHash() => r'2cb0f7bf9e87394716f44a70b212b4d62f828152';

View File

@@ -7,7 +7,6 @@ library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
// ============================================================================

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
/// Button variant types for different use cases.
enum ButtonVariant {
@@ -106,14 +107,7 @@ class CustomButton extends StatelessWidget {
/// 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),
),
);
return const CustomLoadingIndicator(size: 20, color: Colors.white);
}
if (icon != null) {

View File

@@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:loading_animation_widget/loading_animation_widget.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
/// Displays a centered three rotating dots animation with an optional
/// message below it. Used for loading states throughout the app.
///
/// Example usage:
@@ -32,19 +33,14 @@ class CustomLoadingIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation<Color>(
color ?? AppColors.primaryBlue,
),
),
LoadingAnimationWidget.threeRotatingDots(
color: color ?? colorScheme.primary,
size: size,
),
if (message != null) ...[
const SizedBox(height: 16),

View File

@@ -16,6 +16,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/database/hive_initializer.dart';
import 'package:worker/core/database/models/enums.dart';
@@ -36,8 +37,10 @@ class AccountPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
body: SafeArea(
child: RefreshIndicator(
onRefresh: () async {
@@ -48,7 +51,7 @@ class AccountPage extends ConsumerWidget {
child: Column(
children: [
// Simple Header
_buildHeader(),
_buildHeader(context),
const SizedBox(height: AppSpacing.md),
// User Profile Card - only this depends on provider
@@ -76,26 +79,28 @@ class AccountPage extends ConsumerWidget {
}
/// Build simple header with title
Widget _buildHeader() {
Widget _buildHeader(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Text(
child: Text(
'Tài khoản',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
);
@@ -103,14 +108,16 @@ class AccountPage extends ConsumerWidget {
/// Build account menu section
Widget _buildAccountMenu(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -134,6 +141,14 @@ class AccountPage extends ConsumerWidget {
context.push(RouteNames.orders);
},
),
AccountMenuItem(
icon: FontAwesomeIcons.fileInvoiceDollar,
title: 'Hóa đơn đã mua',
subtitle: 'Xem các hóa đơn đã xuất',
onTap: () {
context.push(RouteNames.invoices);
},
),
AccountMenuItem(
icon: FontAwesomeIcons.locationDot,
title: 'Địa chỉ đã lưu',
@@ -158,12 +173,20 @@ class AccountPage extends ConsumerWidget {
context.push(RouteNames.changePassword);
},
),
// AccountMenuItem(
// icon: FontAwesomeIcons.language,
// title: 'Ngôn ngữ',
// subtitle: 'Tiếng Việt',
// onTap: () {
// _showComingSoon(context);
// },
// ),
AccountMenuItem(
icon: FontAwesomeIcons.language,
title: 'Ngôn ngữ',
subtitle: 'Tiếng Việt',
icon: FontAwesomeIcons.palette,
title: 'Giao diện',
subtitle: 'Màu sắc và chế độ hiển thị',
onTap: () {
_showComingSoon(context);
context.push(RouteNames.themeSettings);
},
),
],
@@ -173,14 +196,16 @@ class AccountPage extends ConsumerWidget {
/// Build support section
Widget _buildSupportSection(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -190,8 +215,8 @@ class AccountPage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section title
const Padding(
padding: EdgeInsets.fromLTRB(
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.md,
AppSpacing.md,
@@ -202,7 +227,7 @@ class AccountPage extends ConsumerWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
),
@@ -212,10 +237,10 @@ class AccountPage extends ConsumerWidget {
icon: FontAwesomeIcons.headset,
title: 'Liên hệ hỗ trợ',
subtitle: 'Hotline: 1900 1234',
trailing: const FaIcon(
trailing: FaIcon(
FontAwesomeIcons.phone,
size: 18,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
@@ -275,7 +300,7 @@ class AccountPage extends ConsumerWidget {
const SizedBox(height: 8),
Text(
'Ứng dụng dành cho thầu thợ, kiến trúc sư, đại lý và môi giới trong ngành gạch ốp lát và nội thất.',
style: TextStyle(fontSize: 14, color: AppColors.grey500),
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
],
),
@@ -299,27 +324,28 @@ class _ProfileCardSection extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final userInfoAsync = ref.watch(userInfoProvider);
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: userInfoAsync.when(
loading: () => _buildLoadingCard(),
error: (error, stack) => _buildErrorCard(context, ref, error),
data: (userInfo) => _buildProfileCard(context, userInfo),
loading: () => _buildLoadingCard(colorScheme),
error: (error, stack) => _buildErrorCard(context, ref, error, colorScheme),
data: (userInfo) => _buildProfileCard(context, userInfo, colorScheme),
),
);
}
Widget _buildLoadingCard() {
Widget _buildLoadingCard(ColorScheme colorScheme) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -333,14 +359,9 @@ class _ProfileCardSection extends ConsumerWidget {
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.grey100,
),
child: const Center(
child: CircularProgressIndicator(
color: AppColors.primaryBlue,
strokeWidth: 2,
),
color: colorScheme.surfaceContainerHighest,
),
child: const CustomLoadingIndicator(),
),
const SizedBox(width: AppSpacing.md),
Expanded(
@@ -351,7 +372,7 @@ class _ProfileCardSection extends ConsumerWidget {
height: 20,
width: 150,
decoration: BoxDecoration(
color: AppColors.grey100,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
),
@@ -360,7 +381,7 @@ class _ProfileCardSection extends ConsumerWidget {
height: 14,
width: 100,
decoration: BoxDecoration(
color: AppColors.grey100,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
),
@@ -372,15 +393,15 @@ class _ProfileCardSection extends ConsumerWidget {
);
}
Widget _buildErrorCard(BuildContext context, WidgetRef ref, Object error) {
Widget _buildErrorCard(BuildContext context, WidgetRef ref, Object error, ColorScheme colorScheme) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -391,9 +412,9 @@ class _ProfileCardSection extends ConsumerWidget {
Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.grey100,
color: colorScheme.surfaceContainerHighest,
),
child: const Center(
child: FaIcon(
@@ -408,22 +429,22 @@ class _ProfileCardSection extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Không thể tải thông tin',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
GestureDetector(
onTap: () => ref.read(userInfoProvider.notifier).refresh(),
child: const Text(
child: Text(
'Nhấn để thử lại',
style: TextStyle(
fontSize: 14,
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
@@ -436,15 +457,15 @@ class _ProfileCardSection extends ConsumerWidget {
);
}
Widget _buildProfileCard(BuildContext context, domain.UserInfo userInfo) {
Widget _buildProfileCard(BuildContext context, domain.UserInfo userInfo, ColorScheme colorScheme) {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -463,37 +484,26 @@ class _ProfileCardSection extends ConsumerWidget {
placeholder: (context, url) => Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [Color(0xFF005B9A), Color(0xFF38B6FF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
color: colorScheme.primaryContainer,
),
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
child: CustomLoadingIndicator(
color: colorScheme.onPrimaryContainer,
),
),
errorWidget: (context, url, error) => Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [Color(0xFF005B9A), Color(0xFF38B6FF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
color: colorScheme.primaryContainer,
),
child: Center(
child: Text(
userInfo.initials,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 32,
fontWeight: FontWeight.w700,
),
@@ -505,19 +515,15 @@ class _ProfileCardSection extends ConsumerWidget {
: Container(
width: 80,
height: 80,
decoration: const BoxDecoration(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [Color(0xFF005B9A), Color(0xFF38B6FF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
color: colorScheme.primaryContainer,
),
child: Center(
child: Text(
userInfo.initials,
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontSize: 32,
fontWeight: FontWeight.w700,
),
@@ -533,27 +539,27 @@ class _ProfileCardSection extends ConsumerWidget {
children: [
Text(
userInfo.fullName,
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
'${_getRoleDisplayName(userInfo.role)} · Hạng ${userInfo.tierDisplayName}',
style: const TextStyle(
style: TextStyle(
fontSize: 13,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
if (userInfo.phoneNumber != null) ...[
const SizedBox(height: AppSpacing.xs),
Text(
userInfo.phoneNumber!,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
],
@@ -586,12 +592,14 @@ class _ProfileCardSection extends ConsumerWidget {
class _LogoutButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
_showLogoutConfirmation(context, ref);
_showLogoutConfirmation(context, ref, colorScheme);
},
icon: const FaIcon(FontAwesomeIcons.arrowRightFromBracket, size: 18),
label: const Text('Đăng xuất'),
@@ -608,12 +616,19 @@ class _LogoutButton extends ConsumerWidget {
}
/// Show logout confirmation dialog
void _showLogoutConfirmation(BuildContext context, WidgetRef ref) {
void _showLogoutConfirmation(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Đăng xuất'),
content: const Text('Bạn có chắc chắn muốn đăng xuất?'),
backgroundColor: colorScheme.surface,
title: Text(
'Đăng xuất',
style: TextStyle(color: colorScheme.onSurface),
),
content: Text(
'Bạn có chắc chắn muốn đăng xuất?',
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
@@ -654,7 +669,7 @@ class _LogoutButton extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
CustomLoadingIndicator(),
SizedBox(height: 16),
Text('Đang đăng xuất...'),
],

View File

@@ -10,6 +10,7 @@
library;
import 'package:flutter/material.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
@@ -32,6 +33,8 @@ class AddressFormPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Form key for validation
final formKey = useMemoized(() => GlobalKey<FormState>());
@@ -89,32 +92,32 @@ class AddressFormPage extends HookConsumerWidget {
);
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: AppColors.white,
backgroundColor: colorScheme.surface,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const FaIcon(
icon: FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
color: colorScheme.onSurface,
size: 20,
),
onPressed: () => context.pop(),
),
title: Text(
address == null ? 'Thêm địa chỉ mới' : 'Chỉnh sửa địa chỉ',
style: const TextStyle(color: Colors.black),
style: TextStyle(color: colorScheme.onSurface),
),
foregroundColor: AppColors.grey900,
foregroundColor: colorScheme.onSurface,
centerTitle: false,
actions: [
IconButton(
icon: const FaIcon(
icon: FaIcon(
FontAwesomeIcons.circleInfo,
color: Colors.black,
color: colorScheme.onSurface,
size: 20,
),
onPressed: () => _showInfoDialog(context),
onPressed: () => _showInfoDialog(context, colorScheme),
),
const SizedBox(width: AppSpacing.sm),
],
@@ -136,10 +139,12 @@ class AddressFormPage extends HookConsumerWidget {
children: [
// Contact Information Section
_buildSection(
colorScheme: colorScheme,
icon: FontAwesomeIcons.user,
title: 'Thông tin liên hệ',
children: [
_buildTextField(
colorScheme: colorScheme,
controller: nameController,
label: 'Họ và tên',
icon: FontAwesomeIcons.user,
@@ -154,6 +159,7 @@ class AddressFormPage extends HookConsumerWidget {
),
const SizedBox(height: AppSpacing.md),
_buildTextField(
colorScheme: colorScheme,
controller: phoneController,
label: 'Số điện thoại',
icon: FontAwesomeIcons.phone,
@@ -173,6 +179,7 @@ class AddressFormPage extends HookConsumerWidget {
),
const SizedBox(height: AppSpacing.md),
_buildTextField(
colorScheme: colorScheme,
controller: emailController,
label: 'Email',
icon: FontAwesomeIcons.envelope,
@@ -190,6 +197,7 @@ class AddressFormPage extends HookConsumerWidget {
),
const SizedBox(height: AppSpacing.md),
_buildTextField(
colorScheme: colorScheme,
controller: taxIdController,
label: 'Mã số thuế',
icon: FontAwesomeIcons.fileInvoice,
@@ -203,10 +211,12 @@ class AddressFormPage extends HookConsumerWidget {
// Address Information Section
_buildSection(
colorScheme: colorScheme,
icon: FontAwesomeIcons.locationDot,
title: 'Địa chỉ giao hàng',
children: [
_buildDropdownWithLoading(
colorScheme: colorScheme,
label: 'Tỉnh/Thành phố',
value: selectedCityCode.value,
items: citiesMap,
@@ -226,6 +236,7 @@ class AddressFormPage extends HookConsumerWidget {
),
const SizedBox(height: AppSpacing.md),
_buildDropdownWithLoading(
colorScheme: colorScheme,
label: 'Quận/Huyện',
value: selectedWardCode.value,
items: wardsMap,
@@ -246,6 +257,7 @@ class AddressFormPage extends HookConsumerWidget {
if (citiesAsync.hasError) ...[
const SizedBox(height: 8),
_buildErrorBanner(
colorScheme,
'Không thể tải danh sách tỉnh/thành phố. Vui lòng thử lại.',
),
],
@@ -253,11 +265,13 @@ class AddressFormPage extends HookConsumerWidget {
if (wardsAsync.hasError && selectedCityCode.value != null) ...[
const SizedBox(height: 8),
_buildErrorBanner(
colorScheme,
'Không thể tải danh sách quận/huyện. Vui lòng thử lại.',
),
],
const SizedBox(height: AppSpacing.md),
_buildTextArea(
colorScheme: colorScheme,
controller: addressDetailController,
label: 'Địa chỉ cụ thể',
placeholder: 'Số nhà, tên đường, khu vực...',
@@ -279,11 +293,11 @@ class AddressFormPage extends HookConsumerWidget {
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -303,31 +317,31 @@ class AddressFormPage extends HookConsumerWidget {
value: isDefault.value,
onChanged: (value) =>
isDefault.value = value ?? false,
activeColor: AppColors.primaryBlue,
activeColor: colorScheme.primary,
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 12),
const Text(
Text(
'Đặt làm địa chỉ mặc định',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
],
),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.only(left: 32),
Padding(
padding: const EdgeInsets.only(left: 32),
child: Text(
'Địa chỉ này sẽ được sử dụng làm mặc định khi đặt hàng',
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
),
@@ -401,15 +415,15 @@ class AddressFormPage extends HookConsumerWidget {
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
border: Border(
top: BorderSide(
color: Colors.grey.withValues(alpha: 0.15),
color: colorScheme.outlineVariant,
),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
color: colorScheme.shadow.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, -4),
),
@@ -437,13 +451,9 @@ class AddressFormPage extends HookConsumerWidget {
isSaving,
),
icon: isSaving.value
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
? CustomLoadingIndicator(
color: colorScheme.onPrimary,
size: 18,
)
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 18),
label: Text(
@@ -454,9 +464,9 @@ class AddressFormPage extends HookConsumerWidget {
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.grey500,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
elevation: 0,
shape: RoundedRectangleBorder(
@@ -475,6 +485,7 @@ class AddressFormPage extends HookConsumerWidget {
/// Build a section with icon and title
Widget _buildSection({
required ColorScheme colorScheme,
required IconData icon,
required String title,
required List<Widget> children,
@@ -482,11 +493,11 @@ class AddressFormPage extends HookConsumerWidget {
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -500,15 +511,15 @@ class AddressFormPage extends HookConsumerWidget {
FaIcon(
icon,
size: 16,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
],
@@ -522,6 +533,7 @@ class AddressFormPage extends HookConsumerWidget {
/// Build a text field with label and icon
Widget _buildTextField({
required ColorScheme colorScheme,
required TextEditingController controller,
required String label,
required IconData icon,
@@ -538,10 +550,10 @@ class AddressFormPage extends HookConsumerWidget {
children: [
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
if (isRequired)
@@ -561,13 +573,13 @@ class AddressFormPage extends HookConsumerWidget {
validator: validator,
decoration: InputDecoration(
hintText: placeholder,
hintStyle: const TextStyle(color: AppColors.grey500),
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 16, right: 12),
child: FaIcon(
icon,
size: 16,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
prefixIconConstraints: const BoxConstraints(
@@ -575,19 +587,19 @@ class AddressFormPage extends HookConsumerWidget {
minHeight: 0,
),
filled: true,
fillColor: Colors.white,
fillColor: colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
@@ -612,9 +624,9 @@ class AddressFormPage extends HookConsumerWidget {
const SizedBox(height: 4),
Text(
helperText,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -624,6 +636,7 @@ class AddressFormPage extends HookConsumerWidget {
/// Build a dropdown field
Widget _buildDropdown({
required ColorScheme colorScheme,
required String label,
required String? value,
required Map<String, String> items,
@@ -639,10 +652,10 @@ class AddressFormPage extends HookConsumerWidget {
children: [
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
if (isRequired)
@@ -662,23 +675,23 @@ class AddressFormPage extends HookConsumerWidget {
validator: validator,
decoration: InputDecoration(
filled: true,
fillColor: enabled ? Colors.white : const Color(0xFFF3F4F6),
fillColor: enabled ? colorScheme.surface : colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
@@ -700,7 +713,7 @@ class AddressFormPage extends HookConsumerWidget {
),
hint: Text(
'-- Chọn $label --',
style: const TextStyle(color: AppColors.grey500),
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
items: enabled
? () {
@@ -709,9 +722,9 @@ class AddressFormPage extends HookConsumerWidget {
value: entry.key,
child: Text(
entry.value,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
);
@@ -725,9 +738,9 @@ class AddressFormPage extends HookConsumerWidget {
value: value,
child: Text(
'$value (đã lưu)',
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
@@ -742,7 +755,7 @@ class AddressFormPage extends HookConsumerWidget {
icon: FaIcon(
FontAwesomeIcons.chevronDown,
size: 14,
color: enabled ? AppColors.grey500 : AppColors.grey500.withValues(alpha: 0.5),
color: enabled ? colorScheme.onSurfaceVariant : colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
),
],
@@ -751,6 +764,7 @@ class AddressFormPage extends HookConsumerWidget {
/// Build a dropdown field with loading indicator
Widget _buildDropdownWithLoading({
required ColorScheme colorScheme,
required String label,
required String? value,
required Map<String, String> items,
@@ -767,10 +781,10 @@ class AddressFormPage extends HookConsumerWidget {
children: [
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
if (isRequired)
@@ -783,13 +797,9 @@ class AddressFormPage extends HookConsumerWidget {
),
if (isLoading) ...[
const SizedBox(width: 8),
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primaryBlue,
),
CustomLoadingIndicator(
color: colorScheme.primary,
size: 12,
),
],
],
@@ -801,23 +811,23 @@ class AddressFormPage extends HookConsumerWidget {
validator: validator,
decoration: InputDecoration(
filled: true,
fillColor: enabled && !isLoading ? Colors.white : const Color(0xFFF3F4F6),
fillColor: enabled && !isLoading ? colorScheme.surface : colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
@@ -837,15 +847,11 @@ class AddressFormPage extends HookConsumerWidget {
vertical: 14,
),
suffixIcon: isLoading
? const Padding(
padding: EdgeInsets.only(right: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primaryBlue,
),
? Padding(
padding: const EdgeInsets.only(right: 12),
child: CustomLoadingIndicator(
color: colorScheme.primary,
size: 20,
),
)
: null,
@@ -857,7 +863,7 @@ class AddressFormPage extends HookConsumerWidget {
? 'Vui lòng chọn Tỉnh/Thành phố trước'
: '-- Chọn $label --',
style: TextStyle(
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
fontSize: 14,
fontStyle: !enabled || isLoading ? FontStyle.italic : FontStyle.normal,
),
@@ -869,9 +875,9 @@ class AddressFormPage extends HookConsumerWidget {
value: entry.key,
child: Text(
entry.value,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
);
@@ -885,9 +891,9 @@ class AddressFormPage extends HookConsumerWidget {
value: value,
child: Text(
'$value (đã lưu)',
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
@@ -903,8 +909,8 @@ class AddressFormPage extends HookConsumerWidget {
FontAwesomeIcons.chevronDown,
size: 14,
color: enabled && !isLoading
? AppColors.grey500
: AppColors.grey500.withValues(alpha: 0.5),
? colorScheme.onSurfaceVariant
: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
),
],
@@ -913,6 +919,7 @@ class AddressFormPage extends HookConsumerWidget {
/// Build a text area field
Widget _buildTextArea({
required ColorScheme colorScheme,
required TextEditingController controller,
required String label,
required String placeholder,
@@ -927,10 +934,10 @@ class AddressFormPage extends HookConsumerWidget {
children: [
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
if (isRequired)
@@ -950,21 +957,21 @@ class AddressFormPage extends HookConsumerWidget {
validator: validator,
decoration: InputDecoration(
hintText: placeholder,
hintStyle: const TextStyle(color: AppColors.grey500),
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
filled: true,
fillColor: Colors.white,
fillColor: colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
@@ -986,9 +993,9 @@ class AddressFormPage extends HookConsumerWidget {
const SizedBox(height: 4),
Text(
helperText,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -997,7 +1004,7 @@ class AddressFormPage extends HookConsumerWidget {
}
/// Build error banner for API failures
Widget _buildErrorBanner(String message) {
Widget _buildErrorBanner(ColorScheme colorScheme, String message) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -1032,7 +1039,7 @@ class AddressFormPage extends HookConsumerWidget {
}
/// Show info dialog
void _showInfoDialog(BuildContext context) {
void _showInfoDialog(BuildContext context, ColorScheme colorScheme) {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
@@ -1132,7 +1139,7 @@ class AddressFormPage extends HookConsumerWidget {
Text(address == null ? 'Đã thêm địa chỉ thành công!' : 'Đã cập nhật địa chỉ thành công!'),
],
),
backgroundColor: const Color(0xFF10B981),
backgroundColor: AppColors.success,
duration: const Duration(seconds: 2),
),
);

View File

@@ -10,6 +10,7 @@
library;
import 'package:flutter/material.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
@@ -42,30 +43,32 @@ class AddressesPage extends HookConsumerWidget {
// Watch addresses from API
final addressesAsync = ref.watch(addressesProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: AppColors.white,
backgroundColor: colorScheme.surface,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const FaIcon(
icon: FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
color: colorScheme.onSurface,
size: 20,
),
onPressed: () => context.pop(),
),
title: Text(
selectMode ? 'Chọn địa chỉ' : 'Địa chỉ của bạn',
style: const TextStyle(color: Colors.black),
style: TextStyle(color: colorScheme.onSurface),
),
foregroundColor: AppColors.grey900,
foregroundColor: colorScheme.onSurface,
centerTitle: false,
actions: [
IconButton(
icon: const FaIcon(
icon: FaIcon(
FontAwesomeIcons.circleInfo,
color: Colors.black,
color: colorScheme.onSurface,
size: 20,
),
onPressed: () {
@@ -85,7 +88,7 @@ class AddressesPage extends HookConsumerWidget {
await ref.read(addressesProvider.notifier).refresh();
},
child: addresses.isEmpty
? _buildEmptyState(context)
? _buildEmptyState(context, colorScheme)
: ListView.separated(
padding: const EdgeInsets.all(AppSpacing.md),
itemCount: addresses.length,
@@ -168,9 +171,9 @@ class AddressesPage extends HookConsumerWidget {
),
),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
side: const BorderSide(
color: AppColors.primaryBlue,
foregroundColor: colorScheme.primary,
side: BorderSide(
color: colorScheme.primary,
width: 1.5,
),
padding: const EdgeInsets.symmetric(vertical: 14),
@@ -205,10 +208,10 @@ class AddressesPage extends HookConsumerWidget {
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.surfaceContainerHighest,
disabledForegroundColor: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
@@ -236,8 +239,8 @@ class AddressesPage extends HookConsumerWidget {
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
@@ -251,7 +254,7 @@ class AddressesPage extends HookConsumerWidget {
),
],
),
loading: () => const Center(child: CircularProgressIndicator()),
loading: () => const CustomLoadingIndicator(),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -262,18 +265,18 @@ class AddressesPage extends HookConsumerWidget {
color: AppColors.danger,
),
const SizedBox(height: 16),
const Text(
Text(
'Không thể tải danh sách địa chỉ',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
error.toString(),
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
@@ -284,8 +287,8 @@ class AddressesPage extends HookConsumerWidget {
icon: const FaIcon(FontAwesomeIcons.arrowsRotate, size: 18),
label: const Text('Thử lại'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
),
],
@@ -296,7 +299,7 @@ class AddressesPage extends HookConsumerWidget {
}
/// Build empty state
Widget _buildEmptyState(BuildContext context) {
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -304,15 +307,15 @@ class AddressesPage extends HookConsumerWidget {
FaIcon(
FontAwesomeIcons.locationDot,
size: 64,
color: AppColors.grey500.withValues(alpha: 0.4),
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
),
const SizedBox(height: 16),
const Text(
Text(
'Chưa có địa chỉ nào',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
@@ -320,7 +323,7 @@ class AddressesPage extends HookConsumerWidget {
'Thêm địa chỉ để nhận hàng nhanh hơn',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500.withValues(alpha: 0.8),
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.8),
),
),
const SizedBox(height: 24),
@@ -334,8 +337,8 @@ class AddressesPage extends HookConsumerWidget {
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
elevation: 0,
shape: RoundedRectangleBorder(
@@ -357,20 +360,20 @@ class AddressesPage extends HookConsumerWidget {
ref.read(addressesProvider.notifier).setDefaultAddress(address.name);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
const SnackBar(
content: Row(
children: [
const FaIcon(
FaIcon(
FontAwesomeIcons.circleCheck,
color: Colors.white,
size: 18,
),
const SizedBox(width: 12),
const Text('Đã đặt làm địa chỉ mặc định'),
SizedBox(width: 12),
Text('Đã đặt làm địa chỉ mặc định'),
],
),
backgroundColor: const Color(0xFF10B981),
duration: const Duration(seconds: 2),
backgroundColor: AppColors.success,
duration: Duration(seconds: 2),
),
);
}
@@ -460,7 +463,7 @@ class AddressesPage extends HookConsumerWidget {
Text('Đã xóa địa chỉ'),
],
),
backgroundColor: Color(0xFF10B981),
backgroundColor: AppColors.success,
duration: Duration(seconds: 2),
),
);

View File

@@ -63,19 +63,21 @@ class ChangePasswordPage extends HookConsumerWidget {
};
}, []);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: Colors.white,
backgroundColor: colorScheme.surface,
elevation: 0,
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
onPressed: () => context.pop(),
),
title: const Text(
title: Text(
'Thay đổi mật khẩu',
style: TextStyle(
color: Colors.black,
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
@@ -95,11 +97,11 @@ class ChangePasswordPage extends HookConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -109,12 +111,12 @@ class ChangePasswordPage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
const Text(
Text(
'Cập nhật mật khẩu',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
@@ -122,6 +124,7 @@ class ChangePasswordPage extends HookConsumerWidget {
// Current Password
_buildPasswordField(
colorScheme: colorScheme,
label: 'Mật khẩu hiện tại',
controller: currentPasswordController,
isVisible: currentPasswordVisible,
@@ -138,6 +141,7 @@ class ChangePasswordPage extends HookConsumerWidget {
// New Password
_buildPasswordField(
colorScheme: colorScheme,
label: 'Mật khẩu mới',
controller: newPasswordController,
isVisible: newPasswordVisible,
@@ -158,6 +162,7 @@ class ChangePasswordPage extends HookConsumerWidget {
// Confirm Password
_buildPasswordField(
colorScheme: colorScheme,
label: 'Nhập lại mật khẩu mới',
controller: confirmPasswordController,
isVisible: confirmPasswordVisible,
@@ -206,7 +211,7 @@ class ChangePasswordPage extends HookConsumerWidget {
const SizedBox(height: AppSpacing.lg),
// Security Tips
_buildSecurityTips(),
_buildSecurityTips(colorScheme),
],
),
),
@@ -216,6 +221,7 @@ class ChangePasswordPage extends HookConsumerWidget {
// Action Buttons
_buildActionButtons(
context: context,
colorScheme: colorScheme,
formKey: formKey,
currentPasswordController: currentPasswordController,
newPasswordController: newPasswordController,
@@ -232,6 +238,7 @@ class ChangePasswordPage extends HookConsumerWidget {
/// Build password field with show/hide toggle
Widget _buildPasswordField({
required ColorScheme colorScheme,
required String label,
required TextEditingController controller,
required ValueNotifier<bool> isVisible,
@@ -245,10 +252,10 @@ class ChangePasswordPage extends HookConsumerWidget {
RichText(
text: TextSpan(
text: label,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
children: [
if (required)
@@ -267,11 +274,11 @@ class ChangePasswordPage extends HookConsumerWidget {
decoration: InputDecoration(
hintText: 'Nhập $label',
hintStyle: TextStyle(
color: AppColors.grey500.withValues(alpha: 0.6),
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 14,
),
filled: true,
fillColor: const Color(0xFFF8FAFC),
fillColor: colorScheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
@@ -280,7 +287,7 @@ class ChangePasswordPage extends HookConsumerWidget {
icon: FaIcon(
isVisible.value ? FontAwesomeIcons.eyeSlash : FontAwesomeIcons.eye,
size: 18,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
onPressed: () {
isVisible.value = !isVisible.value;
@@ -288,16 +295,16 @@ class ChangePasswordPage extends HookConsumerWidget {
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
@@ -315,7 +322,7 @@ class ChangePasswordPage extends HookConsumerWidget {
const SizedBox(height: 8),
Text(
helpText,
style: const TextStyle(fontSize: 12, color: AppColors.grey500),
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
),
],
],
@@ -323,38 +330,38 @@ class ChangePasswordPage extends HookConsumerWidget {
}
/// Build security tips section
Widget _buildSecurityTips() {
Widget _buildSecurityTips(ColorScheme colorScheme) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: const Color(0xFFE2E8F0)),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Gợi ý bảo mật:',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
const SizedBox(height: 12),
_buildSecurityTip('Sử dụng ít nhất 8 ký tự'),
_buildSecurityTip('Kết hợp chữ hoa, chữ thường và số'),
_buildSecurityTip('Bao gồm ký tự đặc biệt (!@#\$%^&*)'),
_buildSecurityTip('Không sử dụng thông tin cá nhân'),
_buildSecurityTip('Thường xuyên thay đổi mật khẩu'),
_buildSecurityTip('Sử dụng ít nhất 8 ký tự', colorScheme),
_buildSecurityTip('Kết hợp chữ hoa, chữ thường và số', colorScheme),
_buildSecurityTip('Bao gồm ký tự đặc biệt (!@#\$%^&*)', colorScheme),
_buildSecurityTip('Không sử dụng thông tin cá nhân', colorScheme),
_buildSecurityTip('Thường xuyên thay đổi mật khẩu', colorScheme),
],
),
);
}
/// Build individual security tip
Widget _buildSecurityTip(String text) {
Widget _buildSecurityTip(String text, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
@@ -369,9 +376,9 @@ class ChangePasswordPage extends HookConsumerWidget {
Expanded(
child: Text(
text,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
color: Color(0xFF475569),
color: colorScheme.onSurfaceVariant,
height: 1.4,
),
),
@@ -384,6 +391,7 @@ class ChangePasswordPage extends HookConsumerWidget {
/// Build action buttons
Widget _buildActionButtons({
required BuildContext context,
required ColorScheme colorScheme,
required GlobalKey<FormState> formKey,
required TextEditingController currentPasswordController,
required TextEditingController newPasswordController,
@@ -401,17 +409,17 @@ class ChangePasswordPage extends HookConsumerWidget {
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: const BorderSide(color: AppColors.grey100),
side: BorderSide(color: colorScheme.outlineVariant),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
),
),
child: const Text(
child: Text(
'Hủy bỏ',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
),
@@ -438,8 +446,8 @@ class ChangePasswordPage extends HookConsumerWidget {
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(

View File

@@ -9,6 +9,7 @@
library;
import 'dart:convert';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'dart:io';
import 'package:flutter/material.dart';
@@ -31,6 +32,8 @@ class ProfileEditPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Watch user info from API
final userInfoAsync = ref.watch(userInfoProvider);
@@ -45,50 +48,37 @@ class ProfileEditPage extends HookConsumerWidget {
return userInfoAsync.when(
loading: () => Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: Colors.white,
backgroundColor: colorScheme.surface,
elevation: 0,
title: const Text(
title: Text(
'Thông tin cá nhân',
style: TextStyle(
color: Colors.black,
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
centerTitle: false,
),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: AppColors.primaryBlue),
SizedBox(height: AppSpacing.md),
Text(
'Đang tải thông tin...',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
],
),
body: const CustomLoadingIndicator(
message: 'Đang tải thông tin...',
),
),
error: (error, stack) => Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: Colors.white,
backgroundColor: colorScheme.surface,
elevation: 0,
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
onPressed: () => context.pop(),
),
title: const Text(
title: Text(
'Thông tin cá nhân',
style: TextStyle(
color: Colors.black,
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
@@ -105,11 +95,12 @@ class ProfileEditPage extends HookConsumerWidget {
color: AppColors.danger,
),
const SizedBox(height: AppSpacing.lg),
const Text(
Text(
'Không thể tải thông tin người dùng',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.md),
@@ -118,8 +109,8 @@ class ProfileEditPage extends HookConsumerWidget {
icon: const FaIcon(FontAwesomeIcons.arrowsRotate, size: 16),
label: const Text('Thử lại'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
),
],
@@ -183,12 +174,12 @@ class ProfileEditPage extends HookConsumerWidget {
}
},
child: Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: Colors.white,
backgroundColor: colorScheme.surface,
elevation: 0,
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
onPressed: () async {
if (hasChanges.value) {
final shouldPop = await _showUnsavedChangesDialog(context);
@@ -200,10 +191,10 @@ class ProfileEditPage extends HookConsumerWidget {
}
},
),
title: const Text(
title: Text(
'Thông tin cá nhân',
style: TextStyle(
color: Colors.black,
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
@@ -224,6 +215,7 @@ class ProfileEditPage extends HookConsumerWidget {
// Profile Avatar Section with Name and Status
_buildAvatarAndStatusSection(
context,
colorScheme,
selectedImage,
userInfo.initials,
userInfo.avatarUrl,
@@ -240,11 +232,11 @@ class ProfileEditPage extends HookConsumerWidget {
Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -253,13 +245,13 @@ class ProfileEditPage extends HookConsumerWidget {
child: TabBar(
controller: tabController,
indicator: BoxDecoration(
color: AppColors.primaryBlue,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(AppRadius.card),
),
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent,
labelColor: Colors.white,
unselectedLabelColor: AppColors.grey500,
labelColor: colorScheme.onPrimary,
unselectedLabelColor: colorScheme.onSurfaceVariant,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -278,6 +270,7 @@ class ProfileEditPage extends HookConsumerWidget {
// Tab 1: Personal Information (always show if no tabs, or when selected)
_buildPersonalInformationTab(
ref: ref,
colorScheme: colorScheme,
nameController: nameController,
phoneController: phoneController,
emailController: emailController,
@@ -298,6 +291,7 @@ class ProfileEditPage extends HookConsumerWidget {
_buildVerificationTab(
ref: ref,
context: context,
colorScheme: colorScheme,
idCardFrontImage: idCardFrontImage,
idCardBackImage: idCardBackImage,
certificateImages: certificateImages,
@@ -329,6 +323,7 @@ class ProfileEditPage extends HookConsumerWidget {
/// Build Personal Information Tab
Widget _buildPersonalInformationTab({
required WidgetRef ref,
required ColorScheme colorScheme,
required TextEditingController nameController,
required TextEditingController phoneController,
required TextEditingController emailController,
@@ -351,11 +346,11 @@ class ProfileEditPage extends HookConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -365,20 +360,20 @@ class ProfileEditPage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Header
const Row(
Row(
children: [
FaIcon(
FontAwesomeIcons.circleUser,
color: AppColors.primaryBlue,
color: colorScheme.primary,
size: 20,
),
SizedBox(width: 12),
const SizedBox(width: 12),
Text(
'Thông tin cá nhân',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
],
@@ -388,6 +383,7 @@ class ProfileEditPage extends HookConsumerWidget {
// Full Name
_buildTextField(
colorScheme: colorScheme,
label: 'Họ và tên',
controller: nameController,
required: true,
@@ -403,6 +399,7 @@ class ProfileEditPage extends HookConsumerWidget {
// Phone (Read-only)
_buildTextField(
colorScheme: colorScheme,
label: 'Số điện thoại',
controller: phoneController,
readOnly: true,
@@ -412,6 +409,7 @@ class ProfileEditPage extends HookConsumerWidget {
// Email (Read-only)
_buildTextField(
colorScheme: colorScheme,
label: 'Email',
controller: emailController,
readOnly: true,
@@ -422,6 +420,7 @@ class ProfileEditPage extends HookConsumerWidget {
// Birth Date
_buildDateField(
context: context,
colorScheme: colorScheme,
label: 'Ngày sinh',
controller: birthDateController,
hasChanges: hasChanges,
@@ -431,6 +430,7 @@ class ProfileEditPage extends HookConsumerWidget {
// Gender
_buildDropdownField(
colorScheme: colorScheme,
label: 'Giới tính',
value: selectedGender.value,
items: const [
@@ -450,6 +450,7 @@ class ProfileEditPage extends HookConsumerWidget {
// Company Name
_buildTextField(
colorScheme: colorScheme,
label: 'Tên công ty/Cửa hàng',
controller: companyController,
),
@@ -458,6 +459,7 @@ class ProfileEditPage extends HookConsumerWidget {
// Tax ID
_buildTextField(
colorScheme: colorScheme,
label: 'Mã số thuế',
controller: taxIdController,
),
@@ -472,15 +474,15 @@ class ProfileEditPage extends HookConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: Colors.blue),
border: Border.all(color: colorScheme.primary),
),
child: Row(
children: [
const FaIcon(
FaIcon(
FontAwesomeIcons.circleInfo,
color: AppColors.primaryBlue,
color: colorScheme.primary,
size: 16,
),
const SizedBox(width: 8),
@@ -489,7 +491,7 @@ class ProfileEditPage extends HookConsumerWidget {
'Để thay đổi số điện thoại, email hoặc vai trò, vui lòng liên hệ bộ phận hỗ trợ.',
style: TextStyle(
fontSize: 12,
color: AppColors.primaryBlue.withValues(alpha: 0.9),
color: colorScheme.onPrimaryContainer,
),
),
),
@@ -521,8 +523,8 @@ class ProfileEditPage extends HookConsumerWidget {
certificateImages: certificateImages.value,
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
@@ -549,6 +551,7 @@ class ProfileEditPage extends HookConsumerWidget {
Widget _buildVerificationTab({
required WidgetRef ref,
required BuildContext context,
required ColorScheme colorScheme,
required ValueNotifier<File?> idCardFrontImage,
required ValueNotifier<File?> idCardBackImage,
required ValueNotifier<List<File>> certificateImages,
@@ -574,10 +577,10 @@ class ProfileEditPage extends HookConsumerWidget {
decoration: BoxDecoration(
color: isVerified
? const Color(0xFFF0FDF4) // Green for verified
: const Color(0xFFEFF6FF), // Blue for not verified
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(
color: isVerified ? const Color(0xFFBBF7D0) : Colors.blue,
color: isVerified ? const Color(0xFFBBF7D0) : colorScheme.primary,
),
),
child: Row(
@@ -586,7 +589,7 @@ class ProfileEditPage extends HookConsumerWidget {
isVerified
? FontAwesomeIcons.circleCheck
: FontAwesomeIcons.circleInfo,
color: isVerified ? AppColors.success : AppColors.primaryBlue,
color: isVerified ? AppColors.success : colorScheme.primary,
size: 16,
),
const SizedBox(width: 8),
@@ -599,7 +602,7 @@ class ProfileEditPage extends HookConsumerWidget {
fontSize: 12,
color: isVerified
? const Color(0xFF166534)
: AppColors.primaryBlue.withValues(alpha: 0.9),
: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w500,
),
),
@@ -615,11 +618,11 @@ class ProfileEditPage extends HookConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -629,20 +632,20 @@ class ProfileEditPage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Section Header
const Row(
Row(
children: [
FaIcon(
FontAwesomeIcons.fileCircleCheck,
color: AppColors.primaryBlue,
color: colorScheme.primary,
size: 20,
),
SizedBox(width: 12),
const SizedBox(width: 12),
Text(
'Thông tin xác thực',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
],
@@ -651,17 +654,18 @@ class ProfileEditPage extends HookConsumerWidget {
const Divider(height: 32),
// ID Card Front Upload
const Text(
Text(
'Ảnh mặt trước CCCD/CMND',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
_buildUploadCard(
context: context,
colorScheme: colorScheme,
icon: FontAwesomeIcons.camera,
title: 'Chụp ảnh hoặc chọn file',
subtitle: 'JPG, PNG tối đa 5MB',
@@ -675,17 +679,18 @@ class ProfileEditPage extends HookConsumerWidget {
const SizedBox(height: AppSpacing.md),
// ID Card Back Upload
const Text(
Text(
'Ảnh mặt sau CCCD/CMND',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
_buildUploadCard(
context: context,
colorScheme: colorScheme,
icon: FontAwesomeIcons.camera,
title: 'Chụp ảnh hoặc chọn file',
subtitle: 'JPG, PNG tối đa 5MB',
@@ -699,17 +704,18 @@ class ProfileEditPage extends HookConsumerWidget {
const SizedBox(height: AppSpacing.md),
// Certificates Upload (Multiple)
const Text(
Text(
'Chứng chỉ hành nghề',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
_buildMultipleUploadCard(
context: context,
colorScheme: colorScheme,
selectedImages: certificateImages,
existingImageUrls: existingCertificateUrls,
isVerified: isVerified,
@@ -743,8 +749,8 @@ class ProfileEditPage extends HookConsumerWidget {
certificateImages: certificateImages.value,
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(vertical: 14),
elevation: 0,
shape: RoundedRectangleBorder(
@@ -770,6 +776,7 @@ class ProfileEditPage extends HookConsumerWidget {
/// Build upload card for verification files
Widget _buildUploadCard({
required BuildContext context,
required ColorScheme colorScheme,
required IconData icon,
required String title,
required String subtitle,
@@ -790,14 +797,14 @@ class ProfileEditPage extends HookConsumerWidget {
color: hasAnyImage
? const Color(0xFFF0FDF4)
: isDisabled
? const Color(0xFFF1F5F9) // Gray for disabled
: const Color(0xFFF8FAFC),
? colorScheme.surfaceContainerHighest
: colorScheme.surfaceContainerLowest,
border: Border.all(
color: hasAnyImage
? const Color(0xFFBBF7D0)
: isDisabled
? const Color(0xFFCBD5E1)
: const Color(0xFFE2E8F0),
? colorScheme.outlineVariant
: colorScheme.outlineVariant,
width: 2,
style: BorderStyle.solid,
),
@@ -877,7 +884,7 @@ class ProfileEditPage extends HookConsumerWidget {
children: [
FaIcon(
icon,
color: isDisabled ? AppColors.grey500 : AppColors.grey500,
color: colorScheme.onSurfaceVariant,
size: 32,
),
const SizedBox(height: 8),
@@ -887,8 +894,8 @@ class ProfileEditPage extends HookConsumerWidget {
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDisabled
? AppColors.grey500
: const Color(0xFF1E293B),
? colorScheme.onSurfaceVariant
: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
@@ -896,7 +903,7 @@ class ProfileEditPage extends HookConsumerWidget {
subtitle,
style: TextStyle(
fontSize: 12,
color: isDisabled ? AppColors.grey500 : AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -908,6 +915,7 @@ class ProfileEditPage extends HookConsumerWidget {
/// Build multiple upload card for certificates (supports multiple images)
Widget _buildMultipleUploadCard({
required BuildContext context,
required ColorScheme colorScheme,
required ValueNotifier<List<File>> selectedImages,
List<String>? existingImageUrls,
required bool isVerified,
@@ -972,35 +980,35 @@ class ProfileEditPage extends HookConsumerWidget {
child: Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
color: colorScheme.surfaceContainerLowest,
border: Border.all(
color: const Color(0xFFE2E8F0),
color: colorScheme.outlineVariant,
width: 2,
),
borderRadius: BorderRadius.circular(AppRadius.card),
),
child: Column(
children: [
const FaIcon(
FaIcon(
FontAwesomeIcons.folderPlus,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
size: 32,
),
const SizedBox(height: 8),
Text(
allImages.isEmpty ? 'Chọn ảnh chứng chỉ' : 'Thêm ảnh chứng chỉ',
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
const Text(
Text(
'Có thể chọn nhiều ảnh - JPG, PNG tối đa 5MB mỗi ảnh',
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
@@ -1014,27 +1022,27 @@ class ProfileEditPage extends HookConsumerWidget {
Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
color: colorScheme.surfaceContainerHighest,
border: Border.all(
color: const Color(0xFFCBD5E1),
color: colorScheme.outlineVariant,
width: 2,
),
borderRadius: BorderRadius.circular(AppRadius.card),
),
child: const Column(
child: Column(
children: [
FaIcon(
FontAwesomeIcons.certificate,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
size: 32,
),
SizedBox(height: 8),
const SizedBox(height: 8),
Text(
'Chưa có chứng chỉ',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -1124,6 +1132,7 @@ class ProfileEditPage extends HookConsumerWidget {
/// Build avatar section with name, position, and status
Widget _buildAvatarAndStatusSection(
BuildContext context,
ColorScheme colorScheme,
ValueNotifier<File?> selectedImage,
String initials,
String? avatarUrl,
@@ -1168,11 +1177,11 @@ class ProfileEditPage extends HookConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -1190,11 +1199,11 @@ class ProfileEditPage extends HookConsumerWidget {
height: 96,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primaryBlue,
border: Border.all(color: Colors.white, width: 4),
color: colorScheme.primary,
border: Border.all(color: colorScheme.surface, width: 4),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
color: colorScheme.shadow.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -1237,22 +1246,22 @@ class ProfileEditPage extends HookConsumerWidget {
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppColors.primaryBlue,
color: colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
border: Border.all(color: colorScheme.surface, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
color: colorScheme.shadow.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const Center(
child: Center(
child: FaIcon(
FontAwesomeIcons.camera,
size: 14,
color: Colors.white,
color: colorScheme.onPrimary,
),
),
),
@@ -1266,10 +1275,10 @@ class ProfileEditPage extends HookConsumerWidget {
// Name
Text(
fullName,
style: const TextStyle(
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
@@ -1278,9 +1287,9 @@ class ProfileEditPage extends HookConsumerWidget {
// Position
Text(
positionLabels[position] ?? position,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
@@ -1325,6 +1334,7 @@ class ProfileEditPage extends HookConsumerWidget {
/// Build text field
Widget _buildTextField({
required ColorScheme colorScheme,
required String label,
required TextEditingController controller,
bool required = false,
@@ -1339,10 +1349,10 @@ class ProfileEditPage extends HookConsumerWidget {
RichText(
text: TextSpan(
text: label,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
children: [
if (required)
@@ -1363,27 +1373,27 @@ class ProfileEditPage extends HookConsumerWidget {
decoration: InputDecoration(
hintText: 'Nhập $label',
hintStyle: TextStyle(
color: AppColors.grey500.withValues(alpha: 0.6),
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 14,
),
filled: true,
fillColor: readOnly ? const Color(0xFFF1F5F9) : const Color(0xFFF8FAFC),
fillColor: readOnly ? colorScheme.surfaceContainerHighest : colorScheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
@@ -1404,6 +1414,7 @@ class ProfileEditPage extends HookConsumerWidget {
/// Build date field
Widget _buildDateField({
required BuildContext context,
required ColorScheme colorScheme,
required String label,
required TextEditingController controller,
ValueNotifier<bool>? hasChanges,
@@ -1413,19 +1424,19 @@ class ProfileEditPage extends HookConsumerWidget {
children: [
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
TextField(
controller: controller,
readOnly: true,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
fontWeight: FontWeight.w400,
),
onTap: () async {
@@ -1447,32 +1458,32 @@ class ProfileEditPage extends HookConsumerWidget {
decoration: InputDecoration(
hintText: 'Chọn ngày sinh',
hintStyle: TextStyle(
color: AppColors.grey500.withValues(alpha: 0.6),
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 14,
),
filled: true,
fillColor: const Color(0xFFF8FAFC),
fillColor: colorScheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
suffixIcon: const Icon(
suffixIcon: Icon(
Icons.calendar_today,
size: 20,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
@@ -1484,6 +1495,7 @@ class ProfileEditPage extends HookConsumerWidget {
/// Build dropdown field
Widget _buildDropdownField({
required ColorScheme colorScheme,
required String label,
required String value,
required List<Map<String, String>> items,
@@ -1494,43 +1506,43 @@ class ProfileEditPage extends HookConsumerWidget {
children: [
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: value,
onChanged: onChanged,
icon: const Padding(
padding: EdgeInsets.only(right: 12),
icon: Padding(
padding: const EdgeInsets.only(right: 12),
child: FaIcon(
FontAwesomeIcons.chevronDown,
size: 16,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
decoration: InputDecoration(
filled: true,
fillColor: const Color(0xFFF8FAFC),
fillColor: colorScheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),

View File

@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/theme/theme_provider.dart';
/// Theme Settings Page
///
/// Allows user to customize app theme:
/// - Select seed color from predefined options
/// - Toggle light/dark mode
class ThemeSettingsPage extends ConsumerWidget {
const ThemeSettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(themeSettingsProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Giao diện'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
),
body: ListView(
padding: const EdgeInsets.all(AppSpacing.md),
children: [
// Color Selection Section
_buildSectionTitle('Màu chủ đề'),
const SizedBox(height: AppSpacing.sm),
_buildColorGrid(context, ref, settings),
const SizedBox(height: AppSpacing.lg),
// Theme Mode Section
_buildSectionTitle('Chế độ hiển thị'),
const SizedBox(height: AppSpacing.sm),
_buildThemeModeSelector(context, ref, settings, colorScheme),
],
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
);
}
Widget _buildColorGrid(
BuildContext context,
WidgetRef ref,
ThemeSettings settings,
) {
const options = AppColors.seedColorOptions;
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: Column(
children: [
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: AppSpacing.md,
crossAxisSpacing: AppSpacing.md,
childAspectRatio: 1,
),
itemCount: options.length,
itemBuilder: (context, index) {
final option = options[index];
final isSelected = option.id == settings.seedColorId;
return _ColorOption(
option: option,
isSelected: isSelected,
onTap: () {
ref.read(themeSettingsProvider.notifier).setSeedColor(option.id);
},
);
},
),
const SizedBox(height: AppSpacing.md),
// Current color name
Text(
settings.seedColorOption.name,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
Widget _buildThemeModeSelector(
BuildContext context,
WidgetRef ref,
ThemeSettings settings,
ColorScheme colorScheme,
) {
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
children: [
_ThemeModeOption(
icon: FontAwesomeIcons.mobile,
title: 'Theo hệ thống',
subtitle: 'Tự động theo cài đặt thiết bị',
isSelected: settings.themeMode == ThemeMode.system,
onTap: () {
ref.read(themeSettingsProvider.notifier).setThemeMode(ThemeMode.system);
},
),
Divider(height: 1, color: colorScheme.outlineVariant),
_ThemeModeOption(
icon: FontAwesomeIcons.sun,
title: 'Sáng',
subtitle: 'Luôn sử dụng giao diện sáng',
isSelected: settings.themeMode == ThemeMode.light,
onTap: () {
ref.read(themeSettingsProvider.notifier).setThemeMode(ThemeMode.light);
},
),
Divider(height: 1, color: colorScheme.outlineVariant),
_ThemeModeOption(
icon: FontAwesomeIcons.moon,
title: 'Tối',
subtitle: 'Luôn sử dụng giao diện tối',
isSelected: settings.themeMode == ThemeMode.dark,
onTap: () {
ref.read(themeSettingsProvider.notifier).setThemeMode(ThemeMode.dark);
},
),
],
),
);
}
}
/// Color option widget
class _ColorOption extends StatelessWidget {
const _ColorOption({
required this.option,
required this.isSelected,
required this.onTap,
});
final SeedColorOption option;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: option.color,
shape: BoxShape.circle,
border: isSelected
? Border.all(
color: Theme.of(context).colorScheme.onSurface,
width: 3,
)
: null,
boxShadow: isSelected
? [
BoxShadow(
color: option.color.withValues(alpha: 0.4),
blurRadius: 8,
spreadRadius: 2,
),
]
: null,
),
child: isSelected
? const Center(
child: Icon(
Icons.check,
color: Colors.white,
size: 24,
),
)
: null,
),
);
}
}
/// Theme mode option widget
class _ThemeModeOption extends StatelessWidget {
const _ThemeModeOption({
required this.icon,
required this.title,
required this.subtitle,
required this.isSelected,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(
children: [
FaIcon(
icon,
size: 20,
color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant,
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: colorScheme.onSurface,
),
),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (isSelected)
Icon(
Icons.check_circle,
color: colorScheme.primary,
size: 24,
),
],
),
),
);
}
}

View File

@@ -67,7 +67,7 @@ Future<GetUserInfo> getUserInfoUseCase(Ref ref) async {
///
/// userInfoAsync.when(
/// data: (userInfo) => Text(userInfo.fullName),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
///

View File

@@ -160,7 +160,7 @@ String _$getUserInfoUseCaseHash() =>
///
/// userInfoAsync.when(
/// data: (userInfo) => Text(userInfo.fullName),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
///
@@ -184,7 +184,7 @@ const userInfoProvider = UserInfoProvider._();
///
/// userInfoAsync.when(
/// data: (userInfo) => Text(userInfo.fullName),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
///
@@ -206,7 +206,7 @@ final class UserInfoProvider
///
/// userInfoAsync.when(
/// data: (userInfo) => Text(userInfo.fullName),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
///
@@ -247,7 +247,7 @@ String _$userInfoHash() => r'ed28fdf0213dfd616592b9735cd291f147867047';
///
/// userInfoAsync.when(
/// data: (userInfo) => Text(userInfo.fullName),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
///

View File

@@ -7,7 +7,6 @@ library;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
/// Account Menu Item Widget
///
@@ -51,6 +50,8 @@ class AccountMenuItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: onTap,
child: Container(
@@ -58,9 +59,9 @@ class AccountMenuItem extends StatelessWidget {
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
decoration: const BoxDecoration(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.grey100, width: 1.0),
bottom: BorderSide(color: colorScheme.outlineVariant, width: 1.0),
),
),
child: Row(
@@ -70,16 +71,14 @@ class AccountMenuItem extends StatelessWidget {
width: 40,
height: 40,
decoration: BoxDecoration(
color:
iconBackgroundColor ??
AppColors.lightBlue.withValues(alpha: 0.1),
color: iconBackgroundColor ?? colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Center(
child: FaIcon(
icon,
size: 18,
color: iconColor ?? AppColors.primaryBlue,
color: iconColor ?? colorScheme.primary,
),
),
),
@@ -92,19 +91,19 @@ class AccountMenuItem extends StatelessWidget {
children: [
Text(
title,
style: const TextStyle(
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -114,10 +113,10 @@ class AccountMenuItem extends StatelessWidget {
// Trailing widget (default: chevron)
trailing ??
const FaIcon(
FaIcon(
FontAwesomeIcons.chevronRight,
size: 18,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
],
),

View File

@@ -41,25 +41,27 @@ class AddressCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: isDefault
? Border.all(color: AppColors.primaryBlue, width: 2)
? Border.all(color: colorScheme.primary, width: 2)
: null,
boxShadow: isDefault
? [
BoxShadow(
color: AppColors.primaryBlue.withValues(alpha: 0.15),
color: colorScheme.primary.withValues(alpha: 0.15),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.shadow.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -78,7 +80,7 @@ class AddressCard extends StatelessWidget {
value: true,
groupValue: isSelected,
onChanged: (_) => onRadioTap?.call(),
activeColor: AppColors.primaryBlue,
activeColor: colorScheme.primary,
),
),
),
@@ -94,10 +96,10 @@ class AddressCard extends StatelessWidget {
Flexible(
child: Text(
name,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
@@ -110,15 +112,15 @@ class AddressCard extends StatelessWidget {
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.primaryBlue,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
child: Text(
'Mặc định',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.white,
color: colorScheme.onPrimary,
),
),
)
@@ -133,18 +135,18 @@ class AddressCard extends StatelessWidget {
),
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryBlue.withValues(
color: colorScheme.primary.withValues(
alpha: 0.3,
),
),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
child: Text(
'Đặt mặc định',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
),
@@ -157,9 +159,9 @@ class AddressCard extends StatelessWidget {
// Phone
Text(
phone,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
@@ -168,9 +170,9 @@ class AddressCard extends StatelessWidget {
// Address Text
Text(
address,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: Color(0xFF212121),
color: colorScheme.onSurface,
height: 1.4,
),
),
@@ -194,14 +196,14 @@ class AddressCard extends StatelessWidget {
width: 36,
height: 36,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)),
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: const Center(
child: Center(
child: FaIcon(
FontAwesomeIcons.penToSquare,
size: 16,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
),
@@ -221,7 +223,7 @@ class AddressCard extends StatelessWidget {
width: 36,
height: 36,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)),
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: const Center(

View File

@@ -72,42 +72,6 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
name: 'LPKD',
description: 'Đơn vị kinh doanh LPKD',
),
const BusinessUnit(
id: '2',
code: 'HSKD',
name: 'HSKD',
description: 'Đơn vị kinh doanh HSKD',
),
const BusinessUnit(
id: '3',
code: 'LPKD',
name: 'LPKD',
description: 'Đơn vị kinh doanh LPKD',
),
const BusinessUnit(
id: '2',
code: 'HSKD',
name: 'HSKD',
description: 'Đơn vị kinh doanh HSKD',
),
const BusinessUnit(
id: '3',
code: 'LPKD',
name: 'LPKD',
description: 'Đơn vị kinh doanh LPKD',
),
const BusinessUnit(
id: '2',
code: 'HSKD',
name: 'HSKD',
description: 'Đơn vị kinh doanh HSKD',
),
const BusinessUnit(
id: '3',
code: 'LPKD',
name: 'LPKD',
description: 'Đơn vị kinh doanh LPKD',
),
];
}
@@ -157,19 +121,21 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: AppColors.white,
backgroundColor: colorScheme.surface,
appBar: AppBar(
backgroundColor: AppColors.white,
backgroundColor: colorScheme.surface,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
onPressed: () => context.pop(),
),
title: const Text(
title: Text(
'Đơn vị kinh doanh',
style: TextStyle(
color: Colors.black,
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
@@ -177,7 +143,7 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
centerTitle: false,
actions: [
IconButton(
icon: const FaIcon(FontAwesomeIcons.circleInfo, color: Colors.black, size: 20),
icon: FaIcon(FontAwesomeIcons.circleInfo, color: colorScheme.onSurface, size: 20),
onPressed: _showInfoDialog,
),
const SizedBox(width: AppSpacing.sm),
@@ -200,20 +166,20 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
),
borderRadius: BorderRadius.circular(20),
),
child: const Column(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'DBIZ',
style: TextStyle(
color: Colors.white,
color: colorScheme.onPrimary,
fontSize: 32,
fontWeight: FontWeight.w700,
),
),
Text(
'Worker App',
style: TextStyle(color: Colors.white, fontSize: 12),
style: TextStyle(color: colorScheme.onPrimary, fontSize: 12),
),
],
),
@@ -223,22 +189,22 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
const SizedBox(height: AppSpacing.xl),
// Welcome Message
const Text(
Text(
'Chọn đơn vị kinh doanh để tiếp tục',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.grey500, fontSize: 14),
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 14),
),
const SizedBox(height: 40),
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.sm),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
child: Text(
'Đơn vị kinh doanh',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
),
@@ -263,11 +229,11 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
bottom: isLast ? 0 : AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.white,
color: colorScheme.surface,
border: Border.all(
color: isSelected
? AppColors.primaryBlue
: AppColors.grey100,
? colorScheme.primary
: colorScheme.surfaceContainerHighest,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.vertical(
@@ -285,7 +251,7 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
boxShadow: isSelected
? [
BoxShadow(
color: AppColors.primaryBlue.withValues(
color: colorScheme.primary.withValues(
alpha: 0.1,
),
blurRadius: 8,
@@ -325,17 +291,17 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
height: 40,
decoration: BoxDecoration(
color: isSelected
? AppColors.primaryBlue.withValues(
? colorScheme.primary.withValues(
alpha: 0.1,
)
: AppColors.grey50,
: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
FontAwesomeIcons.building,
color: isSelected
? AppColors.primaryBlue
: AppColors.grey500,
? colorScheme.primary
: colorScheme.onSurfaceVariant,
size: 20,
),
),
@@ -353,17 +319,17 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
? FontWeight.w600
: FontWeight.w500,
color: isSelected
? AppColors.primaryBlue
: AppColors.grey900,
? colorScheme.primary
: colorScheme.onSurface,
),
),
if (unit.description != null) ...[
const SizedBox(height: 2),
Text(
unit.description!,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -378,19 +344,19 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? AppColors.primaryBlue
: AppColors.grey500,
? colorScheme.primary
: colorScheme.onSurfaceVariant,
width: 2,
),
color: isSelected
? AppColors.primaryBlue
? colorScheme.primary
: Colors.transparent,
),
child: isSelected
? const Icon(
? Icon(
FontAwesomeIcons.solidCircle,
size: 10,
color: AppColors.white,
color: colorScheme.onPrimary,
)
: null,
),
@@ -412,8 +378,8 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
child: ElevatedButton(
onPressed: _handleContinue,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(

View File

@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/utils/validators.dart';
@@ -137,10 +138,12 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: AppColors.white,
backgroundColor: colorScheme.surface,
elevation: AppBarSpecs.elevation,
title: const Text(
'Quên mật khẩu',
@@ -166,27 +169,27 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
const SizedBox(height: AppSpacing.xl),
// Icon
_buildIcon(),
_buildIcon(colorScheme),
const SizedBox(height: AppSpacing.xl),
// Title & Instructions
_buildInstructions(),
_buildInstructions(colorScheme),
const SizedBox(height: AppSpacing.xl),
// Form Card
_buildFormCard(),
_buildFormCard(colorScheme),
const SizedBox(height: AppSpacing.lg),
// Back to Login Link
_buildBackToLoginLink(),
_buildBackToLoginLink(colorScheme),
const SizedBox(height: AppSpacing.xl),
// Support Link
_buildSupportLink(),
_buildSupportLink(colorScheme),
],
),
),
@@ -196,45 +199,45 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
}
/// Build icon
Widget _buildIcon() {
Widget _buildIcon(ColorScheme colorScheme) {
return Center(
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: AppColors.primaryBlue.withValues(alpha: 0.1),
color: colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
child: Icon(
FontAwesomeIcons.key,
size: 50,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
);
}
/// Build instructions
Widget _buildInstructions() {
return const Column(
Widget _buildInstructions(ColorScheme colorScheme) {
return Column(
children: [
Text(
'Đặt lại mật khẩu',
style: TextStyle(
fontSize: 28.0,
fontWeight: FontWeight.bold,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
SizedBox(height: AppSpacing.sm),
const SizedBox(height: AppSpacing.sm),
Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Text(
'Nhập số điện thoại đã đăng ký. Chúng tôi sẽ gửi mã OTP để xác nhận và đặt lại mật khẩu của bạn.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 15.0,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
@@ -244,11 +247,11 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
}
/// Build form card
Widget _buildFormCard() {
Widget _buildFormCard(ColorScheme colorScheme) {
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
@@ -282,25 +285,19 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
child: ElevatedButton(
onPressed: _isLoading ? null : _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.surfaceContainerHighest,
disabledForegroundColor: colorScheme.onSurfaceVariant,
elevation: ButtonSpecs.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSpecs.borderRadius),
),
),
child: _isLoading
? const SizedBox(
height: 20.0,
width: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.white,
),
),
? CustomLoadingIndicator(
color: colorScheme.onPrimary,
size: 20,
)
: const Text(
'Gửi mã OTP',
@@ -317,21 +314,21 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
}
/// Build back to login link
Widget _buildBackToLoginLink() {
Widget _buildBackToLoginLink(ColorScheme colorScheme) {
return Center(
child: RichText(
text: TextSpan(
text: 'Nhớ mật khẩu? ',
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
style: TextStyle(fontSize: 14.0, color: colorScheme.onSurfaceVariant),
children: [
WidgetSpan(
child: GestureDetector(
onTap: () => context.pop(),
child: const Text(
child: Text(
'Đăng nhập',
style: TextStyle(
fontSize: 14.0,
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
),
@@ -345,20 +342,20 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
}
/// Build support link
Widget _buildSupportLink() {
Widget _buildSupportLink(ColorScheme colorScheme) {
return Center(
child: TextButton.icon(
onPressed: _showSupport,
icon: const Icon(
icon: Icon(
FontAwesomeIcons.headset,
size: AppIconSize.sm,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
label: const Text(
label: Text(
'Hỗ trợ khách hàng',
style: TextStyle(
fontSize: 14.0,
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),

View File

@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
@@ -166,9 +167,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
// Watch auth state for loading indicator
final authState = ref.watch(authProvider);
final isPasswordVisible = ref.watch(passwordVisibilityProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.lg),
@@ -185,22 +187,22 @@ class _LoginPageState extends ConsumerState<LoginPage> {
const SizedBox(height: AppSpacing.xl),
// Welcome Message
_buildWelcomeMessage(),
_buildWelcomeMessage(colorScheme),
const SizedBox(height: AppSpacing.xl),
// Login Form Card
_buildLoginForm(authState, isPasswordVisible),
_buildLoginForm(authState, isPasswordVisible, colorScheme),
const SizedBox(height: AppSpacing.lg),
// Register Link
_buildRegisterLink(),
_buildRegisterLink(colorScheme),
const SizedBox(height: AppSpacing.xl),
// Support Link
_buildSupportLink(),
_buildSupportLink(colorScheme),
],
),
@@ -228,7 +230,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
Text(
'EUROTILE',
style: TextStyle(
color: AppColors.white,
color: Colors.white,
fontSize: 32.0,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
@@ -238,7 +240,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
Text(
'Worker App',
style: TextStyle(
color: AppColors.white,
color: Colors.white,
fontSize: 12.0,
letterSpacing: 0.5,
),
@@ -250,21 +252,21 @@ class _LoginPageState extends ConsumerState<LoginPage> {
}
/// Build welcome message
Widget _buildWelcomeMessage() {
return const Column(
Widget _buildWelcomeMessage(ColorScheme colorScheme) {
return Column(
children: [
Text(
'Xin chào!',
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.bold,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
SizedBox(height: AppSpacing.xs),
const SizedBox(height: AppSpacing.xs),
Text(
'Đăng nhập để tiếp tục',
style: TextStyle(fontSize: 16.0, color: AppColors.grey500),
style: TextStyle(fontSize: 16.0, color: colorScheme.onSurfaceVariant),
),
],
);
@@ -274,13 +276,14 @@ class _LoginPageState extends ConsumerState<LoginPage> {
Widget _buildLoginForm(
AsyncValue<dynamic> authState,
bool isPasswordVisible,
ColorScheme colorScheme,
) {
final isLoading = authState.isLoading;
return Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
@@ -314,30 +317,30 @@ class _LoginPageState extends ConsumerState<LoginPage> {
enabled: !isLoading,
obscureText: !isPasswordVisible,
textInputAction: TextInputAction.done,
style: const TextStyle(
style: TextStyle(
fontSize: InputFieldSpecs.fontSize,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
decoration: InputDecoration(
labelText: 'Mật khẩu',
labelStyle: const TextStyle(
labelStyle: TextStyle(
fontSize: InputFieldSpecs.labelFontSize,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
hintText: 'Nhập mật khẩu',
hintStyle: const TextStyle(
hintStyle: TextStyle(
fontSize: InputFieldSpecs.hintFontSize,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
prefixIcon: const Icon(
prefixIcon: Icon(
FontAwesomeIcons.lock,
color: AppColors.primaryBlue,
color: colorScheme.primary,
size: AppIconSize.md,
),
suffixIcon: IconButton(
icon: Icon(
isPasswordVisible ? FontAwesomeIcons.eye : FontAwesomeIcons.eyeSlash,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
size: AppIconSize.md,
),
onPressed: () {
@@ -345,14 +348,14 @@ class _LoginPageState extends ConsumerState<LoginPage> {
},
),
filled: true,
fillColor: AppColors.white,
fillColor: colorScheme.surface,
contentPadding: InputFieldSpecs.contentPadding,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(
InputFieldSpecs.borderRadius,
),
borderSide: const BorderSide(
color: AppColors.grey100,
borderSide: BorderSide(
color: colorScheme.surfaceContainerHighest,
width: 1.0,
),
),
@@ -360,8 +363,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
borderRadius: BorderRadius.circular(
InputFieldSpecs.borderRadius,
),
borderSide: const BorderSide(
color: AppColors.grey100,
borderSide: BorderSide(
color: colorScheme.surfaceContainerHighest,
width: 1.0,
),
),
@@ -369,8 +372,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
borderRadius: BorderRadius.circular(
InputFieldSpecs.borderRadius,
),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2.0,
),
),
@@ -424,7 +427,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
_rememberMe = value ?? false;
});
},
activeColor: AppColors.primaryBlue,
activeColor: colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4.0),
),
@@ -437,11 +440,11 @@ class _LoginPageState extends ConsumerState<LoginPage> {
_rememberMe = !_rememberMe;
});
},
child: const Text(
child: Text(
'Ghi nhớ đăng nhập',
style: TextStyle(
fontSize: 14.0,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
),
@@ -455,7 +458,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
'Quên mật khẩu?',
style: TextStyle(
fontSize: 14.0,
color: isLoading ? AppColors.grey500 : AppColors.primaryBlue,
color: isLoading ? colorScheme.onSurfaceVariant : colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
@@ -471,25 +474,19 @@ class _LoginPageState extends ConsumerState<LoginPage> {
child: ElevatedButton(
onPressed: isLoading ? null : _handleLogin,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
disabledBackgroundColor: AppColors.grey100,
disabledForegroundColor: AppColors.grey500,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor: colorScheme.surfaceContainerHighest,
disabledForegroundColor: colorScheme.onSurfaceVariant,
elevation: ButtonSpecs.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ButtonSpecs.borderRadius),
),
),
child: isLoading
? const SizedBox(
height: 20.0,
width: 20.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.white,
),
),
? CustomLoadingIndicator(
color: colorScheme.onPrimary,
size: 20,
)
: const Text(
'Đăng nhập',
@@ -506,21 +503,21 @@ class _LoginPageState extends ConsumerState<LoginPage> {
}
/// Build register link
Widget _buildRegisterLink() {
Widget _buildRegisterLink(ColorScheme colorScheme) {
return Center(
child: RichText(
text: TextSpan(
text: 'Chưa có tài khoản? ',
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
style: TextStyle(fontSize: 14.0, color: colorScheme.onSurfaceVariant),
children: [
WidgetSpan(
child: GestureDetector(
onTap: _navigateToRegister,
child: const Text(
child: Text(
'Đăng ký ngay',
style: TextStyle(
fontSize: 14.0,
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
),
@@ -534,20 +531,20 @@ class _LoginPageState extends ConsumerState<LoginPage> {
}
/// Build support link
Widget _buildSupportLink() {
Widget _buildSupportLink(ColorScheme colorScheme) {
return Center(
child: TextButton.icon(
onPressed: _showSupport,
icon: const Icon(
icon: Icon(
FontAwesomeIcons.headset,
size: AppIconSize.sm,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
label: const Text(
label: Text(
'Hỗ trợ khách hàng',
style: TextStyle(
fontSize: 14.0,
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),

View File

@@ -11,10 +11,11 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/theme/colors.dart'; // Keep for status colors and brand gradients
/// OTP Verification Page
///
@@ -237,19 +238,21 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: AppColors.grey50,
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: AppColors.white,
backgroundColor: colorScheme.surface,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
onPressed: () => context.pop(),
),
title: const Text(
title: Text(
'Xác thực OTP',
style: TextStyle(
color: Colors.black,
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
@@ -276,14 +279,14 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primaryBlue, AppColors.lightBlue],
colors: [AppColors.primaryBlue, AppColors.lightBlue], // Keep brand colors
),
shape: BoxShape.circle,
),
child: const Icon(
child: Icon(
FontAwesomeIcons.shieldHalved,
size: 36,
color: AppColors.white,
color: colorScheme.onPrimary,
),
),
),
@@ -291,24 +294,24 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
const SizedBox(height: AppSpacing.lg),
// Instructions
const Text(
Text(
'Nhập mã xác thực',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 12),
const Text(
Text(
'Mã OTP đã được gửi đến số điện thoại',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
@@ -317,10 +320,10 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
Text(
_formatPhoneNumber(widget.phoneNumber),
textAlign: TextAlign.center,
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
@@ -329,7 +332,7 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
// OTP Input Card
Container(
decoration: BoxDecoration(
color: AppColors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
@@ -351,7 +354,7 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
padding: EdgeInsets.only(
left: index > 0 ? 8 : 0,
),
child: _buildOtpInput(index),
child: _buildOtpInput(index, colorScheme),
),
),
),
@@ -365,8 +368,8 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
child: ElevatedButton(
onPressed: _isLoading ? null : _handleVerifyOtp,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
@@ -375,15 +378,9 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.white,
),
),
? CustomLoadingIndicator(
color: colorScheme.onPrimary,
size: 20,
)
: const Text(
'Xác nhận',
@@ -405,9 +402,9 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
child: Text.rich(
TextSpan(
text: 'Không nhận được mã? ',
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
children: [
WidgetSpan(
@@ -421,8 +418,8 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
fontSize: 12,
fontWeight: FontWeight.w500,
color: _countdown > 0
? AppColors.grey500
: AppColors.primaryBlue,
? colorScheme.onSurfaceVariant
: colorScheme.primary,
decoration: _countdown == 0
? TextDecoration.none
: TextDecoration.none,
@@ -445,7 +442,7 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
}
/// Build single OTP input box
Widget _buildOtpInput(int index) {
Widget _buildOtpInput(int index, ColorScheme colorScheme) {
return SizedBox(
width: 48,
height: 48,
@@ -455,10 +452,10 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
style: const TextStyle(
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
@@ -468,20 +465,20 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
contentPadding: EdgeInsets.zero,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.grey100,
borderSide: BorderSide(
color: colorScheme.surfaceContainerHighest,
width: 2,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
filled: false,
fillColor: AppColors.white,
fillColor: colorScheme.surface,
),
onChanged: (value) => _onOtpChanged(index, value),
onTap: () {

View File

@@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
@@ -379,6 +380,9 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
@override
Widget build(BuildContext context) {
// Get color scheme at the start of build method
final colorScheme = Theme.of(context).colorScheme;
// Initialize data on first build
if (!_hasInitialized) {
// Use addPostFrameCallback to avoid calling setState during build
@@ -388,18 +392,18 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
}
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: AppColors.white,
backgroundColor: colorScheme.surface,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
onPressed: () => context.pop(),
),
title: const Text(
title: Text(
'Đăng ký tài khoản',
style: TextStyle(
color: Colors.black,
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
@@ -407,18 +411,8 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
centerTitle: false,
),
body: _isLoadingData
? const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: AppSpacing.md),
Text(
'Đang tải dữ liệu...',
style: TextStyle(color: AppColors.grey500),
),
],
),
? const CustomLoadingIndicator(
message: 'Đang tải dữ liệu...',
)
: SafeArea(
child: Form(
@@ -429,19 +423,19 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Welcome section
const Text(
Text(
'Tạo tài khoản mới',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.xs),
const Text(
Text(
'Điền thông tin để bắt đầu',
style: TextStyle(fontSize: 14, color: AppColors.grey500),
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.lg),
@@ -449,7 +443,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
// Form card
Container(
decoration: BoxDecoration(
color: AppColors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
@@ -464,7 +458,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Full Name
_buildLabel('Họ và tên *'),
_buildLabel('Họ và tên *', colorScheme),
TextFormField(
controller: _fullNameController,
focusNode: _fullNameFocus,
@@ -472,6 +466,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
decoration: _buildInputDecoration(
hintText: 'Nhập họ và tên',
prefixIcon: FontAwesomeIcons.user,
colorScheme: colorScheme,
),
validator: (value) => Validators.minLength(
value,
@@ -482,7 +477,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
const SizedBox(height: AppSpacing.md),
// Phone Number
_buildLabel('Số điện thoại *'),
_buildLabel('Số điện thoại *', colorScheme),
PhoneInputField(
controller: _phoneController,
focusNode: _phoneFocus,
@@ -491,7 +486,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
const SizedBox(height: AppSpacing.md),
// Email
_buildLabel('Email *'),
_buildLabel('Email *', colorScheme),
TextFormField(
controller: _emailController,
focusNode: _emailFocus,
@@ -500,13 +495,14 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
decoration: _buildInputDecoration(
hintText: 'Nhập email',
prefixIcon: FontAwesomeIcons.envelope,
colorScheme: colorScheme,
),
validator: Validators.email,
),
const SizedBox(height: AppSpacing.md),
// Password
_buildLabel('Mật khẩu *'),
_buildLabel('Mật khẩu *', colorScheme),
TextFormField(
controller: _passwordController,
focusNode: _passwordFocus,
@@ -515,12 +511,13 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
decoration: _buildInputDecoration(
hintText: 'Tạo mật khẩu mới',
prefixIcon: FontAwesomeIcons.lock,
colorScheme: colorScheme,
suffixIcon: IconButton(
icon: Icon(
_passwordVisible
? FontAwesomeIcons.eye
: FontAwesomeIcons.eyeSlash,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
onPressed: () {
setState(() {
@@ -533,28 +530,28 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
Validators.passwordSimple(value, minLength: 6),
),
const SizedBox(height: AppSpacing.xs),
const Text(
Text(
'Mật khẩu tối thiểu 6 ký tự',
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: AppSpacing.md),
// Role Selection (Customer Groups)
_buildLabel('Vai trò *'),
_buildCustomerGroupDropdown(),
_buildLabel('Vai trò *', colorScheme),
_buildCustomerGroupDropdown(colorScheme),
const SizedBox(height: AppSpacing.md),
// Verification Section (conditional)
if (_shouldShowVerification) ...[
_buildVerificationSection(),
_buildVerificationSection(colorScheme),
const SizedBox(height: AppSpacing.md),
],
// Company Name (optional)
_buildLabel('Tên công ty/Cửa hàng'),
_buildLabel('Tên công ty/Cửa hàng', colorScheme),
TextFormField(
controller: _companyController,
focusNode: _companyFocus,
@@ -562,13 +559,14 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
decoration: _buildInputDecoration(
hintText: 'Nhập tên công ty (không bắt buộc)',
prefixIcon: FontAwesomeIcons.building,
colorScheme: colorScheme,
),
),
const SizedBox(height: AppSpacing.md),
// City/Province
_buildLabel('Tỉnh/Thành phố *'),
_buildCityDropdown(),
_buildLabel('Tỉnh/Thành phố *', colorScheme),
_buildCityDropdown(colorScheme),
const SizedBox(height: AppSpacing.md),
// Terms and Conditions
@@ -582,7 +580,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
_termsAccepted = value ?? false;
});
},
activeColor: AppColors.primaryBlue,
activeColor: colorScheme.primary,
),
Expanded(
child: Padding(
@@ -593,23 +591,23 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
_termsAccepted = !_termsAccepted;
});
},
child: const Text.rich(
child: Text.rich(
TextSpan(
text: 'Tôi đồng ý với ',
style: TextStyle(fontSize: 13),
style: const TextStyle(fontSize: 13),
children: [
TextSpan(
text: 'Điều khoản sử dụng',
style: TextStyle(
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
TextSpan(text: ''),
const TextSpan(text: ''),
TextSpan(
text: 'Chính sách bảo mật',
style: TextStyle(
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
@@ -629,8 +627,8 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
child: ElevatedButton(
onPressed: _isLoading ? null : _handleRegister,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
@@ -639,15 +637,9 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppColors.white,
),
),
? CustomLoadingIndicator(
color: colorScheme.onPrimary,
size: 20,
)
: const Text(
'Đăng ký',
@@ -667,17 +659,17 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
Text(
'Đã có tài khoản? ',
style: TextStyle(fontSize: 13, color: AppColors.grey500),
style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant),
),
GestureDetector(
onTap: () => context.pop(),
child: const Text(
child: Text(
'Đăng nhập',
style: TextStyle(
fontSize: 13,
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
@@ -694,15 +686,15 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
}
/// Build label widget
Widget _buildLabel(String text) {
Widget _buildLabel(String text, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Text(
text,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
);
@@ -712,34 +704,35 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
InputDecoration _buildInputDecoration({
required String hintText,
required IconData prefixIcon,
required ColorScheme colorScheme,
Widget? suffixIcon,
}) {
return InputDecoration(
hintText: hintText,
hintStyle: const TextStyle(
hintStyle: TextStyle(
fontSize: InputFieldSpecs.hintFontSize,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
prefixIcon: Icon(
prefixIcon,
color: AppColors.primaryBlue,
color: colorScheme.primary,
size: AppIconSize.md,
),
suffixIcon: suffixIcon,
filled: true,
fillColor: AppColors.white,
fillColor: colorScheme.surface,
contentPadding: InputFieldSpecs.contentPadding,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2.0),
borderSide: BorderSide(color: colorScheme.primary, width: 2.0),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
@@ -753,7 +746,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
}
/// Build customer group dropdown
Widget _buildCustomerGroupDropdown() {
Widget _buildCustomerGroupDropdown(ColorScheme colorScheme) {
final customerGroupsAsync = ref.watch(customerGroupsProvider);
return customerGroupsAsync.when(
@@ -763,6 +756,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
decoration: _buildInputDecoration(
hintText: 'Chọn vai trò',
prefixIcon: FontAwesomeIcons.briefcase,
colorScheme: colorScheme,
),
items: groups
.map(
@@ -792,9 +786,12 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
},
);
},
loading: () => const SizedBox(
loading: () => SizedBox(
height: 48,
child: Center(child: CircularProgressIndicator()),
child: CustomLoadingIndicator(
color: colorScheme.primary,
size: 20,
),
),
error: (error, stack) => Container(
height: 48,
@@ -825,7 +822,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
}
/// Build city dropdown
Widget _buildCityDropdown() {
Widget _buildCityDropdown(ColorScheme colorScheme) {
final citiesAsync = ref.watch(citiesProvider);
return citiesAsync.when(
@@ -835,6 +832,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
decoration: _buildInputDecoration(
hintText: 'Chọn tỉnh/thành phố',
prefixIcon: Icons.location_city,
colorScheme: colorScheme,
),
items: cities
.map(
@@ -857,9 +855,12 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
},
);
},
loading: () => const SizedBox(
loading: () => SizedBox(
height: 48,
child: Center(child: CircularProgressIndicator()),
child: CustomLoadingIndicator(
color: colorScheme.primary,
size: 20,
),
),
error: (error, stack) => Container(
height: 48,
@@ -890,11 +891,11 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
}
/// Build verification section
Widget _buildVerificationSection() {
Widget _buildVerificationSection(ColorScheme colorScheme) {
return Container(
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
border: Border.all(color: const Color(0xFFE2E8F0), width: 2),
color: colorScheme.surfaceContainerLowest,
border: Border.all(color: colorScheme.outlineVariant, width: 2),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
padding: const EdgeInsets.all(AppSpacing.md),
@@ -905,28 +906,28 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.shield, color: AppColors.primaryBlue, size: 20),
Icon(Icons.shield, color: colorScheme.primary, size: 20),
const SizedBox(width: AppSpacing.xs),
const Text(
Text(
'Thông tin xác thực',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
],
),
const SizedBox(height: AppSpacing.xs),
const Text(
Text(
'Thông tin này sẽ được dùng để xác minh tư cách chuyên môn của bạn',
style: TextStyle(fontSize: 12, color: AppColors.grey500),
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.md),
// ID Number
_buildLabel('Số CCCD/CMND'),
_buildLabel('Số CCCD/CMND', colorScheme),
TextFormField(
controller: _idNumberController,
focusNode: _idNumberFocus,
@@ -935,12 +936,13 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
decoration: _buildInputDecoration(
hintText: 'Nhập số CCCD/CMND',
prefixIcon: Icons.badge,
colorScheme: colorScheme,
),
),
const SizedBox(height: AppSpacing.md),
// Tax Code
_buildLabel('Mã số thuế cá nhân/Công ty'),
_buildLabel('Mã số thuế cá nhân/Công ty', colorScheme),
TextFormField(
controller: _taxCodeController,
focusNode: _taxCodeFocus,
@@ -949,13 +951,14 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
decoration: _buildInputDecoration(
hintText: 'Nhập mã số thuế (không bắt buộc)',
prefixIcon: Icons.receipt_long,
colorScheme: colorScheme,
),
validator: Validators.taxIdOptional,
),
const SizedBox(height: AppSpacing.md),
// ID Card Upload
_buildLabel('Ảnh mặt trước CCCD/CMND'),
_buildLabel('Ảnh mặt trước CCCD/CMND', colorScheme),
FileUploadCard(
file: _idCardFile,
onTap: () => _pickImage(true),
@@ -967,7 +970,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
const SizedBox(height: AppSpacing.md),
// Certificate Upload
_buildLabel('Ảnh chứng chỉ hành nghề hoặc GPKD'),
_buildLabel('Ảnh chứng chỉ hành nghề hoặc GPKD', colorScheme),
FileUploadCard(
file: _certificateFile,
onTap: () => _pickImage(false),

View File

@@ -4,6 +4,7 @@
library;
import 'package:flutter/material.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/theme/colors.dart';
/// Splash Page
@@ -15,8 +16,10 @@ class SplashPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: AppColors.white,
backgroundColor: colorScheme.surface,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -37,7 +40,7 @@ class SplashPage extends StatelessWidget {
Text(
'EUROTILE',
style: TextStyle(
color: AppColors.white,
color: Colors.white,
fontSize: 32.0,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
@@ -47,7 +50,7 @@ class SplashPage extends StatelessWidget {
Text(
'Worker App',
style: TextStyle(
color: AppColors.white,
color: Colors.white,
fontSize: 12.0,
letterSpacing: 0.5,
),
@@ -59,19 +62,16 @@ class SplashPage extends StatelessWidget {
const SizedBox(height: 48.0),
// Loading Indicator
const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryBlue),
strokeWidth: 3.0,
),
const CustomLoadingIndicator(),
const SizedBox(height: 16.0),
// Loading Text
const Text(
Text(
'Đang tải...',
style: TextStyle(
fontSize: 14.0,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],

View File

@@ -6,7 +6,6 @@
/// Uses Riverpod 3.0 with code generation for type-safe state management.
library;
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/core/constants/api_constants.dart';
@@ -14,7 +13,6 @@ import 'package:worker/core/network/dio_client.dart';
import 'package:worker/core/services/frappe_auth_service.dart';
import 'package:worker/features/auth/data/datasources/auth_local_datasource.dart';
import 'package:worker/features/auth/data/datasources/auth_remote_datasource.dart';
import 'package:worker/features/auth/data/models/auth_session_model.dart';
import 'package:worker/features/auth/domain/entities/user.dart';
part 'auth_provider.g.dart';
@@ -80,10 +78,6 @@ class Auth extends _$Auth {
Future<FrappeAuthService> get _frappeAuthService async =>
await ref.read(frappeAuthServiceProvider.future);
/// Get auth remote data source
Future<AuthRemoteDataSource> get _remoteDataSource async =>
await ref.read(authRemoteDataSourceProvider.future);
/// Initialize with saved session if available
@override
Future<User?> build() async {
@@ -170,7 +164,6 @@ class Auth extends _$Auth {
}
final frappeService = await _frappeAuthService;
final remoteDataSource = await _remoteDataSource;
// Get current session (should exist from app startup)
final currentSession = await frappeService.getStoredSession();
@@ -183,22 +176,8 @@ class Auth extends _$Auth {
}
}
// Get stored session again
final session = await frappeService.getStoredSession();
if (session == null) {
throw Exception('Session not available');
}
// Call login API with current session
final loginResponse = await remoteDataSource.login(
phone: phoneNumber,
csrfToken: session['csrfToken']!,
sid: session['sid']!,
password: password, // Reserved for future use
);
// Update FlutterSecureStorage with new authenticated session
await frappeService.login(phoneNumber, password: password);
// Call login API and store session
final loginResponse = await frappeService.login(phoneNumber, password: password);
// Save rememberMe preference
await _localDataSource.saveRememberMe(rememberMe);

View File

@@ -272,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
Auth create() => Auth();
}
String _$authHash() => r'f0438cf6eb9eb17c0afc6b23055acd09926b21ae';
String _$authHash() => r'd851980cad7a624f00eba69e19d8a4fee22008e7';
/// Authentication Provider
///

View File

@@ -77,25 +77,27 @@ class FileUploadCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (file != null) {
// Show preview with remove option
return _buildPreview(context);
return _buildPreview(context, colorScheme);
} else {
// Show upload area
return _buildUploadArea(context);
return _buildUploadArea(context, colorScheme);
}
}
/// Build upload area
Widget _buildUploadArea(BuildContext context) {
Widget _buildUploadArea(BuildContext context, ColorScheme colorScheme) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
decoration: BoxDecoration(
color: AppColors.white,
color: colorScheme.surface,
border: Border.all(
color: const Color(0xFFCBD5E1),
color: colorScheme.outlineVariant,
width: 2,
strokeAlign: BorderSide.strokeAlignInside,
),
@@ -105,16 +107,16 @@ class FileUploadCard extends StatelessWidget {
child: Column(
children: [
// Icon
Icon(icon, size: 32, color: AppColors.grey500),
Icon(icon, size: 32, color: colorScheme.onSurfaceVariant),
const SizedBox(height: AppSpacing.sm),
// Title
Text(
title,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.xs),
@@ -122,7 +124,7 @@ class FileUploadCard extends StatelessWidget {
// Subtitle
Text(
subtitle,
style: const TextStyle(fontSize: 12, color: AppColors.grey500),
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
),
],
),
@@ -131,11 +133,11 @@ class FileUploadCard extends StatelessWidget {
}
/// Build preview with remove button
Widget _buildPreview(BuildContext context) {
Widget _buildPreview(BuildContext context, ColorScheme colorScheme) {
return Container(
decoration: BoxDecoration(
color: AppColors.white,
border: Border.all(color: AppColors.grey100, width: 1),
color: colorScheme.surface,
border: Border.all(color: colorScheme.surfaceContainerHighest, width: 1),
borderRadius: BorderRadius.circular(AppRadius.md),
),
padding: const EdgeInsets.all(AppSpacing.sm),
@@ -153,10 +155,10 @@ class FileUploadCard extends StatelessWidget {
return Container(
width: 50,
height: 50,
color: AppColors.grey100,
child: const Icon(
color: colorScheme.surfaceContainerHighest,
child: Icon(
FontAwesomeIcons.image,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
size: 24,
),
);
@@ -173,10 +175,10 @@ class FileUploadCard extends StatelessWidget {
children: [
Text(
_getFileName(file!.path),
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -188,9 +190,9 @@ class FileUploadCard extends StatelessWidget {
if (snapshot.hasData) {
return Text(
_formatFileSize(snapshot.data!),
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
);
}

View File

@@ -8,7 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/theme/colors.dart'; // For AppColors.danger
/// Phone Input Field
///
@@ -65,6 +65,8 @@ class PhoneInputField extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return TextFormField(
controller: controller,
focusNode: focusNode,
@@ -78,41 +80,41 @@ class PhoneInputField extends StatelessWidget {
// Limit to reasonable phone length
LengthLimitingTextInputFormatter(15),
],
style: const TextStyle(
style: TextStyle(
fontSize: InputFieldSpecs.fontSize,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
decoration: InputDecoration(
labelText: 'Số điện thoại',
labelStyle: const TextStyle(
labelStyle: TextStyle(
fontSize: InputFieldSpecs.labelFontSize,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
hintText: 'Nhập số điện thoại',
hintStyle: const TextStyle(
hintStyle: TextStyle(
fontSize: InputFieldSpecs.hintFontSize,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
prefixIcon: const Icon(
prefixIcon: Icon(
FontAwesomeIcons.phone,
color: AppColors.primaryBlue,
color: colorScheme.primary,
size: AppIconSize.md,
),
filled: true,
fillColor: AppColors.white,
fillColor: colorScheme.surface,
contentPadding: InputFieldSpecs.contentPadding,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2.0,
),
),

View File

@@ -54,34 +54,36 @@ class RoleDropdown extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return DropdownButtonFormField<String>(
initialValue: value,
decoration: InputDecoration(
hintText: 'Chọn vai trò của bạn',
hintStyle: const TextStyle(
hintStyle: TextStyle(
fontSize: InputFieldSpecs.hintFontSize,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
prefixIcon: const Icon(
prefixIcon: Icon(
FontAwesomeIcons.briefcase,
color: AppColors.primaryBlue,
color: colorScheme.primary,
size: AppIconSize.md,
),
filled: true,
fillColor: AppColors.white,
fillColor: colorScheme.surface,
contentPadding: InputFieldSpecs.contentPadding,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2.0,
),
),
@@ -105,11 +107,11 @@ class RoleDropdown extends StatelessWidget {
],
onChanged: onChanged,
validator: validator,
icon: const FaIcon(FontAwesomeIcons.chevronDown, color: AppColors.grey500, size: 16),
dropdownColor: AppColors.white,
style: const TextStyle(
icon: FaIcon(FontAwesomeIcons.chevronDown, color: colorScheme.onSurfaceVariant, size: 16),
dropdownColor: colorScheme.surface,
style: TextStyle(
fontSize: InputFieldSpecs.fontSize,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
);
}

View File

@@ -16,8 +16,8 @@ abstract class CartRemoteDataSource {
/// Add items to cart
///
/// [items] - List of items with item_id, quantity, and amount
/// Returns list of cart items from API
Future<List<CartItemModel>> addToCart({
/// Returns true if successful
Future<bool> addToCart({
required List<Map<String, dynamic>> items,
});
@@ -47,7 +47,7 @@ class CartRemoteDataSourceImpl implements CartRemoteDataSource {
final DioClient _dioClient;
@override
Future<List<CartItemModel>> addToCart({
Future<bool> addToCart({
required List<Map<String, dynamic>> items,
}) async {
try {
@@ -78,8 +78,7 @@ class CartRemoteDataSourceImpl implements CartRemoteDataSource {
throw const ParseException('Invalid response format from add to cart API');
}
// After adding, fetch updated cart
return await getUserCart();
return true;
} on DioException catch (e) {
throw _handleDioException(e);
} catch (e) {
@@ -191,15 +190,21 @@ class CartRemoteDataSourceImpl implements CartRemoteDataSource {
try {
// Map API response to CartItemModel
// API fields: name, item, quantity, amount, item_code, item_name, image, conversion_of_sm
final quantity = (item['quantity'] as num?)?.toDouble() ?? 0.0;
final unitPrice = (item['amount'] as num?)?.toDouble() ?? 0.0;
final cartItem = CartItemModel(
cartItemId: item['name'] as String? ?? '',
cartId: 'user_cart', // Fixed cart ID for user's cart
productId: item['item_code'] as String? ?? item['item'] as String? ?? '',
quantity: (item['quantity'] as num?)?.toDouble() ?? 0.0,
unitPrice: (item['amount'] as num?)?.toDouble() ?? 0.0,
subtotal: ((item['quantity'] as num?)?.toDouble() ?? 0.0) *
((item['amount'] as num?)?.toDouble() ?? 0.0),
quantity: quantity,
unitPrice: unitPrice,
subtotal: quantity * unitPrice,
addedAt: DateTime.now(), // API doesn't provide timestamp
// Product details from cart API - no need to fetch separately
itemName: item['item_name'] as String?,
image: item['image'] as String?,
conversionOfSm: (item['conversion_of_sm'] as num?)?.toDouble(),
);
cartItems.add(cartItem);

View File

@@ -1,9 +1,12 @@
import 'package:hive_ce/hive.dart';
import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/cart/domain/entities/cart_item.dart';
part 'cart_item_model.g.dart';
/// Cart Item Model - Type ID: 5
///
/// Includes product details from cart API to avoid fetching each product.
@HiveType(typeId: HiveTypeIds.cartItemModel)
class CartItemModel extends HiveObject {
CartItemModel({
@@ -14,6 +17,9 @@ class CartItemModel extends HiveObject {
required this.unitPrice,
required this.subtotal,
required this.addedAt,
this.itemName,
this.image,
this.conversionOfSm,
});
@HiveField(0)
@@ -37,6 +43,18 @@ class CartItemModel extends HiveObject {
@HiveField(6)
final DateTime addedAt;
/// Product name from cart API
@HiveField(7)
final String? itemName;
/// Product image URL from cart API
@HiveField(8)
final String? image;
/// Conversion factor (m² to tiles) from cart API
@HiveField(9)
final double? conversionOfSm;
factory CartItemModel.fromJson(Map<String, dynamic> json) {
return CartItemModel(
cartItemId: json['cart_item_id'] as String,
@@ -67,6 +85,9 @@ class CartItemModel extends HiveObject {
double? unitPrice,
double? subtotal,
DateTime? addedAt,
String? itemName,
String? image,
double? conversionOfSm,
}) => CartItemModel(
cartItemId: cartItemId ?? this.cartItemId,
cartId: cartId ?? this.cartId,
@@ -75,5 +96,22 @@ class CartItemModel extends HiveObject {
unitPrice: unitPrice ?? this.unitPrice,
subtotal: subtotal ?? this.subtotal,
addedAt: addedAt ?? this.addedAt,
itemName: itemName ?? this.itemName,
image: image ?? this.image,
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
);
/// Convert to domain entity
CartItem toEntity() => CartItem(
cartItemId: cartItemId,
cartId: cartId,
productId: productId,
quantity: quantity,
unitPrice: unitPrice,
subtotal: subtotal,
addedAt: addedAt,
itemName: itemName,
image: image,
conversionOfSm: conversionOfSm,
);
}

View File

@@ -24,13 +24,16 @@ class CartItemModelAdapter extends TypeAdapter<CartItemModel> {
unitPrice: (fields[4] as num).toDouble(),
subtotal: (fields[5] as num).toDouble(),
addedAt: fields[6] as DateTime,
itemName: fields[7] as String?,
image: fields[8] as String?,
conversionOfSm: (fields[9] as num?)?.toDouble(),
);
}
@override
void write(BinaryWriter writer, CartItemModel obj) {
writer
..writeByte(7)
..writeByte(10)
..writeByte(0)
..write(obj.cartItemId)
..writeByte(1)
@@ -44,7 +47,13 @@ class CartItemModelAdapter extends TypeAdapter<CartItemModel> {
..writeByte(5)
..write(obj.subtotal)
..writeByte(6)
..write(obj.addedAt);
..write(obj.addedAt)
..writeByte(7)
..write(obj.itemName)
..writeByte(8)
..write(obj.image)
..writeByte(9)
..write(obj.conversionOfSm);
}
@override

View File

@@ -32,10 +32,11 @@ class CartRepositoryImpl implements CartRepository {
final CartLocalDataSource _localDataSource;
@override
Future<List<CartItem>> addToCart({
Future<bool> addToCart({
required List<String> itemIds,
required List<double> quantities,
required List<double> prices,
List<double?>? conversionFactors,
}) async {
try {
// Validate input
@@ -48,40 +49,52 @@ class CartRepositoryImpl implements CartRepository {
// Build API request items
final items = <Map<String, dynamic>>[];
for (int i = 0; i < itemIds.length; i++) {
items.add({
final item = <String, dynamic>{
'item_id': itemIds[i],
'quantity': quantities[i],
'amount': prices[i],
});
};
// Add conversion_of_sm if provided
if (conversionFactors != null && i < conversionFactors.length) {
item['conversion_of_sm'] = conversionFactors[i] ?? 0.0;
}
items.add(item);
}
// Try API first
try {
final cartItemModels = await _remoteDataSource.addToCart(items: items);
final success = await _remoteDataSource.addToCart(items: items);
// Sync to local storage
await _localDataSource.saveCartItems(cartItemModels);
// Convert to domain entities
return cartItemModels.map(_modelToEntity).toList();
} on NetworkException catch (e) {
// If no internet, add to local cart only
if (e is NoInternetException || e is TimeoutException) {
// Add items to local cart
// Also save to local storage for offline access
if (success) {
for (int i = 0; i < itemIds.length; i++) {
final cartItemModel = _createCartItemModel(
productId: itemIds[i],
quantity: quantities[i],
unitPrice: prices[i],
conversionOfSm: conversionFactors?[i],
);
await _localDataSource.addCartItem(cartItemModel);
}
}
return success;
} on NetworkException catch (e) {
// If no internet, add to local cart only
if (e is NoInternetException || e is TimeoutException) {
for (int i = 0; i < itemIds.length; i++) {
final cartItemModel = _createCartItemModel(
productId: itemIds[i],
quantity: quantities[i],
unitPrice: prices[i],
conversionOfSm: conversionFactors?[i],
);
await _localDataSource.addCartItem(cartItemModel);
}
// TODO: Queue for sync when online
// Return local cart items
final localItems = await _localDataSource.getCartItems();
return localItems.map(_modelToEntity).toList();
return true;
}
rethrow;
}
@@ -167,10 +180,11 @@ class CartRepositoryImpl implements CartRepository {
}
@override
Future<List<CartItem>> updateQuantity({
Future<bool> updateQuantity({
required String itemId,
required double quantity,
required double price,
double? conversionFactor,
}) async {
try {
// API doesn't have update endpoint, use add with new quantity
@@ -179,6 +193,7 @@ class CartRepositoryImpl implements CartRepository {
itemIds: [itemId],
quantities: [quantity],
prices: [price],
conversionFactors: conversionFactor != null ? [conversionFactor] : null,
);
} catch (e) {
throw UnknownException('Failed to update cart item quantity', e);
@@ -263,6 +278,9 @@ class CartRepositoryImpl implements CartRepository {
unitPrice: model.unitPrice,
subtotal: model.subtotal,
addedAt: model.addedAt,
itemName: model.itemName,
image: model.image,
conversionOfSm: model.conversionOfSm,
);
}
@@ -271,6 +289,7 @@ class CartRepositoryImpl implements CartRepository {
required String productId,
required double quantity,
required double unitPrice,
double? conversionOfSm,
}) {
return CartItemModel(
cartItemId: DateTime.now().millisecondsSinceEpoch.toString(),
@@ -280,6 +299,7 @@ class CartRepositoryImpl implements CartRepository {
unitPrice: unitPrice,
subtotal: quantity * unitPrice,
addedAt: DateTime.now(),
conversionOfSm: conversionOfSm,
);
}
}

View File

@@ -6,7 +6,7 @@ library;
/// Cart Item Entity
///
/// Contains item-level information:
/// - Product reference
/// - Product reference and basic info
/// - Quantity
/// - Pricing
class CartItem {
@@ -31,6 +31,15 @@ class CartItem {
/// Timestamp when item was added
final DateTime addedAt;
/// Product name from cart API
final String? itemName;
/// Product image URL from cart API
final String? image;
/// Conversion factor (m² to tiles) from cart API
final double? conversionOfSm;
const CartItem({
required this.cartItemId,
required this.cartId,
@@ -39,6 +48,9 @@ class CartItem {
required this.unitPrice,
required this.subtotal,
required this.addedAt,
this.itemName,
this.image,
this.conversionOfSm,
});
/// Calculate subtotal (for verification)
@@ -53,6 +65,9 @@ class CartItem {
double? unitPrice,
double? subtotal,
DateTime? addedAt,
String? itemName,
String? image,
double? conversionOfSm,
}) {
return CartItem(
cartItemId: cartItemId ?? this.cartItemId,
@@ -62,6 +77,9 @@ class CartItem {
unitPrice: unitPrice ?? this.unitPrice,
subtotal: subtotal ?? this.subtotal,
addedAt: addedAt ?? this.addedAt,
itemName: itemName ?? this.itemName,
image: image ?? this.image,
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
);
}

View File

@@ -22,17 +22,18 @@ import 'package:worker/features/cart/domain/entities/cart_item.dart';
abstract class CartRepository {
/// Add items to cart
///
/// [items] - List of cart items to add
/// [itemIds] - Product ERPNext item codes
/// [quantities] - Quantities for each item
/// [prices] - Unit prices for each item
/// [conversionFactors] - Conversion factors (m² to tiles) for each item
///
/// Returns list of cart items on success.
/// Returns true if successful.
/// Throws exceptions on failure.
Future<List<CartItem>> addToCart({
Future<bool> addToCart({
required List<String> itemIds,
required List<double> quantities,
required List<double> prices,
List<double?>? conversionFactors,
});
/// Remove items from cart
@@ -56,13 +57,15 @@ abstract class CartRepository {
/// [itemId] - Product ERPNext item code
/// [quantity] - New quantity
/// [price] - Unit price
/// [conversionFactor] - Conversion factor (m² to tiles)
///
/// Returns updated cart item list.
/// Returns true if successful.
/// Throws exceptions on failure.
Future<List<CartItem>> updateQuantity({
Future<bool> updateQuantity({
required String itemId,
required double quantity,
required double price,
double? conversionFactor,
});
/// Clear all items from cart

View File

@@ -3,12 +3,13 @@
/// Shopping cart screen with selection and checkout.
/// Features expanded item list with total price at bottom.
library;
import 'package:worker/core/utils/extensions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
@@ -34,14 +35,8 @@ class CartPage extends ConsumerStatefulWidget {
class _CartPageState extends ConsumerState<CartPage> {
bool _isSyncing = false;
@override
void initState() {
super.initState();
// Initialize cart from API on mount
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(cartProvider.notifier).initialize();
});
}
// Cart is initialized once in home_page.dart at app startup
// Provider has keepAlive: true, so no need to reload here
// Note: Sync is handled in PopScope.onPopInvokedWithResult for back navigation
// and in checkout button handler for checkout flow.
@@ -49,13 +44,10 @@ class _CartPageState extends ConsumerState<CartPage> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final cartState = ref.watch(cartProvider);
final currencyFormatter = NumberFormat.currency(
locale: 'vi_VN',
symbol: 'đ',
decimalDigits: 0,
);
final itemCount = cartState.itemCount;
final hasSelection = cartState.selectedCount > 0;
@@ -69,26 +61,26 @@ class _CartPageState extends ConsumerState<CartPage> {
}
},
child: Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
leading: IconButton(
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
onPressed: () => context.pop(),
),
title: Text(
'Giỏ hàng ($itemCount)',
style: const TextStyle(color: Colors.black),
style: TextStyle(color: colorScheme.onSurface),
),
elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
centerTitle: false,
actions: [
if (cartState.isNotEmpty)
IconButton(
icon: Icon(
FontAwesomeIcons.trashCan,
color: hasSelection ? AppColors.danger : AppColors.grey500,
color: hasSelection ? AppColors.danger : colorScheme.onSurfaceVariant,
),
onPressed: hasSelection
? () {
@@ -101,7 +93,7 @@ class _CartPageState extends ConsumerState<CartPage> {
],
),
body: cartState.isLoading && cartState.isEmpty
? const Center(child: CircularProgressIndicator())
? const CustomLoadingIndicator()
: cartState.errorMessage != null && cartState.isEmpty
? _buildErrorState(context, cartState.errorMessage!)
: cartState.isEmpty
@@ -130,10 +122,8 @@ class _CartPageState extends ConsumerState<CartPage> {
// Loading overlay
if (cartState.isLoading)
Container(
color: Colors.black.withValues(alpha: 0.1),
child: const Center(
child: CircularProgressIndicator(),
),
color: colorScheme.onSurface.withValues(alpha: 0.1),
child: const CustomLoadingIndicator(),
),
],
),
@@ -144,7 +134,11 @@ class _CartPageState extends ConsumerState<CartPage> {
context,
cartState,
ref,
currencyFormatter,
NumberFormat.currency(
locale: 'vi_VN',
symbol: 'đ',
decimalDigits: 0,
),
hasSelection,
),
],
@@ -155,15 +149,17 @@ class _CartPageState extends ConsumerState<CartPage> {
/// Build select all section
Widget _buildSelectAllSection(CartState cartState, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: BoxDecoration(
color: AppColors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.onSurface.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -200,7 +196,7 @@ class _CartPageState extends ConsumerState<CartPage> {
Text(
'Đã chọn: ${cartState.selectedCount}/${cartState.itemCount}',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.w600,
fontSize: 14,
),
@@ -218,15 +214,17 @@ class _CartPageState extends ConsumerState<CartPage> {
NumberFormat currencyFormatter,
bool hasSelection,
) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: AppColors.white,
border: const Border(
top: BorderSide(color: Color(0xFFF0F0F0), width: 2),
color: colorScheme.surface,
border: Border(
top: BorderSide(color: colorScheme.outlineVariant, width: 2),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
color: colorScheme.onSurface.withValues(alpha: 0.08),
blurRadius: 10,
offset: const Offset(0, -2),
),
@@ -245,14 +243,14 @@ class _CartPageState extends ConsumerState<CartPage> {
Text(
'Tổng tạm tính (${cartState.selectedCount} sản phẩm)',
style: AppTypography.bodyMedium.copyWith(
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
Text(
currencyFormatter.format(cartState.selectedTotal),
style: AppTypography.headlineSmall.copyWith(
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.bold,
fontSize: 20,
),
@@ -302,27 +300,22 @@ class _CartPageState extends ConsumerState<CartPage> {
}
: null,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
disabledBackgroundColor: AppColors.grey100,
backgroundColor: colorScheme.primary,
disabledBackgroundColor: colorScheme.inverseSurface.withValues(alpha: 0.6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 0,
),
child: _isSyncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(AppColors.white),
),
? CustomLoadingIndicator(
color: colorScheme.surface,
size: 20,
)
: Text(
'Tiến hành đặt hàng',
style: AppTypography.labelLarge.copyWith(
color: AppColors.white,
color: colorScheme.surface,
fontWeight: FontWeight.w600,
fontSize: 16,
),
@@ -359,6 +352,8 @@ class _CartPageState extends ConsumerState<CartPage> {
/// Build error state (shown when cart fails to load and is empty)
Widget _buildErrorState(BuildContext context, String errorMessage) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -374,7 +369,7 @@ class _CartPageState extends ConsumerState<CartPage> {
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
errorMessage,
style: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
style: AppTypography.bodyMedium.copyWith(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
),
@@ -393,6 +388,8 @@ class _CartPageState extends ConsumerState<CartPage> {
/// Build empty cart state
Widget _buildEmptyCart(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -400,23 +397,23 @@ class _CartPageState extends ConsumerState<CartPage> {
Icon(
FontAwesomeIcons.cartShopping,
size: 80,
color: AppColors.grey500.withValues(alpha: 0.5),
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'Giỏ hàng trống',
style: AppTypography.headlineMedium.copyWith(
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
'Hãy thêm sản phẩm vào giỏ hàng',
style: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
style: AppTypography.bodyMedium.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => context.go(RouteNames.products),
onPressed: () => context.push(RouteNames.products),
icon: const FaIcon(FontAwesomeIcons.bagShopping, size: 20),
label: const Text('Xem sản phẩm'),
),
@@ -475,24 +472,26 @@ class _CustomCheckbox extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => onChanged?.call(!value),
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: value ? AppColors.primaryBlue : AppColors.white,
color: value ? colorScheme.primary : colorScheme.surface,
border: Border.all(
color: value ? AppColors.primaryBlue : const Color(0xFFCBD5E1),
color: value ? colorScheme.primary : colorScheme.outlineVariant,
width: 2,
),
borderRadius: BorderRadius.circular(6),
),
child: value
? const Icon(
? Icon(
FontAwesomeIcons.check,
size: 16,
color: AppColors.white,
color: colorScheme.surface,
)
: null,
),

View File

@@ -15,6 +15,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
@@ -42,6 +43,8 @@ class CheckoutPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Form key for validation
final formKey = useMemoized(() => GlobalKey<FormState>());
@@ -102,22 +105,22 @@ class CheckoutPage extends HookConsumerWidget {
final total = subtotal - memberDiscount + shipping;
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
backgroundColor: Colors.white,
backgroundColor: colorScheme.surface,
elevation: 0,
leading: IconButton(
icon: const FaIcon(
icon: FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
color: colorScheme.onSurface,
size: 20,
),
onPressed: () => context.pop(),
),
title: const Text(
title: Text(
'Thanh toán',
style: TextStyle(
color: Colors.black,
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
@@ -165,29 +168,27 @@ class CheckoutPage extends HookConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.onSurface.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Center(
child: CircularProgressIndicator(),
),
child: const CustomLoadingIndicator(),
),
error: (error, stack) => Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.onSurface.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -203,9 +204,9 @@ class CheckoutPage extends HookConsumerWidget {
const SizedBox(height: 12),
Text(
'Không thể tải phương thức thanh toán',
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
@@ -225,7 +226,7 @@ class CheckoutPage extends HookConsumerWidget {
const SizedBox(height: AppSpacing.md),
// Discount Code Section
_buildDiscountCodeSection(),
_buildDiscountCodeSection(context),
const SizedBox(height: AppSpacing.md),
@@ -263,13 +264,13 @@ class CheckoutPage extends HookConsumerWidget {
},
activeColor: AppColors.warning,
),
const Expanded(
Expanded(
child: Text(
'Yêu cầu hợp đồng',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
),
@@ -281,20 +282,20 @@ class CheckoutPage extends HookConsumerWidget {
const SizedBox(height: AppSpacing.md),
// Terms and Conditions
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.md),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Text.rich(
TextSpan(
text: 'Bằng cách đặt hàng, bạn đồng ý với ',
style: TextStyle(
fontSize: 14,
color: Color(0xFF6B7280),
color: colorScheme.onSurfaceVariant,
),
children: [
TextSpan(
text: 'Điều khoản & Điều kiện',
style: TextStyle(
color: AppColors.primaryBlue,
color: colorScheme.primary,
decoration: TextDecoration.underline,
),
),
@@ -328,16 +329,18 @@ class CheckoutPage extends HookConsumerWidget {
}
/// Build Discount Code Section (Card 4 from HTML)
Widget _buildDiscountCodeSection() {
Widget _buildDiscountCodeSection(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: colorScheme.onSurface.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
@@ -351,16 +354,16 @@ class CheckoutPage extends HookConsumerWidget {
children: [
Icon(
FontAwesomeIcons.ticket,
color: AppColors.primaryBlue,
color: colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
const Text(
Text(
'Mã giảm giá',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
],
@@ -377,16 +380,16 @@ class CheckoutPage extends HookConsumerWidget {
hintText: 'Nhập mã giảm giá',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFD1D5DB)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFD1D5DB)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
@@ -403,7 +406,7 @@ class CheckoutPage extends HookConsumerWidget {
// TODO: Apply discount code
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
backgroundColor: colorScheme.primary,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
@@ -413,10 +416,10 @@ class CheckoutPage extends HookConsumerWidget {
),
elevation: 0,
),
child: const Text(
child: Text(
'Áp dụng',
style: TextStyle(
color: Colors.white,
color: colorScheme.onPrimary,
fontWeight: FontWeight.w600,
),
),
@@ -436,18 +439,18 @@ class CheckoutPage extends HookConsumerWidget {
),
child: Row(
children: [
Icon(
const Icon(
FontAwesomeIcons.circleCheck,
color: AppColors.success,
size: 18,
),
const SizedBox(width: 8),
const Expanded(
Expanded(
child: Text(
'Bạn được giảm 15% (hạng Diamond)',
style: TextStyle(
fontSize: 14,
color: Color(0xFF166534),
color: const Color(0xFF166534),
fontWeight: FontWeight.w500,
),
),

View File

@@ -9,7 +9,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:worker/features/cart/presentation/providers/cart_data_providers.dart';
import 'package:worker/features/cart/presentation/providers/cart_state.dart';
import 'package:worker/features/products/domain/entities/product.dart';
import 'package:worker/features/products/presentation/providers/products_provider.dart';
part 'cart_provider.g.dart';
@@ -46,8 +45,12 @@ class Cart extends _$Cart {
/// Initialize cart by loading from API
///
/// Call this from UI on mount to load cart items from backend.
/// Call this ONCE from HomePage on app startup.
/// Cart API returns product details, no need to fetch each product separately.
Future<void> initialize() async {
// Skip if already loaded
if (state.items.isNotEmpty) return;
final repository = await ref.read(cartRepositoryProvider.future);
// Set loading state
@@ -55,6 +58,7 @@ class Cart extends _$Cart {
try {
// Load cart items from API (with Hive fallback)
// Cart API returns: item_code, item_name, image, conversion_of_sm, quantity, amount
final cartItems = await repository.getCartItems();
// Get member tier from user profile
@@ -63,41 +67,47 @@ class Cart extends _$Cart {
const memberDiscountPercent = 15.0;
// Convert CartItem entities to CartItemData for UI
// Use product data from cart API directly - no need to fetch each product
final items = <CartItemData>[];
final selectedItems = <String, bool>{};
// Fetch product details for each cart item
final productsRepository = await ref.read(productsRepositoryProvider.future);
for (final cartItem in cartItems) {
try {
// Fetch full product entity from products repository
final product = await productsRepository.getProductById(cartItem.productId);
// Create minimal Product from cart item data (no need to fetch from API)
final now = DateTime.now();
final product = Product(
productId: cartItem.productId,
name: cartItem.itemName ?? cartItem.productId,
basePrice: cartItem.unitPrice,
images: cartItem.image != null ? [cartItem.image!] : [],
thumbnail: cartItem.image ?? '',
imageCaptions: const {},
specifications: const {},
conversionOfSm: cartItem.conversionOfSm,
erpnextItemCode: cartItem.productId,
isActive: true,
isFeatured: false,
createdAt: now,
updatedAt: now,
);
// Calculate conversion for this item
final converted = _calculateConversion(
cartItem.quantity,
product.conversionOfSm,
);
// Calculate conversion for this item
final converted = _calculateConversion(
cartItem.quantity,
product.conversionOfSm,
);
// Create CartItemData with full product info
items.add(
CartItemData(
product: product,
quantity: cartItem.quantity,
quantityConverted: converted.convertedQuantity,
boxes: converted.boxes,
),
);
// Create CartItemData with product info from cart API
items.add(
CartItemData(
product: product,
quantity: cartItem.quantity,
quantityConverted: converted.convertedQuantity,
boxes: converted.boxes,
),
);
// Initialize as not selected by default
selectedItems[product.productId] = false;
} catch (productError) {
// Skip this item if product can't be fetched
// In production, use a proper logging framework
// ignore: avoid_print
print('[CartProvider] Failed to load product ${cartItem.productId}: $productError');
}
// Initialize as not selected by default
selectedItems[product.productId] = false;
}
final newState = CartState(
@@ -150,6 +160,7 @@ class Cart extends _$Cart {
itemIds: [product.erpnextItemCode ?? product.productId],
quantities: [quantity],
prices: [product.basePrice],
conversionFactors: [product.conversionOfSm],
);
// Calculate conversion
@@ -332,6 +343,7 @@ class Cart extends _$Cart {
itemId: item.product.erpnextItemCode ?? productId,
quantity: quantity,
price: item.product.basePrice,
conversionFactor: item.product.conversionOfSm,
);
} catch (e) {
// Silent fail - keep local state, user can retry later
@@ -370,6 +382,7 @@ class Cart extends _$Cart {
itemId: item.product.erpnextItemCode ?? productId,
quantity: newQuantity,
price: item.product.basePrice,
conversionFactor: item.product.conversionOfSm,
);
// Update local state

View File

@@ -64,7 +64,7 @@ final class CartProvider extends $NotifierProvider<Cart, CartState> {
}
}
String _$cartHash() => r'706de28734e7059b2e9484f3b1d94226a0e90bb9';
String _$cartHash() => r'a12712db833127ef66b75f52cba8376409806afa';
/// Cart Notifier
///

View File

@@ -8,7 +8,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:intl/intl.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/utils/extensions.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/theme/typography.dart';
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
import 'package:worker/features/cart/presentation/providers/cart_state.dart';
@@ -74,21 +75,17 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final cartState = ref.watch(cartProvider);
final isSelected =
cartState.selectedItems[widget.item.product.productId] ?? false;
final currencyFormatter = NumberFormat.currency(
locale: 'vi_VN',
symbol: 'đ',
decimalDigits: 0,
);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
@@ -120,25 +117,29 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: widget.item.product.thumbnail,
imageUrl: widget.item.product.thumbnail.isNotEmpty
? widget.item.product.thumbnail
: (widget.item.product.images.isNotEmpty
? widget.item.product.images.first
: ''),
width: 100,
height: 100,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
width: 100,
height: 100,
color: AppColors.grey100,
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
color: colorScheme.surfaceContainerHighest,
child: Center(
child: CustomLoadingIndicator(color: colorScheme.primary, size: 20),
),
),
errorWidget: (context, url, error) => Container(
width: 100,
height: 100,
color: AppColors.grey100,
child: const FaIcon(
color: colorScheme.surfaceContainerHighest,
child: FaIcon(
FontAwesomeIcons.image,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
size: 32,
),
),
@@ -167,9 +168,9 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
// Price
Text(
'${currencyFormatter.format(widget.item.product.basePrice)}/m²',
'${widget.item.product.basePrice.toVNCurrency}/m²',
style: AppTypography.titleMedium.copyWith(
color: AppColors.primaryBlue,
color: colorScheme.primary,
fontWeight: FontWeight.bold,
fontSize: 16,
),
@@ -209,22 +210,22 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
contentPadding: EdgeInsets.zero,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(
color: Color(0xFFE0E0E0),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(
color: Color(0xFFE0E0E0),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
@@ -254,7 +255,7 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
Text(
'',
style: AppTypography.bodySmall.copyWith(
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -266,7 +267,7 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
RichText(
text: TextSpan(
style: AppTypography.bodySmall.copyWith(
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
fontSize: 13,
),
children: [
@@ -305,24 +306,25 @@ class _CustomCheckbox extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => onChanged?.call(!value),
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: value ? AppColors.primaryBlue : AppColors.white,
color: value ? colorScheme.primary : colorScheme.surface,
border: Border.all(
color: value ? AppColors.primaryBlue : const Color(0xFFCBD5E1),
color: value ? colorScheme.primary : colorScheme.outlineVariant,
width: 2,
),
borderRadius: BorderRadius.circular(6),
),
child: value
? const Icon(
? Icon(
FontAwesomeIcons.check,
size: 14,
color: AppColors.white,
color: colorScheme.surface,
)
: null,
),
@@ -341,6 +343,7 @@ class _QuantityButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(6),
@@ -348,11 +351,11 @@ class _QuantityButton extends StatelessWidget {
width: 32,
height: 32,
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE0E0E0), width: 2),
border: Border.all(color: colorScheme.outlineVariant, width: 2),
borderRadius: BorderRadius.circular(6),
color: AppColors.white,
color: colorScheme.surface,
),
child: Icon(icon, size: 16, color: AppColors.grey900),
child: Icon(icon, size: 16, color: colorScheme.onSurface),
),
);
}

View File

@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
/// Checkout Date Picker Field
///
@@ -24,15 +23,17 @@ class CheckoutDatePickerField extends HookWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Ngày nhận hàng mong muốn',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
@@ -51,9 +52,9 @@ class CheckoutDatePickerField extends HookWidget {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(AppRadius.input),
border: Border.all(color: const Color(0xFFE2E8F0)),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -65,14 +66,14 @@ class CheckoutDatePickerField extends HookWidget {
style: TextStyle(
fontSize: 14,
color: selectedDate.value != null
? const Color(0xFF212121)
: AppColors.grey500.withValues(alpha: 0.6),
? colorScheme.onSurface
: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
const Icon(
Icon(
FontAwesomeIcons.calendar,
size: 20,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
],
),

View File

@@ -28,16 +28,18 @@ class CheckoutDropdownField extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: label,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
children: [
if (required)
@@ -53,23 +55,23 @@ class CheckoutDropdownField extends StatelessWidget {
initialValue: value,
decoration: InputDecoration(
filled: true,
fillColor: const Color(0xFFF8FAFC),
fillColor: colorScheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),

View File

@@ -6,6 +6,7 @@ library;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
@@ -42,6 +43,8 @@ class CheckoutSubmitButton extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
@@ -66,8 +69,8 @@ class CheckoutSubmitButton extends HookConsumerWidget {
style: ElevatedButton.styleFrom(
backgroundColor: ignorePricingRule
? AppColors.warning
: AppColors.primaryBlue,
foregroundColor: Colors.white,
: colorScheme.primary,
foregroundColor: colorScheme.surface,
padding: const EdgeInsets.symmetric(vertical: 16),
elevation: 0,
shape: RoundedRectangleBorder(
@@ -91,8 +94,8 @@ class CheckoutSubmitButton extends HookConsumerWidget {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
builder: (context) => Center(
child: CustomLoadingIndicator(color: Theme.of(context).colorScheme.primary, size: 40),
),
);

View File

@@ -32,16 +32,18 @@ class CheckoutTextField extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
text: label,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF1E293B),
color: colorScheme.onSurface,
),
children: [
if (required)
@@ -61,27 +63,27 @@ class CheckoutTextField extends StatelessWidget {
decoration: InputDecoration(
hintText: hintText ?? 'Nhập $label',
hintStyle: TextStyle(
color: AppColors.grey500.withValues(alpha: 0.6),
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 14,
),
filled: true,
fillColor: const Color(0xFFF8FAFC),
fillColor: colorScheme.surfaceContainerLowest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.input),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),

View File

@@ -9,7 +9,6 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/account/domain/entities/address.dart';
import 'package:worker/features/account/presentation/providers/address_provider.dart';
import 'package:worker/features/cart/presentation/widgets/checkout_date_picker_field.dart';
@@ -33,6 +32,8 @@ class DeliveryInformationSection extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Watch the default address
final defaultAddr = ref.watch(defaultAddressProvider);
@@ -54,7 +55,7 @@ class DeliveryInformationSection extends HookConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
@@ -70,18 +71,18 @@ class DeliveryInformationSection extends HookConsumerWidget {
// Section Title
Row(
children: [
const FaIcon(
FaIcon(
FontAwesomeIcons.truck,
color: AppColors.primaryBlue,
color: colorScheme.primary,
size: 16,
),
const SizedBox(width: AppSpacing.sm),
const Text(
Text(
'Thông tin giao hàng',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
],
@@ -93,12 +94,12 @@ class DeliveryInformationSection extends HookConsumerWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
Text(
'Địa chỉ nhận hàng',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF424242),
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.sm),
@@ -125,7 +126,7 @@ class DeliveryInformationSection extends HookConsumerWidget {
child: Container(
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE0E0E0)),
border: Border.all(color: colorScheme.outline),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Row(
@@ -137,10 +138,10 @@ class DeliveryInformationSection extends HookConsumerWidget {
// Name
Text(
selectedAddress.value!.addressTitle,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
@@ -148,9 +149,9 @@ class DeliveryInformationSection extends HookConsumerWidget {
// Phone
Text(
selectedAddress.value!.phone,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: Color(0xFF757575),
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
@@ -158,19 +159,19 @@ class DeliveryInformationSection extends HookConsumerWidget {
// Address
Text(
selectedAddress.value!.fullAddress,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: Color(0xFF757575),
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
const FaIcon(
FaIcon(
FontAwesomeIcons.chevronRight,
size: 14,
color: Color(0xFF9E9E9E),
color: colorScheme.onSurfaceVariant,
),
],
),
@@ -194,26 +195,26 @@ class DeliveryInformationSection extends HookConsumerWidget {
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryBlue,
color: colorScheme.primary,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: const Row(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FaIcon(
FontAwesomeIcons.plus,
size: 14,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
SizedBox(width: AppSpacing.sm),
const SizedBox(width: AppSpacing.sm),
Text(
'Thêm địa chỉ giao hàng',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
],

View File

@@ -8,7 +8,6 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/account/presentation/providers/address_provider.dart';
/// Invoice Section
@@ -22,6 +21,8 @@ class InvoiceSection extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Watch the default address
final defaultAddr = ref.watch(defaultAddressProvider);
@@ -29,7 +30,7 @@ class InvoiceSection extends HookConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
@@ -45,19 +46,19 @@ class InvoiceSection extends HookConsumerWidget {
// Header with Toggle
Row(
children: [
const FaIcon(
FaIcon(
FontAwesomeIcons.fileInvoice,
color: AppColors.primaryBlue,
color: colorScheme.primary,
size: 16,
),
const SizedBox(width: AppSpacing.sm),
const Expanded(
Expanded(
child: Text(
'Phát hành hóa đơn',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
),
@@ -67,7 +68,7 @@ class InvoiceSection extends HookConsumerWidget {
onChanged: (value) {
needsInvoice.value = value;
},
activeTrackColor: AppColors.primaryBlue,
activeTrackColor: colorScheme.primary,
),
],
),
@@ -75,7 +76,7 @@ class InvoiceSection extends HookConsumerWidget {
// Invoice Information (visible when toggle is ON)
if (needsInvoice.value) ...[
const SizedBox(height: AppSpacing.md),
const Divider(color: Color(0xFFE0E0E0)),
Divider(color: colorScheme.outlineVariant),
const SizedBox(height: AppSpacing.md),
// Address Card
@@ -89,7 +90,7 @@ class InvoiceSection extends HookConsumerWidget {
child: Container(
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE0E0E0)),
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Row(
@@ -101,10 +102,10 @@ class InvoiceSection extends HookConsumerWidget {
// Company/Address Title
Text(
defaultAddr.addressTitle,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
@@ -114,9 +115,9 @@ class InvoiceSection extends HookConsumerWidget {
defaultAddr.taxCode!.isNotEmpty) ...[
Text(
'Mã số thuế: ${defaultAddr.taxCode}',
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: Color(0xFF757575),
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
@@ -125,9 +126,9 @@ class InvoiceSection extends HookConsumerWidget {
// Phone
Text(
'Số điện thoại: ${defaultAddr.phone}',
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: Color(0xFF757575),
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
@@ -137,9 +138,9 @@ class InvoiceSection extends HookConsumerWidget {
defaultAddr.email!.isNotEmpty) ...[
Text(
'Email: ${defaultAddr.email}',
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: Color(0xFF757575),
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
@@ -148,19 +149,19 @@ class InvoiceSection extends HookConsumerWidget {
// Address
Text(
'Địa chỉ: ${defaultAddr.fullAddress}',
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: Color(0xFF757575),
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
const FaIcon(
FaIcon(
FontAwesomeIcons.chevronRight,
size: 14,
color: Color(0xFF9E9E9E),
color: colorScheme.onSurfaceVariant,
),
],
),
@@ -177,26 +178,26 @@ class InvoiceSection extends HookConsumerWidget {
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryBlue,
color: colorScheme.primary,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: const Row(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FaIcon(
FontAwesomeIcons.plus,
size: 14,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
SizedBox(width: AppSpacing.sm),
const SizedBox(width: AppSpacing.sm),
Text(
'Thêm địa chỉ xuất hóa đơn',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
],

View File

@@ -29,11 +29,13 @@ class OrderSummarySection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
@@ -47,32 +49,32 @@ class OrderSummarySection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Title
const Text(
Text(
'Tóm tắt đơn hàng',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.md),
// Cart Items with conversion details
...cartItems.map((item) => _buildCartItemWithConversion(item)),
...cartItems.map((item) => _buildCartItemWithConversion(context, item)),
const Divider(height: 32),
// Subtotal
_buildSummaryRow('Tạm tính', subtotal),
_buildSummaryRow(context, 'Tạm tính', subtotal),
const SizedBox(height: 8),
// Member Tier Discount (Diamond 15%)
_buildSummaryRow('Giảm giá Diamond', -discount, isDiscount: true),
_buildSummaryRow(context, 'Giảm giá Diamond', -discount, isDiscount: true),
const SizedBox(height: 8),
// Shipping
_buildSummaryRow('Phí vận chuyển', shipping, isFree: shipping == 0),
_buildSummaryRow(context, 'Phí vận chuyển', shipping, isFree: shipping == 0),
const Divider(height: 24),
@@ -80,20 +82,20 @@ class OrderSummarySection extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
Text(
'Tổng thanh toán',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
Text(
_formatCurrency(total),
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
],
@@ -104,7 +106,9 @@ class OrderSummarySection extends StatelessWidget {
}
/// Build cart item with conversion details on two lines
Widget _buildCartItemWithConversion(Map<String, dynamic> item) {
Widget _buildCartItemWithConversion(BuildContext context, Map<String, dynamic> item) {
final colorScheme = Theme.of(context).colorScheme;
// Get real conversion data from CartItemData
final quantity = item['quantity'] as double;
final quantityConverted = item['quantityConverted'] as double;
@@ -125,10 +129,10 @@ class OrderSummarySection extends StatelessWidget {
// Line 1: Product name
Text(
item['name'] as String,
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -137,9 +141,9 @@ class OrderSummarySection extends StatelessWidget {
// Line 2: Conversion details (muted text)
Text(
'${quantity.toStringAsFixed(2)} m² ($boxes viên / ${quantityConverted.toStringAsFixed(2)} m²)',
style: const TextStyle(
style: TextStyle(
fontSize: 13,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -151,10 +155,10 @@ class OrderSummarySection extends StatelessWidget {
// Price (right side) - using converted quantity for accurate billing
Text(
_formatCurrency(price * quantityConverted),
style: const TextStyle(
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
],
@@ -164,24 +168,27 @@ class OrderSummarySection extends StatelessWidget {
/// Build summary row
Widget _buildSummaryRow(
BuildContext context,
String label,
double amount, {
bool isDiscount = false,
bool isFree = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
),
Text(
isFree ? 'Miễn phí' : _formatCurrency(amount),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isDiscount ? AppColors.success : const Color(0xFF212121),
color: isDiscount ? AppColors.success : colorScheme.onSurface,
),
),
],

View File

@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/orders/domain/entities/payment_term.dart';
/// Payment Method Section
@@ -25,13 +24,15 @@ class PaymentMethodSection extends HookWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
// Show empty state if no payment terms available
if (paymentTerms.isEmpty) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
@@ -41,12 +42,12 @@ class PaymentMethodSection extends HookWidget {
),
],
),
child: const Center(
child: Center(
child: Text(
'Không có phương thức thanh toán khả dụng',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
),
@@ -57,7 +58,7 @@ class PaymentMethodSection extends HookWidget {
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: Colors.white,
color: colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: [
BoxShadow(
@@ -71,12 +72,12 @@ class PaymentMethodSection extends HookWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Title
const Text(
Text(
'Phương thức thanh toán',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
@@ -109,12 +110,12 @@ class PaymentMethodSection extends HookWidget {
onChanged: (value) {
paymentMethod.value = value!;
},
activeColor: AppColors.primaryBlue,
activeColor: colorScheme.primary,
),
const SizedBox(width: 12),
Icon(
icon,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
size: 24,
),
const SizedBox(width: 12),
@@ -132,9 +133,9 @@ class PaymentMethodSection extends HookWidget {
const SizedBox(height: 4),
Text(
term.customDescription,
style: const TextStyle(
style: TextStyle(
fontSize: 13,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],

View File

@@ -18,6 +18,8 @@ class PriceNegotiationSection extends HookWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
@@ -35,7 +37,7 @@ class PriceNegotiationSection extends HookWidget {
},
activeColor: AppColors.warning,
),
const Expanded(
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -44,13 +46,16 @@ class PriceNegotiationSection extends HookWidget {
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
color: colorScheme.onSurface,
),
),
SizedBox(height: 4),
const SizedBox(height: 4),
Text(
'Gửi yêu cầu đàm phán giá cho đơn hàng này',
style: TextStyle(fontSize: 13, color: AppColors.grey500),
style: TextStyle(
fontSize: 13,
color: colorScheme.onSurfaceVariant,
),
),
],
),

View File

@@ -201,7 +201,7 @@ class FavoritesPage extends ConsumerWidget {
return ProductCard(productId: productId);
},
),
loading: () => CircularProgressIndicator(),
loading: () => const CustomLoadingIndicator(),
error: (error, stack) => Text('Error: $error'),
);
}

View File

@@ -119,7 +119,7 @@ class FavoritesPage extends ConsumerWidget {
return ProductTile(productId: productId);
},
),
loading: () => CircularProgressIndicator(),
loading: () => const CustomLoadingIndicator(),
error: (error, stack) => ErrorWidget(error),
);
}

View File

@@ -204,11 +204,11 @@ class FavoritesPage extends ConsumerWidget {
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
loading: () => const Center(child: const CustomLoadingIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
loading: () => const Center(child: const CustomLoadingIndicator()),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -368,7 +368,7 @@ class FavoriteProductsList extends ConsumerWidget {
);
},
),
loading: () => const CircularProgressIndicator(),
loading: () => const CustomLoadingIndicator(),
error: (error, stack) => Text('Error: $error'),
);
}
@@ -417,7 +417,7 @@ class FavoritesPageWithRefresh extends ConsumerWidget {
return ListTile(title: Text('Product: $productId'));
},
),
loading: () => const Center(child: CircularProgressIndicator()),
loading: () => const Center(child: const CustomLoadingIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
),
),
@@ -466,7 +466,7 @@ class FavoriteButtonWithLoadingState extends ConsumerWidget {
loading: () => const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
child: CustomLoadingIndicator(strokeWidth: 2),
),
error: (error, stack) => IconButton(
icon: const Icon(Icons.error, color: Colors.grey),

View File

@@ -60,6 +60,48 @@ class FavoriteProductsLocalDataSource {
bool isBoxOpen() {
return Hive.isBoxOpen(HiveBoxNames.favoriteProductsBox);
}
/// Check if a product is in favorites (local only - no API call)
bool isFavorite(String productId) {
try {
return _box.containsKey(productId);
} catch (e) {
_debugPrint('Error checking favorite: $e');
return false;
}
}
/// Get all favorite product IDs (local only - no API call)
Set<String> getFavoriteIds() {
try {
return _box.keys.cast<String>().toSet();
} catch (e) {
_debugPrint('Error getting favorite IDs: $e');
return {};
}
}
/// Add a product to local favorites cache
Future<void> addFavorite(ProductModel product) async {
try {
await _box.put(product.productId, product);
_debugPrint('Added to local favorites: ${product.productId}');
} catch (e) {
_debugPrint('Error adding to local favorites: $e');
rethrow;
}
}
/// Remove a product from local favorites cache
Future<void> removeFavorite(String productId) async {
try {
await _box.delete(productId);
_debugPrint('Removed from local favorites: $productId');
} catch (e) {
_debugPrint('Error removing from local favorites: $e');
rethrow;
}
}
}
/// Debug print helper

View File

@@ -5,6 +5,7 @@
library;
import 'package:flutter/material.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
@@ -78,6 +79,8 @@ class FavoritesPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
// Search controller
final searchController = useTextEditingController();
final searchQuery = useState('');
@@ -104,20 +107,20 @@ class FavoritesPage extends HookConsumerWidget {
}
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
backgroundColor: colorScheme.surfaceContainerLowest,
appBar: AppBar(
leading: IconButton(
icon: const FaIcon(
icon: FaIcon(
FontAwesomeIcons.arrowLeft,
color: Colors.black,
color: colorScheme.onSurface,
size: 20,
),
onPressed: () => context.pop(),
),
title: const Text('Yêu thích', style: TextStyle(color: Colors.black)),
title: Text('Yêu thích', style: TextStyle(color: colorScheme.onSurface)),
elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
centerTitle: false,
actions: [
// Count badge
@@ -127,10 +130,10 @@ class FavoritesPage extends HookConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
'($favoriteCount)',
style: const TextStyle(
style: TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.w600,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
),
@@ -139,9 +142,9 @@ class FavoritesPage extends HookConsumerWidget {
// Clear all button
if (favoriteCount > 0)
IconButton(
icon: const FaIcon(
icon: FaIcon(
FontAwesomeIcons.trashCan,
color: Colors.black,
color: colorScheme.onSurface,
size: 20,
),
tooltip: 'Xóa tất cả',
@@ -177,16 +180,16 @@ class FavoritesPage extends HookConsumerWidget {
onChanged: (value) => searchQuery.value = value,
decoration: InputDecoration(
hintText: 'Tìm kiếm sản phẩm...',
hintStyle: const TextStyle(color: AppColors.grey500),
prefixIcon: const Icon(
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
prefixIcon: Icon(
Icons.search,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
suffixIcon: searchQuery.value.isNotEmpty
? IconButton(
icon: const Icon(
icon: Icon(
Icons.clear,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
onPressed: () {
searchController.clear();
@@ -195,19 +198,19 @@ class FavoritesPage extends HookConsumerWidget {
)
: null,
filled: true,
fillColor: Colors.white,
fillColor: colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
borderSide: const BorderSide(color: AppColors.grey100),
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
borderSide: const BorderSide(color: AppColors.grey100),
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.button),
borderSide: const BorderSide(
color: AppColors.primaryBlue,
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
@@ -229,9 +232,9 @@ class FavoritesPage extends HookConsumerWidget {
alignment: Alignment.centerLeft,
child: Text(
'Tìm thấy ${filteredProducts.length} sản phẩm',
style: const TextStyle(
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
),
@@ -245,26 +248,26 @@ class FavoritesPage extends HookConsumerWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FaIcon(
FaIcon(
FontAwesomeIcons.magnifyingGlass,
size: 64,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: AppSpacing.md),
Text(
'Không tìm thấy "${searchQuery.value}"',
style: const TextStyle(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
),
const SizedBox(height: AppSpacing.sm),
const Text(
Text(
'Thử tìm kiếm với từ khóa khác',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -300,14 +303,14 @@ class FavoritesPage extends HookConsumerWidget {
},
child: _FavoritesGrid(products: previousValue),
),
const Positioned(
Positioned(
top: 16,
left: 0,
right: 0,
child: Center(
child: Card(
child: Padding(
padding: EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
@@ -317,12 +320,10 @@ class FavoritesPage extends HookConsumerWidget {
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
child: CustomLoadingIndicator(color: colorScheme.primary, size: 20),
),
SizedBox(width: 8),
Text('Đang tải...'),
const SizedBox(width: 8),
const Text('Đang tải...'),
],
),
),
@@ -431,6 +432,8 @@ class _EmptyState extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
@@ -441,18 +444,18 @@ class _EmptyState extends StatelessWidget {
FaIcon(
FontAwesomeIcons.heart,
size: 80.0,
color: AppColors.grey500.withValues(alpha: 0.5),
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
const SizedBox(height: AppSpacing.lg),
// Heading
const Text(
Text(
'Chưa có sản phẩm yêu thích',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
@@ -460,9 +463,9 @@ class _EmptyState extends StatelessWidget {
const SizedBox(height: AppSpacing.sm),
// Subtext
const Text(
Text(
'Thêm sản phẩm vào danh sách yêu thích để xem lại sau',
style: TextStyle(fontSize: 14.0, color: AppColors.grey500),
style: TextStyle(fontSize: 14.0, color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
@@ -475,8 +478,8 @@ class _EmptyState extends StatelessWidget {
context.pushReplacement('/products');
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.surface,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.md,
@@ -527,23 +530,25 @@ class _LoadingState extends StatelessWidget {
class _ShimmerCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: ProductCardSpecs.elevation,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ProductCardSpecs.borderRadius),
),
child: Shimmer.fromColors(
baseColor: AppColors.grey100,
highlightColor: AppColors.grey50,
baseColor: colorScheme.surfaceContainerHighest,
highlightColor: colorScheme.surfaceContainerLowest,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image placeholder
Expanded(
child: Container(
decoration: const BoxDecoration(
color: AppColors.grey100,
borderRadius: BorderRadius.vertical(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(ProductCardSpecs.borderRadius),
),
),
@@ -561,7 +566,7 @@ class _ShimmerCard extends StatelessWidget {
height: 14.0,
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.grey100,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4.0),
),
),
@@ -573,7 +578,7 @@ class _ShimmerCard extends StatelessWidget {
height: 12.0,
width: 80.0,
decoration: BoxDecoration(
color: AppColors.grey100,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4.0),
),
),
@@ -585,7 +590,7 @@ class _ShimmerCard extends StatelessWidget {
height: 16.0,
width: 100.0,
decoration: BoxDecoration(
color: AppColors.grey100,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4.0),
),
),
@@ -597,7 +602,7 @@ class _ShimmerCard extends StatelessWidget {
height: 36.0,
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.grey100,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(AppRadius.button),
),
),
@@ -626,6 +631,8 @@ class _ErrorState extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xl),
@@ -642,12 +649,12 @@ class _ErrorState extends StatelessWidget {
const SizedBox(height: AppSpacing.lg),
// Title
const Text(
Text(
'Có lỗi xảy ra',
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
@@ -657,7 +664,7 @@ class _ErrorState extends StatelessWidget {
// Error message
Text(
error.toString(),
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
style: TextStyle(fontSize: 14.0, color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
@@ -669,8 +676,8 @@ class _ErrorState extends StatelessWidget {
ElevatedButton.icon(
onPressed: onRetry,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: AppColors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.surface,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.md,

View File

@@ -71,7 +71,12 @@ class FavoriteProducts extends _$FavoriteProducts {
@override
Future<List<Product>> build() async {
_repository = await ref.read(favoritesRepositoryProvider.future);
return await _loadProducts();
final products = await _loadProducts();
// Sync local IDs after loading
ref.read(favoriteIdsLocalProvider.notifier).refresh();
return products;
}
// ==========================================================================
@@ -99,20 +104,22 @@ class FavoriteProducts extends _$FavoriteProducts {
/// Add a product to favorites
///
/// Calls API to add to wishlist, then refreshes the products list.
/// Calls API to add to wishlist, updates local state only (no refetch).
/// No userId needed - the API uses the authenticated session.
Future<void> addFavorite(String productId) async {
try {
_debugPrint('Adding product to favorites: $productId');
// Optimistically update local state first for instant UI feedback
ref.read(favoriteIdsLocalProvider.notifier).addId(productId);
// Call repository to add to favorites (uses auth token from session)
await _repository.addFavorite(productId);
// Refresh the products list after successful addition
await refresh();
_debugPrint('Successfully added favorite: $productId');
} catch (e) {
// Rollback optimistic update on error
ref.read(favoriteIdsLocalProvider.notifier).removeId(productId);
_debugPrint('Error adding favorite: $e');
rethrow;
}
@@ -120,20 +127,22 @@ class FavoriteProducts extends _$FavoriteProducts {
/// Remove a product from favorites
///
/// Calls API to remove from wishlist, then refreshes the products list.
/// Calls API to remove from wishlist, updates local state only (no refetch).
/// No userId needed - the API uses the authenticated session.
Future<void> removeFavorite(String productId) async {
try {
_debugPrint('Removing product from favorites: $productId');
// Optimistically update local state first for instant UI feedback
ref.read(favoriteIdsLocalProvider.notifier).removeId(productId);
// Call repository to remove from favorites (uses auth token from session)
await _repository.removeFavorite(productId);
// Refresh the products list after successful removal
await refresh();
_debugPrint('Successfully removed favorite: $productId');
} catch (e) {
// Rollback optimistic update on error
ref.read(favoriteIdsLocalProvider.notifier).addId(productId);
_debugPrint('Error removing favorite: $e');
rethrow;
}
@@ -143,9 +152,11 @@ class FavoriteProducts extends _$FavoriteProducts {
///
/// If the product is favorited, it will be removed.
/// If the product is not favorited, it will be added.
/// Checks from local state for instant response.
Future<void> toggleFavorite(String productId) async {
final currentProducts = state.value ?? [];
final isFavorited = currentProducts.any((p) => p.productId == productId);
// Check from local IDs (instant, no API call)
final localIds = ref.read(favoriteIdsLocalProvider);
final isFavorited = localIds.contains(productId);
if (isFavorited) {
await removeFavorite(productId);
@@ -170,20 +181,48 @@ class FavoriteProducts extends _$FavoriteProducts {
// HELPER PROVIDERS
// ============================================================================
/// Check if a specific product is favorited
/// Check if a specific product is favorited (LOCAL ONLY - no API call)
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
/// Reads directly from Hive local cache for instant response.
/// This is used in product detail page to avoid unnecessary API calls.
/// The cache is synced when favorites are loaded or modified.
@riverpod
bool isFavorite(Ref ref, String productId) {
final favoriteProductsAsync = ref.watch(favoriteProductsProvider);
// Watch the notifier state to trigger rebuild when favorites change
// But check from local Hive directly for instant response
ref.watch(favoriteIdsLocalProvider);
return favoriteProductsAsync.when(
data: (products) => products.any((p) => p.productId == productId),
loading: () => false,
error: (_, __) => false,
);
final localDataSource = ref.read(favoriteProductsLocalDataSourceProvider);
return localDataSource.isFavorite(productId);
}
/// Local favorite IDs provider (synced with Hive)
///
/// This provider watches Hive changes and provides a Set of favorite product IDs.
/// Used to trigger rebuilds when favorites are added/removed.
@Riverpod(keepAlive: true)
class FavoriteIdsLocal extends _$FavoriteIdsLocal {
@override
Set<String> build() {
final localDataSource = ref.read(favoriteProductsLocalDataSourceProvider);
return localDataSource.getFavoriteIds();
}
/// Refresh from local storage
void refresh() {
final localDataSource = ref.read(favoriteProductsLocalDataSourceProvider);
state = localDataSource.getFavoriteIds();
}
/// Add a product ID to local state
void addId(String productId) {
state = {...state, productId};
}
/// Remove a product ID from local state
void removeId(String productId) {
state = {...state}..remove(productId);
}
}
/// Get the total count of favorites

View File

@@ -231,7 +231,7 @@ final class FavoriteProductsProvider
FavoriteProducts create() => FavoriteProducts();
}
String _$favoriteProductsHash() => r'd43c41db210259021df104f9fecdd00cf474d196';
String _$favoriteProductsHash() => r'6d042f469a1f71bb06f8b5b76014bf24e30e6758';
/// Manages favorite products with full Product data from wishlist API
///
@@ -269,28 +269,28 @@ abstract class _$FavoriteProducts extends $AsyncNotifier<List<Product>> {
}
}
/// Check if a specific product is favorited
/// Check if a specific product is favorited (LOCAL ONLY - no API call)
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
/// Reads directly from Hive local cache for instant response.
/// This is used in product detail page to avoid unnecessary API calls.
/// The cache is synced when favorites are loaded or modified.
@ProviderFor(isFavorite)
const isFavoriteProvider = IsFavoriteFamily._();
/// Check if a specific product is favorited
/// Check if a specific product is favorited (LOCAL ONLY - no API call)
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
/// Reads directly from Hive local cache for instant response.
/// This is used in product detail page to avoid unnecessary API calls.
/// The cache is synced when favorites are loaded or modified.
final class IsFavoriteProvider extends $FunctionalProvider<bool, bool, bool>
with $Provider<bool> {
/// Check if a specific product is favorited
/// Check if a specific product is favorited (LOCAL ONLY - no API call)
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
/// Reads directly from Hive local cache for instant response.
/// This is used in product detail page to avoid unnecessary API calls.
/// The cache is synced when favorites are loaded or modified.
const IsFavoriteProvider._({
required IsFavoriteFamily super.from,
required String super.argument,
@@ -342,13 +342,13 @@ final class IsFavoriteProvider extends $FunctionalProvider<bool, bool, bool>
}
}
String _$isFavoriteHash() => r'6e2f5a50d2350975e17d91f395595cd284b69c20';
String _$isFavoriteHash() => r'7aa2377f37ceb2c450c9e29b5c134ba160e4ecc2';
/// Check if a specific product is favorited
/// Check if a specific product is favorited (LOCAL ONLY - no API call)
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
/// Reads directly from Hive local cache for instant response.
/// This is used in product detail page to avoid unnecessary API calls.
/// The cache is synced when favorites are loaded or modified.
final class IsFavoriteFamily extends $Family
with $FunctionalFamilyOverride<bool, String> {
@@ -361,11 +361,11 @@ final class IsFavoriteFamily extends $Family
isAutoDispose: true,
);
/// Check if a specific product is favorited
/// Check if a specific product is favorited (LOCAL ONLY - no API call)
///
/// Derived from the favorite products list.
/// Returns true if the product is in the user's favorites, false otherwise.
/// Safe to use in build methods - will return false during loading/error states.
/// Reads directly from Hive local cache for instant response.
/// This is used in product detail page to avoid unnecessary API calls.
/// The cache is synced when favorites are loaded or modified.
IsFavoriteProvider call(String productId) =>
IsFavoriteProvider._(argument: productId, from: this);
@@ -374,6 +374,77 @@ final class IsFavoriteFamily extends $Family
String toString() => r'isFavoriteProvider';
}
/// Local favorite IDs provider (synced with Hive)
///
/// This provider watches Hive changes and provides a Set of favorite product IDs.
/// Used to trigger rebuilds when favorites are added/removed.
@ProviderFor(FavoriteIdsLocal)
const favoriteIdsLocalProvider = FavoriteIdsLocalProvider._();
/// Local favorite IDs provider (synced with Hive)
///
/// This provider watches Hive changes and provides a Set of favorite product IDs.
/// Used to trigger rebuilds when favorites are added/removed.
final class FavoriteIdsLocalProvider
extends $NotifierProvider<FavoriteIdsLocal, Set<String>> {
/// Local favorite IDs provider (synced with Hive)
///
/// This provider watches Hive changes and provides a Set of favorite product IDs.
/// Used to trigger rebuilds when favorites are added/removed.
const FavoriteIdsLocalProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'favoriteIdsLocalProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$favoriteIdsLocalHash();
@$internal
@override
FavoriteIdsLocal create() => FavoriteIdsLocal();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(Set<String> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<Set<String>>(value),
);
}
}
String _$favoriteIdsLocalHash() => r'db248bc6dcd8ba39d8c3e410188cac67ebf96140';
/// Local favorite IDs provider (synced with Hive)
///
/// This provider watches Hive changes and provides a Set of favorite product IDs.
/// Used to trigger rebuilds when favorites are added/removed.
abstract class _$FavoriteIdsLocal extends $Notifier<Set<String>> {
Set<String> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<Set<String>, Set<String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<Set<String>, Set<String>>,
Set<String>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
/// Get the total count of favorites
///
/// Derived from the favorite products list.

View File

@@ -12,7 +12,7 @@ import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:shimmer/shimmer.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/theme/colors.dart'; // Keep for AppColors.danger and AppColors.white
import 'package:worker/features/favorites/presentation/providers/favorites_provider.dart';
import 'package:worker/features/products/domain/entities/product.dart';
@@ -76,6 +76,8 @@ class FavoriteProductCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: ProductCardSpecs.elevation,
shape: RoundedRectangleBorder(
@@ -101,16 +103,16 @@ class FavoriteProductCard extends ConsumerWidget {
memCacheWidth: ImageSpecs.productImageCacheWidth,
memCacheHeight: ImageSpecs.productImageCacheHeight,
placeholder: (context, url) => Shimmer.fromColors(
baseColor: AppColors.grey100,
highlightColor: AppColors.grey50,
child: Container(color: AppColors.grey100),
baseColor: colorScheme.surfaceContainerHighest,
highlightColor: colorScheme.surfaceContainerLowest,
child: Container(color: colorScheme.surfaceContainerHighest),
),
errorWidget: (context, url, error) => Container(
color: AppColors.grey100,
child: const FaIcon(
color: colorScheme.surfaceContainerHighest,
child: FaIcon(
FontAwesomeIcons.image,
size: 48.0,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
),
),
@@ -122,7 +124,7 @@ class FavoriteProductCard extends ConsumerWidget {
right: AppSpacing.sm,
child: Container(
decoration: BoxDecoration(
color: AppColors.white,
color: colorScheme.surface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
@@ -176,9 +178,9 @@ class FavoriteProductCard extends ConsumerWidget {
if (product.erpnextItemCode != null)
Text(
'Mã: ${product.erpnextItemCode}',
style: const TextStyle(
style: TextStyle(
fontSize: 12.0,
color: AppColors.grey500,
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -189,10 +191,10 @@ class FavoriteProductCard extends ConsumerWidget {
// Price
Text(
_formatPrice(product.effectivePrice),
style: const TextStyle(
style: TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.bold,
color: AppColors.primaryBlue,
color: colorScheme.primary,
),
),
@@ -208,9 +210,9 @@ class FavoriteProductCard extends ConsumerWidget {
context.push('/products/${product.productId}');
},
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryBlue,
side: const BorderSide(
color: AppColors.primaryBlue,
foregroundColor: colorScheme.primary,
side: BorderSide(
color: colorScheme.primary,
width: 1.5,
),
elevation: 0,

View File

@@ -8,8 +8,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/widgets/loading_indicator.dart';
import 'package:shimmer/shimmer.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/utils/extensions.dart';
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
import 'package:worker/features/home/presentation/providers/member_card_provider.dart';
import 'package:worker/features/home/presentation/providers/promotions_provider.dart';
@@ -59,8 +61,10 @@ class _HomePageState extends ConsumerState<HomePage> {
// Watch cart item count
final cartItemCount = ref.watch(cartItemCountProvider);
final colorScheme = context.colorScheme;
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8), // --background-gray from CSS
backgroundColor: colorScheme.surfaceContainerLowest,
body: CustomScrollView(
slivers: [
// Add top padding for status bar
@@ -76,39 +80,39 @@ class _HomePageState extends ConsumerState<HomePage> {
margin: const EdgeInsets.all(16),
height: 200,
decoration: BoxDecoration(
color: AppColors.grey100,
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: const Center(child: CircularProgressIndicator()),
child: const CustomLoadingIndicator(),
),
error: (error, stack) => Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.danger.withValues(alpha: 0.1),
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const FaIcon(
FaIcon(
FontAwesomeIcons.circleExclamation,
color: AppColors.danger,
color: colorScheme.error,
size: 48,
),
const SizedBox(height: 8),
Text(
l10n.error,
style: const TextStyle(
color: AppColors.danger,
style: TextStyle(
color: colorScheme.error,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
error.toString(),
style: const TextStyle(
color: AppColors.grey500,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
textAlign: TextAlign.center,
@@ -131,10 +135,7 @@ class _HomePageState extends ConsumerState<HomePage> {
},
)
: const SizedBox.shrink(),
loading: () => const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
loading: () => _buildPromotionsShimmer(colorScheme),
error: (error, stack) => const SizedBox.shrink(),
),
),
@@ -239,4 +240,93 @@ class _HomePageState extends ConsumerState<HomePage> {
),
);
}
/// Build shimmer loading for promotions section
Widget _buildPromotionsShimmer(ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title shimmer
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Tin tức nổi bật',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
),
),
const SizedBox(height: 12),
// Cards shimmer
SizedBox(
height: 210,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: 3,
itemBuilder: (context, index) {
return Shimmer.fromColors(
baseColor: colorScheme.surfaceContainerHighest,
highlightColor: colorScheme.surface,
child: Container(
width: 280,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image placeholder
Container(
height: 140,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
),
),
// Text placeholders
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 200,
height: 16,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
width: 140,
height: 12,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
),
);
},
),
),
],
),
);
}
}

View File

@@ -26,7 +26,7 @@ part 'member_card_provider.g.dart';
///
/// memberCardAsync.when(
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
/// loading: () => CircularProgressIndicator(),
/// loading: () => const CustomLoadingIndicator(),
/// error: (error, stack) => ErrorWidget(error),
/// );
/// ```

Some files were not shown because too many files have changed in this diff Show More