Compare commits
14 Commits
250c453413
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ff7b3b505 | ||
|
|
2a14f82b72 | ||
|
|
2dadcc5ce1 | ||
|
|
27798cc234 | ||
|
|
e1c9f818d2 | ||
|
|
cae04b3ae7 | ||
|
|
9fb4ba621b | ||
|
|
19d9a3dc2d | ||
|
|
fc9b5e967f | ||
|
|
211ebdf1d8 | ||
|
|
359c31a4d4 | ||
|
|
49a41d24eb | ||
|
|
12bd70479c | ||
|
|
e62c466155 |
@@ -420,7 +420,7 @@ ref.watch(userProvider).when(
|
|||||||
|
|
||||||
data: (user) => UserView(user),
|
data: (user) => UserView(user),
|
||||||
|
|
||||||
loading: () => CircularProgressIndicator(),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
|
|
||||||
error: (error, stack) => ErrorView(error),
|
error: (error, stack) => ErrorView(error),
|
||||||
|
|
||||||
@@ -443,7 +443,7 @@ switch (userState) {
|
|||||||
|
|
||||||
case AsyncLoading():
|
case AsyncLoading():
|
||||||
|
|
||||||
return CircularProgressIndicator();
|
return const CustomLoadingIndicator();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1117,5 +1117,5 @@ All recent implementations follow:
|
|||||||
- ✅ AppBar standardization
|
- ✅ AppBar standardization
|
||||||
- ✅ CachedNetworkImage for all remote images
|
- ✅ CachedNetworkImage for all remote images
|
||||||
- ✅ Proper error handling
|
- ✅ Proper error handling
|
||||||
- ✅ Loading states (CircularProgressIndicator)
|
- ✅ Loading states (CustomLoadingIndicator)
|
||||||
- ✅ Empty states with helpful messages
|
- ✅ Empty states with helpful messages
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
|
// START: FlutterFire Configuration
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
// END: FlutterFire Configuration
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
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 {
|
android {
|
||||||
namespace = "com.dbiz.partner"
|
namespace = "com.dbiz.partner"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -30,11 +43,18 @@ android {
|
|||||||
versionName = flutter.versionName
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
android/app/google-services.json
Normal file
29
android/app/google-services.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="worker"
|
android:label="worker"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pluginManagement {
|
|||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.9.1" apply false
|
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
|
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ curl --location 'https://land.dbiz.com//api/method/building_material.building_ma
|
|||||||
{
|
{
|
||||||
"item_id": "Bình giữ nhiệt Euroutile",
|
"item_id": "Bình giữ nhiệt Euroutile",
|
||||||
"amount": 3000000,
|
"amount": 3000000,
|
||||||
"quantity" : 5.78
|
"quantity" : 5.78,
|
||||||
|
"conversion_of_sm: 1.5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"item_id": "Gạch ốp Signature SIG.P-8806",
|
"item_id": "Gạch ốp Signature SIG.P-8806",
|
||||||
"amount": 4000000,
|
"amount": 4000000,
|
||||||
"quantity" : 33
|
"quantity" : 33,
|
||||||
|
"conversion_of_sm: 1.5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}'
|
}'
|
||||||
|
|||||||
97
docs/invoice.sh
Normal file
97
docs/invoice.sh
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,7 +219,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
child: _isSyncing
|
child: _isSyncing
|
||||||
? CircularProgressIndicator() // Show loading while syncing
|
? const CustomLoadingIndicator() // Show loading while syncing
|
||||||
: Text('Tiến hành đặt hàng'),
|
: Text('Tiến hành đặt hàng'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -768,5 +768,5 @@ end
|
|||||||
- ✅ Vietnamese localization
|
- ✅ Vietnamese localization
|
||||||
- ✅ CachedNetworkImage for all remote images
|
- ✅ CachedNetworkImage for all remote images
|
||||||
- ✅ Proper error handling
|
- ✅ Proper error handling
|
||||||
- ✅ Loading states (CircularProgressIndicator)
|
- ✅ Loading states (CustomLoadingIndicator)
|
||||||
- ✅ Empty states with helpful messages
|
- ✅ Empty states with helpful messages
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ int stars = apiRatingToStars(0.8); // 4
|
|||||||
- Added date formatting function (`_formatDate`)
|
- Added date formatting function (`_formatDate`)
|
||||||
|
|
||||||
**States**:
|
**States**:
|
||||||
1. **Loading**: Shows CircularProgressIndicator
|
1. **Loading**: Shows CustomLoadingIndicator
|
||||||
2. **Error**: Shows error icon and message
|
2. **Error**: Shows error icon and message
|
||||||
3. **Empty**: Shows "Chưa có đánh giá nào" with call-to-action
|
3. **Empty**: Shows "Chưa có đánh giá nào" with call-to-action
|
||||||
4. **Data**: Shows rating overview and review list
|
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'),
|
error: (error, stack) => Text('Error: $error'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ RatingProvider CountProvider in UI components)
|
|||||||
```
|
```
|
||||||
1. Initial State (Loading)
|
1. Initial State (Loading)
|
||||||
├─► productReviewsProvider returns AsyncValue.loading()
|
├─► productReviewsProvider returns AsyncValue.loading()
|
||||||
└─► UI shows CircularProgressIndicator
|
└─► UI shows CustomLoadingIndicator
|
||||||
|
|
||||||
2. Loading State → Data State
|
2. Loading State → Data State
|
||||||
├─► API call succeeds
|
├─► API call succeeds
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class ReviewsListPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(
|
loading: () => const Center(
|
||||||
child: CircularProgressIndicator(),
|
child: const CustomLoadingIndicator(),
|
||||||
),
|
),
|
||||||
error: (error, stack) => Center(
|
error: (error, stack) => Center(
|
||||||
child: Text('Error: $error'),
|
child: Text('Error: $error'),
|
||||||
@@ -263,7 +263,7 @@ class _SimpleReviewFormState extends ConsumerState<SimpleReviewForm> {
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _isSubmitting ? null : _submitReview,
|
onPressed: _isSubmitting ? null : _submitReview,
|
||||||
child: _isSubmitting
|
child: _isSubmitting
|
||||||
? const CircularProgressIndicator()
|
? const const CustomLoadingIndicator()
|
||||||
: const Text('Submit Review'),
|
: const Text('Submit Review'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -351,7 +351,7 @@ class _PaginatedReviewsListState
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const CircularProgressIndicator()
|
? const const CustomLoadingIndicator()
|
||||||
: ElevatedButton(
|
: ElevatedButton(
|
||||||
onPressed: _loadMoreReviews,
|
onPressed: _loadMoreReviews,
|
||||||
child: const Text('Load More'),
|
child: const Text('Load More'),
|
||||||
@@ -430,7 +430,7 @@ class RefreshableReviewsList extends ConsumerWidget {
|
|||||||
Center(
|
Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(40),
|
padding: EdgeInsets.all(40),
|
||||||
child: CircularProgressIndicator(),
|
child: const CustomLoadingIndicator(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -540,7 +540,7 @@ class _FilteredReviewsListState extends ConsumerState<FilteredReviewsList> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(
|
loading: () => const Center(
|
||||||
child: CircularProgressIndicator(),
|
child: const CustomLoadingIndicator(),
|
||||||
),
|
),
|
||||||
error: (error, stack) => Center(
|
error: (error, stack) => Center(
|
||||||
child: Text('Error: $error'),
|
child: Text('Error: $error'),
|
||||||
@@ -662,7 +662,7 @@ class ReviewsWithRetry extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(
|
loading: () => const Center(
|
||||||
child: CircularProgressIndicator(),
|
child: const CustomLoadingIndicator(),
|
||||||
),
|
),
|
||||||
error: (error, stack) => Center(
|
error: (error, stack) => Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ final reviewsAsync = ref.watch(productReviewsProvider(itemId));
|
|||||||
|
|
||||||
reviewsAsync.when(
|
reviewsAsync.when(
|
||||||
data: (reviews) => /* show reviews */,
|
data: (reviews) => /* show reviews */,
|
||||||
loading: () => CircularProgressIndicator(),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => /* show error */,
|
error: (error, stack) => /* show error */,
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|||||||
68
docs/payment.sh
Normal file
68
docs/payment.sh
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
|
|||||||
--data '{
|
--data '{
|
||||||
"doctype": "Item Group",
|
"doctype": "Item Group",
|
||||||
"fields": ["item_group_name","name"],
|
"fields": ["item_group_name","name"],
|
||||||
"filters": {"is_group": 0},
|
"filters": {"is_group": 0, "custom_published" : 1},
|
||||||
"limit_page_length": 0
|
"limit_page_length": 0
|
||||||
}'
|
}'
|
||||||
|
|
||||||
|
|||||||
1
firebase.json
Normal file
1
firebase.json
Normal 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
632
html/invoice-detail.html
Normal 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
351
html/invoice-list.html
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# 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.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
@@ -39,7 +39,7 @@ end
|
|||||||
# OneSignal Notification Service Extension (OUTSIDE Runner target)
|
# OneSignal Notification Service Extension (OUTSIDE Runner target)
|
||||||
target 'OneSignalNotificationServiceExtension' do
|
target 'OneSignalNotificationServiceExtension' do
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
pod 'OneSignalXCFramework', '>= 5.0.0', '< 6.0'
|
pod 'OneSignalXCFramework', '5.2.14'
|
||||||
end
|
end
|
||||||
|
|
||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
@@ -48,7 +48,7 @@ post_install do |installer|
|
|||||||
|
|
||||||
# Ensure consistent deployment target
|
# Ensure consistent deployment target
|
||||||
target.build_configurations.each do |config|
|
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
|
end
|
||||||
end
|
end
|
||||||
225
ios/Podfile.lock
225
ios/Podfile.lock
@@ -35,65 +35,132 @@ PODS:
|
|||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- DKImagePickerController/PhotoGallery
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- 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 (1.0.0)
|
||||||
- flutter_secure_storage (6.0.0):
|
- flutter_secure_storage (6.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- GoogleDataTransport (9.4.1):
|
- GoogleAdsOnDeviceConversion (3.1.0):
|
||||||
- GoogleUtilities/Environment (~> 7.7)
|
- GoogleUtilities/Environment (~> 8.1)
|
||||||
- nanopb (< 2.30911.0, >= 2.30908.0)
|
- GoogleUtilities/Logger (~> 8.1)
|
||||||
- PromisesObjC (< 3.0, >= 1.2)
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
- GoogleMLKit/BarcodeScanning (6.0.0):
|
- nanopb (~> 3.30910.0)
|
||||||
- GoogleMLKit/MLKitCore
|
- GoogleAppMeasurement/Core (12.4.0):
|
||||||
- MLKitBarcodeScanning (~> 5.0.0)
|
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||||
- GoogleMLKit/MLKitCore (6.0.0):
|
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||||
- MLKitCommon (~> 11.0.0)
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
- GoogleToolboxForMac/Defines (4.2.1)
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
- GoogleToolboxForMac/Logger (4.2.1):
|
- nanopb (~> 3.30910.0)
|
||||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
- GoogleAppMeasurement/Default (12.4.0):
|
||||||
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
- GoogleAdsOnDeviceConversion (~> 3.1.0)
|
||||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
- GoogleAppMeasurement/Core (= 12.4.0)
|
||||||
- GoogleUtilities/Environment (7.13.3):
|
- 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
|
- GoogleUtilities/Privacy
|
||||||
- PromisesObjC (< 3.0, >= 1.2)
|
- GoogleUtilities/Environment (8.1.0):
|
||||||
- GoogleUtilities/Logger (7.13.3):
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/Logger (8.1.0):
|
||||||
- GoogleUtilities/Environment
|
- GoogleUtilities/Environment
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilities/Privacy (7.13.3)
|
- GoogleUtilities/MethodSwizzler (8.1.0):
|
||||||
- GoogleUtilities/UserDefaults (7.13.3):
|
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilitiesComponents (1.1.0):
|
- GoogleUtilities/Network (8.1.0):
|
||||||
- GoogleUtilities/Logger
|
- 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):
|
- image_picker_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- integration_test (0.0.1):
|
- integration_test (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- MLImage (1.0.0-beta5)
|
- mobile_scanner (7.0.0):
|
||||||
- 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):
|
|
||||||
- Flutter
|
- Flutter
|
||||||
- GoogleMLKit/BarcodeScanning (~> 6.0.0)
|
- FlutterMacOS
|
||||||
- nanopb (2.30910.0):
|
- nanopb (3.30910.0):
|
||||||
- nanopb/decode (= 2.30910.0)
|
- nanopb/decode (= 3.30910.0)
|
||||||
- nanopb/encode (= 2.30910.0)
|
- nanopb/encode (= 3.30910.0)
|
||||||
- nanopb/decode (2.30910.0)
|
- nanopb/decode (3.30910.0)
|
||||||
- nanopb/encode (2.30910.0)
|
- nanopb/encode (3.30910.0)
|
||||||
- onesignal_flutter (5.3.4):
|
- onesignal_flutter (5.3.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- OneSignalXCFramework (= 5.2.14)
|
- OneSignalXCFramework (= 5.2.14)
|
||||||
@@ -149,9 +216,9 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- PromisesObjC (2.4.0)
|
- PromisesObjC (2.4.0)
|
||||||
- SDWebImage (5.21.3):
|
- SDWebImage (5.21.4):
|
||||||
- SDWebImage/Core (= 5.21.3)
|
- SDWebImage/Core (= 5.21.4)
|
||||||
- SDWebImage/Core (5.21.3)
|
- SDWebImage/Core (5.21.4)
|
||||||
- share_plus (0.0.1):
|
- share_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
@@ -167,13 +234,16 @@ PODS:
|
|||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/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 (from `Flutter`)
|
||||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- integration_test (from `.symlinks/plugins/integration_test/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`)
|
- 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`)
|
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
@@ -185,16 +255,16 @@ SPEC REPOS:
|
|||||||
trunk:
|
trunk:
|
||||||
- DKImagePickerController
|
- DKImagePickerController
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
|
- Firebase
|
||||||
|
- FirebaseAnalytics
|
||||||
|
- FirebaseCore
|
||||||
|
- FirebaseCoreInternal
|
||||||
|
- FirebaseInstallations
|
||||||
|
- FirebaseMessaging
|
||||||
|
- GoogleAdsOnDeviceConversion
|
||||||
|
- GoogleAppMeasurement
|
||||||
- GoogleDataTransport
|
- GoogleDataTransport
|
||||||
- GoogleMLKit
|
|
||||||
- GoogleToolboxForMac
|
|
||||||
- GoogleUtilities
|
- GoogleUtilities
|
||||||
- GoogleUtilitiesComponents
|
|
||||||
- GTMSessionFetcher
|
|
||||||
- MLImage
|
|
||||||
- MLKitBarcodeScanning
|
|
||||||
- MLKitCommon
|
|
||||||
- MLKitVision
|
|
||||||
- nanopb
|
- nanopb
|
||||||
- OneSignalXCFramework
|
- OneSignalXCFramework
|
||||||
- PromisesObjC
|
- PromisesObjC
|
||||||
@@ -206,6 +276,12 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
file_picker:
|
file_picker:
|
||||||
:path: ".symlinks/plugins/file_picker/ios"
|
: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:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
@@ -215,7 +291,7 @@ EXTERNAL SOURCES:
|
|||||||
integration_test:
|
integration_test:
|
||||||
:path: ".symlinks/plugins/integration_test/ios"
|
:path: ".symlinks/plugins/integration_test/ios"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||||
onesignal_flutter:
|
onesignal_flutter:
|
||||||
:path: ".symlinks/plugins/onesignal_flutter/ios"
|
:path: ".symlinks/plugins/onesignal_flutter/ios"
|
||||||
open_file_ios:
|
open_file_ios:
|
||||||
@@ -236,34 +312,37 @@ SPEC CHECKSUMS:
|
|||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
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: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1
|
||||||
GoogleMLKit: 97ac7af399057e99182ee8edfa8249e3226a4065
|
GoogleAppMeasurement: 1e718274b7e015cefd846ac1fcf7820c70dc017d
|
||||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||||
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
|
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||||
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
|
|
||||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
|
||||||
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
|
image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b
|
||||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||||
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
|
mobile_scanner: 77265f3dc8d580810e91849d4a0811a90467ed5e
|
||||||
MLKitBarcodeScanning: 10ca0845a6d15f2f6e911f682a1998b68b973e8b
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
MLKitCommon: afec63980417d29ffbb4790529a1b0a2291699e1
|
|
||||||
MLKitVision: e858c5f125ecc288e4a31127928301eaba9ae0c1
|
|
||||||
mobile_scanner: 96e91f2e1fb396bb7df8da40429ba8dfad664740
|
|
||||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
|
||||||
onesignal_flutter: f69ff09eeaf41cea4b7a841de5a61e79e7fd9a5a
|
onesignal_flutter: f69ff09eeaf41cea4b7a841de5a61e79e7fd9a5a
|
||||||
OneSignalXCFramework: 7112f3e89563e41ebc23fe807788f11985ac541c
|
OneSignalXCFramework: 7112f3e89563e41ebc23fe807788f11985ac541c
|
||||||
open_file_ios: 461db5853723763573e140de3193656f91990d9e
|
open_file_ios: 461db5853723763573e140de3193656f91990d9e
|
||||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf
|
||||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||||
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
|
||||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
|
url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa
|
||||||
|
|
||||||
PODFILE CHECKSUM: 41022e80ca79dfdcc337fcf6a6cca3b7d3cb6958
|
PODFILE CHECKSUM: d8ed968486e3d2e023338569c014e9285ba7bc53
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
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, ); }; };
|
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 */; };
|
58215889146B2DBBD9C81410 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2545A56CA7C5FCC88F0D6DF7 /* Pods_Runner.framework */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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; };
|
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>"; };
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
@@ -175,6 +177,7 @@
|
|||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
D39C332D04678D8C49EEA401 /* Pods */,
|
D39C332D04678D8C49EEA401 /* Pods */,
|
||||||
E0C416BADC6D23D3F5D8CCA9 /* Frameworks */,
|
E0C416BADC6D23D3F5D8CCA9 /* Frameworks */,
|
||||||
|
1D94728EBD25906AAA78A0D5 /* GoogleService-Info.plist */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -365,6 +368,7 @@
|
|||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
41D57CDF80C517B01729B4E6 /* GoogleService-Info.plist in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,6 +73,12 @@
|
|||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
<CommandLineArgument
|
||||||
|
argument = "-FIRDebugEnabled"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</CommandLineArgument>
|
||||||
|
</CommandLineArguments>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Profile"
|
buildConfiguration = "Profile"
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
// #if DEBUG
|
||||||
|
// var args = ProcessInfo.processInfo.arguments
|
||||||
|
// args.append("-FIRDebugEnabled")
|
||||||
|
// ProcessInfo.processInfo.setValue(args, forKey: "arguments")
|
||||||
|
// #endif
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|||||||
30
ios/Runner/GoogleService-Info.plist
Normal file
30
ios/Runner/GoogleService-Info.plist
Normal 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>
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
<string>Ứng dụng cần quyền truy cập microphone để ghi âm video nếu cần</string>
|
<string>Ứng dụng cần quyền truy cập microphone để ghi âm video nếu cần</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<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>
|
<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>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
|
|||||||
@@ -271,6 +271,30 @@ class ApiConstants {
|
|||||||
/// GET /payments/{paymentId}
|
/// GET /payments/{paymentId}
|
||||||
static const String getPaymentDetails = '/payments';
|
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)
|
// Project Endpoints (Frappe ERPNext)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ library;
|
|||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -569,10 +570,10 @@ Future<AuthInterceptor> authInterceptor(Ref ref, Dio dio) async {
|
|||||||
@riverpod
|
@riverpod
|
||||||
LoggingInterceptor loggingInterceptor(Ref ref) {
|
LoggingInterceptor loggingInterceptor(Ref ref) {
|
||||||
// Only enable logging in debug mode
|
// 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(
|
return LoggingInterceptor(
|
||||||
enableRequestLogging: false,
|
enableRequestLogging: true,
|
||||||
enableResponseLogging: isDebug,
|
enableResponseLogging: isDebug,
|
||||||
enableErrorLogging: isDebug,
|
enableErrorLogging: isDebug,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ final class LoggingInterceptorProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$loggingInterceptorHash() =>
|
String _$loggingInterceptorHash() =>
|
||||||
r'6afa480caa6fcd723dab769bb01601b8a37e20fd';
|
r'79e90e0eb78663d2645d2d7c467e01bc18a30551';
|
||||||
|
|
||||||
/// Provider for ErrorTransformerInterceptor
|
/// Provider for ErrorTransformerInterceptor
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'dart:developer' as developer;
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:dio_cache_interceptor/dio_cache_interceptor.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: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:path_provider/path_provider.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ Future<User> user(UserRef ref, String id) async {
|
|||||||
final userAsync = ref.watch(userProvider('123'));
|
final userAsync = ref.watch(userProvider('123'));
|
||||||
userAsync.when(
|
userAsync.when(
|
||||||
data: (user) => Text(user.name),
|
data: (user) => Text(user.name),
|
||||||
loading: () => CircularProgressIndicator(),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (e, _) => Text('Error: $e'),
|
error: (e, _) => Text('Error: $e'),
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
@@ -202,7 +202,7 @@ final newValue = ref.refresh(userProvider);
|
|||||||
```dart
|
```dart
|
||||||
asyncValue.when(
|
asyncValue.when(
|
||||||
data: (value) => Text(value),
|
data: (value) => Text(value),
|
||||||
loading: () => CircularProgressIndicator(),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => Text('Error: $error'),
|
error: (error, stack) => Text('Error: $error'),
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
@@ -215,7 +215,7 @@ switch (asyncValue) {
|
|||||||
case AsyncError(:final error):
|
case AsyncError(:final error):
|
||||||
return Text('Error: $error');
|
return Text('Error: $error');
|
||||||
case AsyncLoading():
|
case AsyncLoading():
|
||||||
return CircularProgressIndicator();
|
return const CustomLoadingIndicator();
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Connectivity connectivity(Ref ref) {
|
|||||||
/// final connectivityState = ref.watch(connectivityStreamProvider);
|
/// final connectivityState = ref.watch(connectivityStreamProvider);
|
||||||
/// connectivityState.when(
|
/// connectivityState.when(
|
||||||
/// data: (status) => Text('Status: $status'),
|
/// data: (status) => Text('Status: $status'),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, _) => Text('Error: $error'),
|
/// error: (error, _) => Text('Error: $error'),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@@ -83,7 +83,7 @@ Future<ConnectivityStatus> currentConnectivity(Ref ref) async {
|
|||||||
/// final isOnlineAsync = ref.watch(isOnlineProvider);
|
/// final isOnlineAsync = ref.watch(isOnlineProvider);
|
||||||
/// isOnlineAsync.when(
|
/// isOnlineAsync.when(
|
||||||
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
|
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, _) => Text('Error: $error'),
|
/// error: (error, _) => Text('Error: $error'),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ String _$connectivityHash() => r'6d67af0ea4110f6ee0246dd332f90f8901380eda';
|
|||||||
/// final connectivityState = ref.watch(connectivityStreamProvider);
|
/// final connectivityState = ref.watch(connectivityStreamProvider);
|
||||||
/// connectivityState.when(
|
/// connectivityState.when(
|
||||||
/// data: (status) => Text('Status: $status'),
|
/// data: (status) => Text('Status: $status'),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, _) => Text('Error: $error'),
|
/// error: (error, _) => Text('Error: $error'),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@@ -81,7 +81,7 @@ const connectivityStreamProvider = ConnectivityStreamProvider._();
|
|||||||
/// final connectivityState = ref.watch(connectivityStreamProvider);
|
/// final connectivityState = ref.watch(connectivityStreamProvider);
|
||||||
/// connectivityState.when(
|
/// connectivityState.when(
|
||||||
/// data: (status) => Text('Status: $status'),
|
/// data: (status) => Text('Status: $status'),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, _) => Text('Error: $error'),
|
/// error: (error, _) => Text('Error: $error'),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@@ -104,7 +104,7 @@ final class ConnectivityStreamProvider
|
|||||||
/// final connectivityState = ref.watch(connectivityStreamProvider);
|
/// final connectivityState = ref.watch(connectivityStreamProvider);
|
||||||
/// connectivityState.when(
|
/// connectivityState.when(
|
||||||
/// data: (status) => Text('Status: $status'),
|
/// data: (status) => Text('Status: $status'),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, _) => Text('Error: $error'),
|
/// error: (error, _) => Text('Error: $error'),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@@ -219,7 +219,7 @@ String _$currentConnectivityHash() =>
|
|||||||
/// final isOnlineAsync = ref.watch(isOnlineProvider);
|
/// final isOnlineAsync = ref.watch(isOnlineProvider);
|
||||||
/// isOnlineAsync.when(
|
/// isOnlineAsync.when(
|
||||||
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
|
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, _) => Text('Error: $error'),
|
/// error: (error, _) => Text('Error: $error'),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@@ -235,7 +235,7 @@ const isOnlineProvider = IsOnlineProvider._();
|
|||||||
/// final isOnlineAsync = ref.watch(isOnlineProvider);
|
/// final isOnlineAsync = ref.watch(isOnlineProvider);
|
||||||
/// isOnlineAsync.when(
|
/// isOnlineAsync.when(
|
||||||
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
|
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, _) => Text('Error: $error'),
|
/// error: (error, _) => Text('Error: $error'),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@@ -251,7 +251,7 @@ final class IsOnlineProvider
|
|||||||
/// final isOnlineAsync = ref.watch(isOnlineProvider);
|
/// final isOnlineAsync = ref.watch(isOnlineProvider);
|
||||||
/// isOnlineAsync.when(
|
/// isOnlineAsync.when(
|
||||||
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
|
/// data: (isOnline) => isOnline ? Text('Online') : Text('Offline'),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, _) => Text('Error: $error'),
|
/// error: (error, _) => Text('Error: $error'),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
|
|||||||
@@ -428,7 +428,7 @@ final version = ref.watch(appVersionProvider);
|
|||||||
final userData = ref.watch(userDataProvider);
|
final userData = ref.watch(userDataProvider);
|
||||||
userData.when(
|
userData.when(
|
||||||
data: (data) => Text(data),
|
data: (data) => Text(data),
|
||||||
loading: () => CircularProgressIndicator(),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => Text('Error: $error'),
|
error: (error, stack) => Text('Error: $error'),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -466,7 +466,7 @@ switch (profileState) {
|
|||||||
case AsyncError(:final error):
|
case AsyncError(:final error):
|
||||||
return Text('Error: $error');
|
return Text('Error: $error');
|
||||||
case AsyncLoading():
|
case AsyncLoading():
|
||||||
return CircularProgressIndicator();
|
return const CustomLoadingIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/domain/entities/address.dart';
|
||||||
import 'package:worker/features/account/presentation/pages/address_form_page.dart';
|
import 'package:worker/features/account/presentation/pages/address_form_page.dart';
|
||||||
@@ -52,6 +53,8 @@ import 'package:worker/features/showrooms/presentation/pages/design_request_deta
|
|||||||
import 'package:worker/features/showrooms/presentation/pages/model_house_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/showrooms/presentation/pages/model_houses_page.dart';
|
||||||
import 'package:worker/features/account/presentation/pages/theme_settings_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
|
/// Router Provider
|
||||||
///
|
///
|
||||||
@@ -62,7 +65,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return GoRouter(
|
return GoRouter(
|
||||||
// Initial route - start with splash screen
|
// Initial route - start with splash screen
|
||||||
initialLocation: RouteNames.splash,
|
initialLocation: RouteNames.splash,
|
||||||
|
observers: [AnalyticsService.observer],
|
||||||
// Redirect based on auth state
|
// Redirect based on auth state
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final isLoading = authState.isLoading;
|
final isLoading = authState.isLoading;
|
||||||
@@ -129,16 +132,22 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.splash,
|
path: RouteNames.splash,
|
||||||
name: RouteNames.splash,
|
name: RouteNames.splash,
|
||||||
pageBuilder: (context, state) =>
|
pageBuilder: (context, state) => MaterialPage(
|
||||||
MaterialPage(key: state.pageKey, child: const SplashPage()),
|
key: state.pageKey,
|
||||||
|
name: RouteNames.splash,
|
||||||
|
child: const SplashPage(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Authentication Routes
|
// Authentication Routes
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.login,
|
path: RouteNames.login,
|
||||||
name: RouteNames.login,
|
name: RouteNames.login,
|
||||||
pageBuilder: (context, state) =>
|
pageBuilder: (context, state) => MaterialPage(
|
||||||
MaterialPage(key: state.pageKey, child: const LoginPage()),
|
key: state.pageKey,
|
||||||
|
name: RouteNames.login,
|
||||||
|
child: const LoginPage(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.forgotPassword,
|
path: RouteNames.forgotPassword,
|
||||||
@@ -190,16 +199,22 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.home,
|
path: RouteNames.home,
|
||||||
name: RouteNames.home,
|
name: RouteNames.home,
|
||||||
pageBuilder: (context, state) =>
|
pageBuilder: (context, state) => MaterialPage(
|
||||||
MaterialPage(key: state.pageKey, child: const MainScaffold()),
|
key: state.pageKey,
|
||||||
|
name: 'home',
|
||||||
|
child: const MainScaffold(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Products Route (full screen, no bottom nav)
|
// Products Route (full screen, no bottom nav)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.products,
|
path: RouteNames.products,
|
||||||
name: RouteNames.products,
|
name: RouteNames.products,
|
||||||
pageBuilder: (context, state) =>
|
pageBuilder: (context, state) => MaterialPage(
|
||||||
MaterialPage(key: state.pageKey, child: const ProductsPage()),
|
key: state.pageKey,
|
||||||
|
name: 'products',
|
||||||
|
child: const ProductsPage(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Product Detail Route
|
// Product Detail Route
|
||||||
@@ -210,6 +225,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
final productId = state.pathParameters['id'];
|
final productId = state.pathParameters['id'];
|
||||||
return MaterialPage(
|
return MaterialPage(
|
||||||
key: state.pageKey,
|
key: state.pageKey,
|
||||||
|
name: 'product_detail',
|
||||||
child: ProductDetailPage(productId: productId ?? ''),
|
child: ProductDetailPage(productId: productId ?? ''),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -222,6 +238,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
final productId = state.pathParameters['id'];
|
final productId = state.pathParameters['id'];
|
||||||
return MaterialPage(
|
return MaterialPage(
|
||||||
key: state.pageKey,
|
key: state.pageKey,
|
||||||
|
name: 'write_review',
|
||||||
child: WriteReviewPage(productId: productId ?? ''),
|
child: WriteReviewPage(productId: productId ?? ''),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -237,6 +254,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
final promotionId = state.pathParameters['id'];
|
final promotionId = state.pathParameters['id'];
|
||||||
return MaterialPage(
|
return MaterialPage(
|
||||||
key: state.pageKey,
|
key: state.pageKey,
|
||||||
|
name: 'promotion_detail',
|
||||||
child: PromotionDetailPage(promotionId: promotionId),
|
child: PromotionDetailPage(promotionId: promotionId),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -246,8 +264,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.cart,
|
path: RouteNames.cart,
|
||||||
name: RouteNames.cart,
|
name: RouteNames.cart,
|
||||||
pageBuilder: (context, state) =>
|
pageBuilder: (context, state) => MaterialPage(
|
||||||
MaterialPage(key: state.pageKey, child: const CartPage()),
|
key: state.pageKey,
|
||||||
|
name: 'cart',
|
||||||
|
child: const CartPage(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Checkout Route
|
// Checkout Route
|
||||||
@@ -350,14 +371,9 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
name: RouteNames.paymentQr,
|
name: RouteNames.paymentQr,
|
||||||
pageBuilder: (context, state) {
|
pageBuilder: (context, state) {
|
||||||
final orderId = state.uri.queryParameters['orderId'] ?? '';
|
final orderId = state.uri.queryParameters['orderId'] ?? '';
|
||||||
final amountStr = state.uri.queryParameters['amount'] ?? '0';
|
|
||||||
final amount = double.tryParse(amountStr) ?? 0.0;
|
|
||||||
return MaterialPage(
|
return MaterialPage(
|
||||||
key: state.pageKey,
|
key: state.pageKey,
|
||||||
child: PaymentQrPage(
|
child: PaymentQrPage(orderId: orderId),
|
||||||
orderId: orderId,
|
|
||||||
amount: amount,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -493,6 +509,27 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
MaterialPage(key: state.pageKey, child: const ThemeSettingsPage()),
|
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
|
// Chat List Route
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.chat,
|
path: RouteNames.chat,
|
||||||
@@ -643,6 +680,10 @@ class RouteNames {
|
|||||||
static const String themeSettings = '$account/theme-settings';
|
static const String themeSettings = '$account/theme-settings';
|
||||||
static const String settings = '$account/settings';
|
static const String settings = '$account/settings';
|
||||||
|
|
||||||
|
// Invoice Routes
|
||||||
|
static const String invoices = '/invoices';
|
||||||
|
static const String invoiceDetail = '$invoices/:id';
|
||||||
|
|
||||||
// Promotions & Notifications Routes
|
// Promotions & Notifications Routes
|
||||||
static const String promotions = '/promotions';
|
static const String promotions = '/promotions';
|
||||||
static const String promotionDetail = '$promotions/:id';
|
static const String promotionDetail = '$promotions/:id';
|
||||||
|
|||||||
362
lib/core/services/analytics_service.dart
Normal file
362
lib/core/services/analytics_service.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// Build cookie header
|
||||||
final storedSession = await getStoredSession();
|
final storedSession = await getStoredSession();
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ class AppTheme {
|
|||||||
/// Light theme configuration
|
/// Light theme configuration
|
||||||
/// [seedColor] - Optional custom seed color, defaults to AppColors.defaultSeedColor
|
/// [seedColor] - Optional custom seed color, defaults to AppColors.defaultSeedColor
|
||||||
static ThemeData lightTheme([Color? seedColor]) {
|
static ThemeData lightTheme([Color? seedColor]) {
|
||||||
|
final seed = seedColor ?? AppColors.defaultSeedColor;
|
||||||
final ColorScheme colorScheme = ColorScheme.fromSeed(
|
final ColorScheme colorScheme = ColorScheme.fromSeed(
|
||||||
seedColor: seedColor ?? AppColors.defaultSeedColor,
|
seedColor: seed,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
);
|
).copyWith(primary: seed);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
@@ -239,10 +240,11 @@ class AppTheme {
|
|||||||
/// Dark theme configuration
|
/// Dark theme configuration
|
||||||
/// [seedColor] - Optional custom seed color, defaults to AppColors.defaultSeedColor
|
/// [seedColor] - Optional custom seed color, defaults to AppColors.defaultSeedColor
|
||||||
static ThemeData darkTheme([Color? seedColor]) {
|
static ThemeData darkTheme([Color? seedColor]) {
|
||||||
|
final seed = seedColor ?? AppColors.defaultSeedColor;
|
||||||
final ColorScheme colorScheme = ColorScheme.fromSeed(
|
final ColorScheme colorScheme = ColorScheme.fromSeed(
|
||||||
seedColor: seedColor ?? AppColors.defaultSeedColor,
|
seedColor: seed,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
);
|
).copyWith(primary: seed);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
|
|
||||||
/// Button variant types for different use cases.
|
/// Button variant types for different use cases.
|
||||||
enum ButtonVariant {
|
enum ButtonVariant {
|
||||||
@@ -106,14 +107,7 @@ class CustomButton extends StatelessWidget {
|
|||||||
/// Builds the button content (text, icon, or loading indicator)
|
/// Builds the button content (text, icon, or loading indicator)
|
||||||
Widget _buildContent() {
|
Widget _buildContent() {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return const SizedBox(
|
return const CustomLoadingIndicator(size: 20, color: Colors.white);
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (icon != null) {
|
if (icon != null) {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:loading_animation_widget/loading_animation_widget.dart';
|
||||||
|
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
|
|
||||||
/// Custom loading indicator widget with optional message text.
|
/// 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.
|
/// message below it. Used for loading states throughout the app.
|
||||||
///
|
///
|
||||||
/// Example usage:
|
/// Example usage:
|
||||||
@@ -32,19 +33,14 @@ class CustomLoadingIndicator extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
LoadingAnimationWidget.threeRotatingDots(
|
||||||
width: size,
|
color: color ?? colorScheme.primary,
|
||||||
height: size,
|
size: size,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 3,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
color ?? AppColors.primaryBlue,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (message != null) ...[
|
if (message != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.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/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/database/hive_initializer.dart';
|
import 'package:worker/core/database/hive_initializer.dart';
|
||||||
import 'package:worker/core/database/models/enums.dart';
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
@@ -36,8 +37,10 @@ class AccountPage extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
@@ -48,7 +51,7 @@ class AccountPage extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Simple Header
|
// Simple Header
|
||||||
_buildHeader(),
|
_buildHeader(context),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// User Profile Card - only this depends on provider
|
// User Profile Card - only this depends on provider
|
||||||
@@ -76,26 +79,28 @@ class AccountPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build simple header with title
|
/// Build simple header with title
|
||||||
Widget _buildHeader() {
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Tài khoản',
|
'Tài khoản',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -103,14 +108,16 @@ class AccountPage extends ConsumerWidget {
|
|||||||
|
|
||||||
/// Build account menu section
|
/// Build account menu section
|
||||||
Widget _buildAccountMenu(BuildContext context) {
|
Widget _buildAccountMenu(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -134,6 +141,14 @@ class AccountPage extends ConsumerWidget {
|
|||||||
context.push(RouteNames.orders);
|
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(
|
AccountMenuItem(
|
||||||
icon: FontAwesomeIcons.locationDot,
|
icon: FontAwesomeIcons.locationDot,
|
||||||
title: 'Địa chỉ đã lưu',
|
title: 'Địa chỉ đã lưu',
|
||||||
@@ -158,14 +173,14 @@ class AccountPage extends ConsumerWidget {
|
|||||||
context.push(RouteNames.changePassword);
|
context.push(RouteNames.changePassword);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
AccountMenuItem(
|
// AccountMenuItem(
|
||||||
icon: FontAwesomeIcons.language,
|
// icon: FontAwesomeIcons.language,
|
||||||
title: 'Ngôn ngữ',
|
// title: 'Ngôn ngữ',
|
||||||
subtitle: 'Tiếng Việt',
|
// subtitle: 'Tiếng Việt',
|
||||||
onTap: () {
|
// onTap: () {
|
||||||
_showComingSoon(context);
|
// _showComingSoon(context);
|
||||||
},
|
// },
|
||||||
),
|
// ),
|
||||||
AccountMenuItem(
|
AccountMenuItem(
|
||||||
icon: FontAwesomeIcons.palette,
|
icon: FontAwesomeIcons.palette,
|
||||||
title: 'Giao diện',
|
title: 'Giao diện',
|
||||||
@@ -181,14 +196,16 @@ class AccountPage extends ConsumerWidget {
|
|||||||
|
|
||||||
/// Build support section
|
/// Build support section
|
||||||
Widget _buildSupportSection(BuildContext context) {
|
Widget _buildSupportSection(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -198,8 +215,8 @@ class AccountPage extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Section title
|
// Section title
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
AppSpacing.md,
|
AppSpacing.md,
|
||||||
AppSpacing.md,
|
AppSpacing.md,
|
||||||
AppSpacing.md,
|
AppSpacing.md,
|
||||||
@@ -210,7 +227,7 @@ class AccountPage extends ConsumerWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -220,10 +237,10 @@ class AccountPage extends ConsumerWidget {
|
|||||||
icon: FontAwesomeIcons.headset,
|
icon: FontAwesomeIcons.headset,
|
||||||
title: 'Liên hệ hỗ trợ',
|
title: 'Liên hệ hỗ trợ',
|
||||||
subtitle: 'Hotline: 1900 1234',
|
subtitle: 'Hotline: 1900 1234',
|
||||||
trailing: const FaIcon(
|
trailing: FaIcon(
|
||||||
FontAwesomeIcons.phone,
|
FontAwesomeIcons.phone,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -283,7 +300,7 @@ class AccountPage extends ConsumerWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
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.',
|
'Ứ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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -307,27 +324,28 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final userInfoAsync = ref.watch(userInfoProvider);
|
final userInfoAsync = ref.watch(userInfoProvider);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
child: userInfoAsync.when(
|
child: userInfoAsync.when(
|
||||||
loading: () => _buildLoadingCard(),
|
loading: () => _buildLoadingCard(colorScheme),
|
||||||
error: (error, stack) => _buildErrorCard(context, ref, error),
|
error: (error, stack) => _buildErrorCard(context, ref, error, colorScheme),
|
||||||
data: (userInfo) => _buildProfileCard(context, userInfo),
|
data: (userInfo) => _buildProfileCard(context, userInfo, colorScheme),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLoadingCard() {
|
Widget _buildLoadingCard(ColorScheme colorScheme) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -339,16 +357,11 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: AppColors.primaryBlue,
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: const CustomLoadingIndicator(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -359,7 +372,7 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
height: 20,
|
height: 20,
|
||||||
width: 150,
|
width: 150,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -368,7 +381,7 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
height: 14,
|
height: 14,
|
||||||
width: 100,
|
width: 100,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -380,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(
|
return Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -399,9 +412,9 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
@@ -416,22 +429,22 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Không thể tải thông tin',
|
'Không thể tải thông tin',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => ref.read(userInfoProvider.notifier).refresh(),
|
onTap: () => ref.read(userInfoProvider.notifier).refresh(),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Nhấn để thử lại',
|
'Nhấn để thử lại',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -444,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(
|
return Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -471,37 +484,26 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: LinearGradient(
|
color: colorScheme.primaryContainer,
|
||||||
colors: [Color(0xFF005B9A), Color(0xFF38B6FF)],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: CustomLoadingIndicator(
|
||||||
child: CircularProgressIndicator(
|
color: colorScheme.onPrimaryContainer,
|
||||||
color: Colors.white,
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: LinearGradient(
|
color: colorScheme.primaryContainer,
|
||||||
colors: [Color(0xFF005B9A), Color(0xFF38B6FF)],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
userInfo.initials,
|
userInfo.initials,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onPrimaryContainer,
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
@@ -513,19 +515,15 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
: Container(
|
: Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: LinearGradient(
|
color: colorScheme.primaryContainer,
|
||||||
colors: [Color(0xFF005B9A), Color(0xFF38B6FF)],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
userInfo.initials,
|
userInfo.initials,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onPrimaryContainer,
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
@@ -541,27 +539,27 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
userInfo.fullName,
|
userInfo.fullName,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xs),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
Text(
|
Text(
|
||||||
'${_getRoleDisplayName(userInfo.role)} · Hạng ${userInfo.tierDisplayName}',
|
'${_getRoleDisplayName(userInfo.role)} · Hạng ${userInfo.tierDisplayName}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (userInfo.phoneNumber != null) ...[
|
if (userInfo.phoneNumber != null) ...[
|
||||||
const SizedBox(height: AppSpacing.xs),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
Text(
|
Text(
|
||||||
userInfo.phoneNumber!,
|
userInfo.phoneNumber!,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -594,12 +592,14 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
class _LogoutButton extends ConsumerWidget {
|
class _LogoutButton extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_showLogoutConfirmation(context, ref);
|
_showLogoutConfirmation(context, ref, colorScheme);
|
||||||
},
|
},
|
||||||
icon: const FaIcon(FontAwesomeIcons.arrowRightFromBracket, size: 18),
|
icon: const FaIcon(FontAwesomeIcons.arrowRightFromBracket, size: 18),
|
||||||
label: const Text('Đăng xuất'),
|
label: const Text('Đăng xuất'),
|
||||||
@@ -616,12 +616,19 @@ class _LogoutButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Show logout confirmation dialog
|
/// Show logout confirmation dialog
|
||||||
void _showLogoutConfirmation(BuildContext context, WidgetRef ref) {
|
void _showLogoutConfirmation(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Đăng xuất'),
|
backgroundColor: colorScheme.surface,
|
||||||
content: const Text('Bạn có chắc chắn muốn đăng xuất?'),
|
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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
@@ -662,7 +669,7 @@ class _LogoutButton extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(),
|
CustomLoadingIndicator(),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text('Đang đăng xuất...'),
|
Text('Đang đăng xuất...'),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -32,6 +33,8 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Form key for validation
|
// Form key for validation
|
||||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||||
|
|
||||||
@@ -89,32 +92,32 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: AppBarSpecs.elevation,
|
elevation: AppBarSpecs.elevation,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(
|
icon: FaIcon(
|
||||||
FontAwesomeIcons.arrowLeft,
|
FontAwesomeIcons.arrowLeft,
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
address == null ? 'Thêm địa chỉ mới' : 'Chỉnh sửa địa chỉ',
|
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,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const FaIcon(
|
icon: FaIcon(
|
||||||
FontAwesomeIcons.circleInfo,
|
FontAwesomeIcons.circleInfo,
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () => _showInfoDialog(context),
|
onPressed: () => _showInfoDialog(context, colorScheme),
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
],
|
],
|
||||||
@@ -136,10 +139,12 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Contact Information Section
|
// Contact Information Section
|
||||||
_buildSection(
|
_buildSection(
|
||||||
|
colorScheme: colorScheme,
|
||||||
icon: FontAwesomeIcons.user,
|
icon: FontAwesomeIcons.user,
|
||||||
title: 'Thông tin liên hệ',
|
title: 'Thông tin liên hệ',
|
||||||
children: [
|
children: [
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
label: 'Họ và tên',
|
label: 'Họ và tên',
|
||||||
icon: FontAwesomeIcons.user,
|
icon: FontAwesomeIcons.user,
|
||||||
@@ -154,6 +159,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
controller: phoneController,
|
controller: phoneController,
|
||||||
label: 'Số điện thoại',
|
label: 'Số điện thoại',
|
||||||
icon: FontAwesomeIcons.phone,
|
icon: FontAwesomeIcons.phone,
|
||||||
@@ -173,6 +179,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
controller: emailController,
|
controller: emailController,
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
icon: FontAwesomeIcons.envelope,
|
icon: FontAwesomeIcons.envelope,
|
||||||
@@ -190,6 +197,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
controller: taxIdController,
|
controller: taxIdController,
|
||||||
label: 'Mã số thuế',
|
label: 'Mã số thuế',
|
||||||
icon: FontAwesomeIcons.fileInvoice,
|
icon: FontAwesomeIcons.fileInvoice,
|
||||||
@@ -203,10 +211,12 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Address Information Section
|
// Address Information Section
|
||||||
_buildSection(
|
_buildSection(
|
||||||
|
colorScheme: colorScheme,
|
||||||
icon: FontAwesomeIcons.locationDot,
|
icon: FontAwesomeIcons.locationDot,
|
||||||
title: 'Địa chỉ giao hàng',
|
title: 'Địa chỉ giao hàng',
|
||||||
children: [
|
children: [
|
||||||
_buildDropdownWithLoading(
|
_buildDropdownWithLoading(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Tỉnh/Thành phố',
|
label: 'Tỉnh/Thành phố',
|
||||||
value: selectedCityCode.value,
|
value: selectedCityCode.value,
|
||||||
items: citiesMap,
|
items: citiesMap,
|
||||||
@@ -226,6 +236,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
_buildDropdownWithLoading(
|
_buildDropdownWithLoading(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Quận/Huyện',
|
label: 'Quận/Huyện',
|
||||||
value: selectedWardCode.value,
|
value: selectedWardCode.value,
|
||||||
items: wardsMap,
|
items: wardsMap,
|
||||||
@@ -246,6 +257,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
if (citiesAsync.hasError) ...[
|
if (citiesAsync.hasError) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildErrorBanner(
|
_buildErrorBanner(
|
||||||
|
colorScheme,
|
||||||
'Không thể tải danh sách tỉnh/thành phố. Vui lòng thử lại.',
|
'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) ...[
|
if (wardsAsync.hasError && selectedCityCode.value != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildErrorBanner(
|
_buildErrorBanner(
|
||||||
|
colorScheme,
|
||||||
'Không thể tải danh sách quận/huyện. Vui lòng thử lại.',
|
'Không thể tải danh sách quận/huyện. Vui lòng thử lại.',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
_buildTextArea(
|
_buildTextArea(
|
||||||
|
colorScheme: colorScheme,
|
||||||
controller: addressDetailController,
|
controller: addressDetailController,
|
||||||
label: 'Địa chỉ cụ thể',
|
label: 'Địa chỉ cụ thể',
|
||||||
placeholder: 'Số nhà, tên đường, khu vực...',
|
placeholder: 'Số nhà, tên đường, khu vực...',
|
||||||
@@ -279,11 +293,11 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -303,31 +317,31 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
value: isDefault.value,
|
value: isDefault.value,
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
isDefault.value = value ?? false,
|
isDefault.value = value ?? false,
|
||||||
activeColor: AppColors.primaryBlue,
|
activeColor: colorScheme.primary,
|
||||||
materialTapTargetSize:
|
materialTapTargetSize:
|
||||||
MaterialTapTargetSize.shrinkWrap,
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Text(
|
Text(
|
||||||
'Đặt làm địa chỉ mặc định',
|
'Đặt làm địa chỉ mặc định',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(left: 32),
|
padding: const EdgeInsets.only(left: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Địa chỉ này sẽ được sử dụng làm mặc định khi đặt hàng',
|
'Địa chỉ này sẽ được sử dụng làm mặc định khi đặt hàng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -401,15 +415,15 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
vertical: AppSpacing.md,
|
vertical: AppSpacing.md,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
border: Border(
|
border: Border(
|
||||||
top: BorderSide(
|
top: BorderSide(
|
||||||
color: Colors.grey.withValues(alpha: 0.15),
|
color: colorScheme.outlineVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.08),
|
color: colorScheme.shadow.withValues(alpha: 0.08),
|
||||||
blurRadius: 12,
|
blurRadius: 12,
|
||||||
offset: const Offset(0, -4),
|
offset: const Offset(0, -4),
|
||||||
),
|
),
|
||||||
@@ -437,13 +451,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
isSaving,
|
isSaving,
|
||||||
),
|
),
|
||||||
icon: isSaving.value
|
icon: isSaving.value
|
||||||
? const SizedBox(
|
? CustomLoadingIndicator(
|
||||||
width: 18,
|
color: colorScheme.onPrimary,
|
||||||
height: 18,
|
size: 18,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 18),
|
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 18),
|
||||||
label: Text(
|
label: Text(
|
||||||
@@ -454,9 +464,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
disabledBackgroundColor: AppColors.grey500,
|
disabledBackgroundColor: colorScheme.onSurfaceVariant,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -475,6 +485,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
/// Build a section with icon and title
|
/// Build a section with icon and title
|
||||||
Widget _buildSection({
|
Widget _buildSection({
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String title,
|
required String title,
|
||||||
required List<Widget> children,
|
required List<Widget> children,
|
||||||
@@ -482,11 +493,11 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -500,15 +511,15 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
FaIcon(
|
FaIcon(
|
||||||
icon,
|
icon,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
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
|
/// Build a text field with label and icon
|
||||||
Widget _buildTextField({
|
Widget _buildTextField({
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required TextEditingController controller,
|
required TextEditingController controller,
|
||||||
required String label,
|
required String label,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
@@ -538,10 +550,10 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isRequired)
|
if (isRequired)
|
||||||
@@ -561,13 +573,13 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
validator: validator,
|
validator: validator,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: placeholder,
|
hintText: placeholder,
|
||||||
hintStyle: const TextStyle(color: AppColors.grey500),
|
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
prefixIcon: Padding(
|
prefixIcon: Padding(
|
||||||
padding: const EdgeInsets.only(left: 16, right: 12),
|
padding: const EdgeInsets.only(left: 16, right: 12),
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
icon,
|
icon,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
prefixIconConstraints: const BoxConstraints(
|
prefixIconConstraints: const BoxConstraints(
|
||||||
@@ -575,19 +587,19 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: colorScheme.surface,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -612,9 +624,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
helperText,
|
helperText,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -624,6 +636,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
/// Build a dropdown field
|
/// Build a dropdown field
|
||||||
Widget _buildDropdown({
|
Widget _buildDropdown({
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required String label,
|
required String label,
|
||||||
required String? value,
|
required String? value,
|
||||||
required Map<String, String> items,
|
required Map<String, String> items,
|
||||||
@@ -639,10 +652,10 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isRequired)
|
if (isRequired)
|
||||||
@@ -662,23 +675,23 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
validator: validator,
|
validator: validator,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: enabled ? Colors.white : const Color(0xFFF3F4F6),
|
fillColor: enabled ? colorScheme.surface : colorScheme.surfaceContainerHighest,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
disabledBorder: OutlineInputBorder(
|
disabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -700,7 +713,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
hint: Text(
|
hint: Text(
|
||||||
'-- Chọn $label --',
|
'-- Chọn $label --',
|
||||||
style: const TextStyle(color: AppColors.grey500),
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
items: enabled
|
items: enabled
|
||||||
? () {
|
? () {
|
||||||
@@ -709,9 +722,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
value: entry.key,
|
value: entry.key,
|
||||||
child: Text(
|
child: Text(
|
||||||
entry.value,
|
entry.value,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -725,9 +738,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
value: value,
|
value: value,
|
||||||
child: Text(
|
child: Text(
|
||||||
'$value (đã lưu)',
|
'$value (đã lưu)',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -742,7 +755,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
icon: FaIcon(
|
icon: FaIcon(
|
||||||
FontAwesomeIcons.chevronDown,
|
FontAwesomeIcons.chevronDown,
|
||||||
size: 14,
|
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
|
/// Build a dropdown field with loading indicator
|
||||||
Widget _buildDropdownWithLoading({
|
Widget _buildDropdownWithLoading({
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required String label,
|
required String label,
|
||||||
required String? value,
|
required String? value,
|
||||||
required Map<String, String> items,
|
required Map<String, String> items,
|
||||||
@@ -767,10 +781,10 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isRequired)
|
if (isRequired)
|
||||||
@@ -783,13 +797,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (isLoading) ...[
|
if (isLoading) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(
|
CustomLoadingIndicator(
|
||||||
width: 12,
|
color: colorScheme.primary,
|
||||||
height: 12,
|
size: 12,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: AppColors.primaryBlue,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -801,23 +811,23 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
validator: validator,
|
validator: validator,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: enabled && !isLoading ? Colors.white : const Color(0xFFF3F4F6),
|
fillColor: enabled && !isLoading ? colorScheme.surface : colorScheme.surfaceContainerHighest,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
disabledBorder: OutlineInputBorder(
|
disabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -837,15 +847,11 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
vertical: 14,
|
vertical: 14,
|
||||||
),
|
),
|
||||||
suffixIcon: isLoading
|
suffixIcon: isLoading
|
||||||
? const Padding(
|
? Padding(
|
||||||
padding: EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: SizedBox(
|
child: CustomLoadingIndicator(
|
||||||
width: 20,
|
color: colorScheme.primary,
|
||||||
height: 20,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: AppColors.primaryBlue,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -857,7 +863,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
? 'Vui lòng chọn Tỉnh/Thành phố trước'
|
? 'Vui lòng chọn Tỉnh/Thành phố trước'
|
||||||
: '-- Chọn $label --',
|
: '-- Chọn $label --',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontStyle: !enabled || isLoading ? FontStyle.italic : FontStyle.normal,
|
fontStyle: !enabled || isLoading ? FontStyle.italic : FontStyle.normal,
|
||||||
),
|
),
|
||||||
@@ -869,9 +875,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
value: entry.key,
|
value: entry.key,
|
||||||
child: Text(
|
child: Text(
|
||||||
entry.value,
|
entry.value,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -885,9 +891,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
value: value,
|
value: value,
|
||||||
child: Text(
|
child: Text(
|
||||||
'$value (đã lưu)',
|
'$value (đã lưu)',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -903,8 +909,8 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
FontAwesomeIcons.chevronDown,
|
FontAwesomeIcons.chevronDown,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: enabled && !isLoading
|
color: enabled && !isLoading
|
||||||
? AppColors.grey500
|
? colorScheme.onSurfaceVariant
|
||||||
: AppColors.grey500.withValues(alpha: 0.5),
|
: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -913,6 +919,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
/// Build a text area field
|
/// Build a text area field
|
||||||
Widget _buildTextArea({
|
Widget _buildTextArea({
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required TextEditingController controller,
|
required TextEditingController controller,
|
||||||
required String label,
|
required String label,
|
||||||
required String placeholder,
|
required String placeholder,
|
||||||
@@ -927,10 +934,10 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isRequired)
|
if (isRequired)
|
||||||
@@ -950,21 +957,21 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
validator: validator,
|
validator: validator,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: placeholder,
|
hintText: placeholder,
|
||||||
hintStyle: const TextStyle(color: AppColors.grey500),
|
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: colorScheme.surface,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -986,9 +993,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
helperText,
|
helperText,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -997,7 +1004,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build error banner for API failures
|
/// Build error banner for API failures
|
||||||
Widget _buildErrorBanner(String message) {
|
Widget _buildErrorBanner(ColorScheme colorScheme, String message) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1032,7 +1039,7 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Show info dialog
|
/// Show info dialog
|
||||||
void _showInfoDialog(BuildContext context) {
|
void _showInfoDialog(BuildContext context, ColorScheme colorScheme) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
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!'),
|
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),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -42,30 +43,32 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
// Watch addresses from API
|
// Watch addresses from API
|
||||||
final addressesAsync = ref.watch(addressesProvider);
|
final addressesAsync = ref.watch(addressesProvider);
|
||||||
|
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: AppBarSpecs.elevation,
|
elevation: AppBarSpecs.elevation,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(
|
icon: FaIcon(
|
||||||
FontAwesomeIcons.arrowLeft,
|
FontAwesomeIcons.arrowLeft,
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
selectMode ? 'Chọn địa chỉ' : 'Địa chỉ của bạn',
|
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,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const FaIcon(
|
icon: FaIcon(
|
||||||
FontAwesomeIcons.circleInfo,
|
FontAwesomeIcons.circleInfo,
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -85,7 +88,7 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
await ref.read(addressesProvider.notifier).refresh();
|
await ref.read(addressesProvider.notifier).refresh();
|
||||||
},
|
},
|
||||||
child: addresses.isEmpty
|
child: addresses.isEmpty
|
||||||
? _buildEmptyState(context)
|
? _buildEmptyState(context, colorScheme)
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
itemCount: addresses.length,
|
itemCount: addresses.length,
|
||||||
@@ -168,9 +171,9 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: AppColors.primaryBlue,
|
foregroundColor: colorScheme.primary,
|
||||||
side: const BorderSide(
|
side: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
@@ -205,10 +208,10 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
disabledBackgroundColor: AppColors.grey100,
|
disabledBackgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
disabledForegroundColor: AppColors.grey500,
|
disabledForegroundColor: colorScheme.onSurfaceVariant,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -236,8 +239,8 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -251,7 +254,7 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => Center(
|
error: (error, stack) => Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -262,18 +265,18 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
color: AppColors.danger,
|
color: AppColors.danger,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Không thể tải danh sách địa chỉ',
|
'Không thể tải danh sách địa chỉ',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
error.toString(),
|
error.toString(),
|
||||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -284,8 +287,8 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
icon: const FaIcon(FontAwesomeIcons.arrowsRotate, size: 18),
|
icon: const FaIcon(FontAwesomeIcons.arrowsRotate, size: 18),
|
||||||
label: const Text('Thử lại'),
|
label: const Text('Thử lại'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -296,7 +299,7 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build empty state
|
/// Build empty state
|
||||||
Widget _buildEmptyState(BuildContext context) {
|
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -304,15 +307,15 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.locationDot,
|
FontAwesomeIcons.locationDot,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: AppColors.grey500.withValues(alpha: 0.4),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Chưa có địa chỉ nào',
|
'Chưa có địa chỉ nào',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -320,7 +323,7 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
'Thêm địa chỉ để nhận hàng nhanh hơn',
|
'Thêm địa chỉ để nhận hàng nhanh hơn',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey500.withValues(alpha: 0.8),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -334,8 +337,8 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -357,20 +360,20 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
ref.read(addressesProvider.notifier).setDefaultAddress(address.name);
|
ref.read(addressesProvider.notifier).setDefaultAddress(address.name);
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
children: [
|
children: [
|
||||||
const FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.circleCheck,
|
FontAwesomeIcons.circleCheck,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
SizedBox(width: 12),
|
||||||
const Text('Đã đặt làm địa chỉ mặc định'),
|
Text('Đã đặt làm địa chỉ mặc định'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
backgroundColor: const Color(0xFF10B981),
|
backgroundColor: AppColors.success,
|
||||||
duration: const Duration(seconds: 2),
|
duration: Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -460,7 +463,7 @@ class AddressesPage extends HookConsumerWidget {
|
|||||||
Text('Đã xóa địa chỉ'),
|
Text('Đã xóa địa chỉ'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
backgroundColor: Color(0xFF10B981),
|
backgroundColor: AppColors.success,
|
||||||
duration: Duration(seconds: 2),
|
duration: Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,19 +63,21 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Thay đổi mật khẩu',
|
'Thay đổi mật khẩu',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -95,11 +97,11 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -109,12 +111,12 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Title
|
// Title
|
||||||
const Text(
|
Text(
|
||||||
'Cập nhật mật khẩu',
|
'Cập nhật mật khẩu',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Current Password
|
// Current Password
|
||||||
_buildPasswordField(
|
_buildPasswordField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Mật khẩu hiện tại',
|
label: 'Mật khẩu hiện tại',
|
||||||
controller: currentPasswordController,
|
controller: currentPasswordController,
|
||||||
isVisible: currentPasswordVisible,
|
isVisible: currentPasswordVisible,
|
||||||
@@ -138,6 +141,7 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// New Password
|
// New Password
|
||||||
_buildPasswordField(
|
_buildPasswordField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Mật khẩu mới',
|
label: 'Mật khẩu mới',
|
||||||
controller: newPasswordController,
|
controller: newPasswordController,
|
||||||
isVisible: newPasswordVisible,
|
isVisible: newPasswordVisible,
|
||||||
@@ -158,6 +162,7 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Confirm Password
|
// Confirm Password
|
||||||
_buildPasswordField(
|
_buildPasswordField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Nhập lại mật khẩu mới',
|
label: 'Nhập lại mật khẩu mới',
|
||||||
controller: confirmPasswordController,
|
controller: confirmPasswordController,
|
||||||
isVisible: confirmPasswordVisible,
|
isVisible: confirmPasswordVisible,
|
||||||
@@ -206,7 +211,7 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
// Security Tips
|
// Security Tips
|
||||||
_buildSecurityTips(),
|
_buildSecurityTips(colorScheme),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -216,6 +221,7 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
// Action Buttons
|
// Action Buttons
|
||||||
_buildActionButtons(
|
_buildActionButtons(
|
||||||
context: context,
|
context: context,
|
||||||
|
colorScheme: colorScheme,
|
||||||
formKey: formKey,
|
formKey: formKey,
|
||||||
currentPasswordController: currentPasswordController,
|
currentPasswordController: currentPasswordController,
|
||||||
newPasswordController: newPasswordController,
|
newPasswordController: newPasswordController,
|
||||||
@@ -232,6 +238,7 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
/// Build password field with show/hide toggle
|
/// Build password field with show/hide toggle
|
||||||
Widget _buildPasswordField({
|
Widget _buildPasswordField({
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required String label,
|
required String label,
|
||||||
required TextEditingController controller,
|
required TextEditingController controller,
|
||||||
required ValueNotifier<bool> isVisible,
|
required ValueNotifier<bool> isVisible,
|
||||||
@@ -245,10 +252,10 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
RichText(
|
RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
text: label,
|
text: label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
if (required)
|
if (required)
|
||||||
@@ -267,11 +274,11 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Nhập $label',
|
hintText: 'Nhập $label',
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(
|
||||||
color: AppColors.grey500.withValues(alpha: 0.6),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFFF8FAFC),
|
fillColor: colorScheme.surfaceContainerLowest,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
@@ -280,7 +287,7 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
icon: FaIcon(
|
icon: FaIcon(
|
||||||
isVisible.value ? FontAwesomeIcons.eyeSlash : FontAwesomeIcons.eye,
|
isVisible.value ? FontAwesomeIcons.eyeSlash : FontAwesomeIcons.eye,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
isVisible.value = !isVisible.value;
|
isVisible.value = !isVisible.value;
|
||||||
@@ -288,16 +295,16 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -315,7 +322,7 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
helpText,
|
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
|
/// Build security tips section
|
||||||
Widget _buildSecurityTips() {
|
Widget _buildSecurityTips(ColorScheme colorScheme) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: colorScheme.surfaceContainerLowest,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Gợi ý bảo mật:',
|
'Gợi ý bảo mật:',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildSecurityTip('Sử dụng ít nhất 8 ký tự'),
|
_buildSecurityTip('Sử dụng ít nhất 8 ký tự', colorScheme),
|
||||||
_buildSecurityTip('Kết hợp chữ hoa, chữ thường và số'),
|
_buildSecurityTip('Kết hợp chữ hoa, chữ thường và số', colorScheme),
|
||||||
_buildSecurityTip('Bao gồm ký tự đặc biệt (!@#\$%^&*)'),
|
_buildSecurityTip('Bao gồm ký tự đặc biệt (!@#\$%^&*)', colorScheme),
|
||||||
_buildSecurityTip('Không sử dụng thông tin cá nhân'),
|
_buildSecurityTip('Không sử dụng thông tin cá nhân', colorScheme),
|
||||||
_buildSecurityTip('Thường xuyên thay đổi mật khẩu'),
|
_buildSecurityTip('Thường xuyên thay đổi mật khẩu', colorScheme),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build individual security tip
|
/// Build individual security tip
|
||||||
Widget _buildSecurityTip(String text) {
|
Widget _buildSecurityTip(String text, ColorScheme colorScheme) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -369,9 +376,9 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Color(0xFF475569),
|
color: colorScheme.onSurfaceVariant,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -384,6 +391,7 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
/// Build action buttons
|
/// Build action buttons
|
||||||
Widget _buildActionButtons({
|
Widget _buildActionButtons({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required GlobalKey<FormState> formKey,
|
required GlobalKey<FormState> formKey,
|
||||||
required TextEditingController currentPasswordController,
|
required TextEditingController currentPasswordController,
|
||||||
required TextEditingController newPasswordController,
|
required TextEditingController newPasswordController,
|
||||||
@@ -401,17 +409,17 @@ class ChangePasswordPage extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
side: const BorderSide(color: AppColors.grey100),
|
side: BorderSide(color: colorScheme.outlineVariant),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Hủy bỏ',
|
'Hủy bỏ',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
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: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -31,6 +32,8 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Watch user info from API
|
// Watch user info from API
|
||||||
final userInfoAsync = ref.watch(userInfoProvider);
|
final userInfoAsync = ref.watch(userInfoProvider);
|
||||||
|
|
||||||
@@ -45,50 +48,37 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
return userInfoAsync.when(
|
return userInfoAsync.when(
|
||||||
loading: () => Scaffold(
|
loading: () => Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Thông tin cá nhân',
|
'Thông tin cá nhân',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
),
|
),
|
||||||
body: const Center(
|
body: const CustomLoadingIndicator(
|
||||||
child: Column(
|
message: 'Đang tải thông tin...',
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (error, stack) => Scaffold(
|
error: (error, stack) => Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Thông tin cá nhân',
|
'Thông tin cá nhân',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -105,11 +95,12 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
color: AppColors.danger,
|
color: AppColors.danger,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
const Text(
|
Text(
|
||||||
'Không thể tải thông tin người dùng',
|
'Không thể tải thông tin người dùng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
@@ -118,8 +109,8 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
icon: const FaIcon(FontAwesomeIcons.arrowsRotate, size: 16),
|
icon: const FaIcon(FontAwesomeIcons.arrowsRotate, size: 16),
|
||||||
label: const Text('Thử lại'),
|
label: const Text('Thử lại'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: AppColors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -183,12 +174,12 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
if (hasChanges.value) {
|
if (hasChanges.value) {
|
||||||
final shouldPop = await _showUnsavedChangesDialog(context);
|
final shouldPop = await _showUnsavedChangesDialog(context);
|
||||||
@@ -200,10 +191,10 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Thông tin cá nhân',
|
'Thông tin cá nhân',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -224,6 +215,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
// Profile Avatar Section with Name and Status
|
// Profile Avatar Section with Name and Status
|
||||||
_buildAvatarAndStatusSection(
|
_buildAvatarAndStatusSection(
|
||||||
context,
|
context,
|
||||||
|
colorScheme,
|
||||||
selectedImage,
|
selectedImage,
|
||||||
userInfo.initials,
|
userInfo.initials,
|
||||||
userInfo.avatarUrl,
|
userInfo.avatarUrl,
|
||||||
@@ -240,11 +232,11 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -253,13 +245,13 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
child: TabBar(
|
child: TabBar(
|
||||||
controller: tabController,
|
controller: tabController,
|
||||||
indicator: BoxDecoration(
|
indicator: BoxDecoration(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
),
|
),
|
||||||
indicatorSize: TabBarIndicatorSize.tab,
|
indicatorSize: TabBarIndicatorSize.tab,
|
||||||
dividerColor: Colors.transparent,
|
dividerColor: Colors.transparent,
|
||||||
labelColor: Colors.white,
|
labelColor: colorScheme.onPrimary,
|
||||||
unselectedLabelColor: AppColors.grey500,
|
unselectedLabelColor: colorScheme.onSurfaceVariant,
|
||||||
labelStyle: const TextStyle(
|
labelStyle: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -278,6 +270,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
// Tab 1: Personal Information (always show if no tabs, or when selected)
|
// Tab 1: Personal Information (always show if no tabs, or when selected)
|
||||||
_buildPersonalInformationTab(
|
_buildPersonalInformationTab(
|
||||||
ref: ref,
|
ref: ref,
|
||||||
|
colorScheme: colorScheme,
|
||||||
nameController: nameController,
|
nameController: nameController,
|
||||||
phoneController: phoneController,
|
phoneController: phoneController,
|
||||||
emailController: emailController,
|
emailController: emailController,
|
||||||
@@ -298,6 +291,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
_buildVerificationTab(
|
_buildVerificationTab(
|
||||||
ref: ref,
|
ref: ref,
|
||||||
context: context,
|
context: context,
|
||||||
|
colorScheme: colorScheme,
|
||||||
idCardFrontImage: idCardFrontImage,
|
idCardFrontImage: idCardFrontImage,
|
||||||
idCardBackImage: idCardBackImage,
|
idCardBackImage: idCardBackImage,
|
||||||
certificateImages: certificateImages,
|
certificateImages: certificateImages,
|
||||||
@@ -329,6 +323,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
/// Build Personal Information Tab
|
/// Build Personal Information Tab
|
||||||
Widget _buildPersonalInformationTab({
|
Widget _buildPersonalInformationTab({
|
||||||
required WidgetRef ref,
|
required WidgetRef ref,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required TextEditingController nameController,
|
required TextEditingController nameController,
|
||||||
required TextEditingController phoneController,
|
required TextEditingController phoneController,
|
||||||
required TextEditingController emailController,
|
required TextEditingController emailController,
|
||||||
@@ -351,11 +346,11 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -365,20 +360,20 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Section Header
|
// Section Header
|
||||||
const Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.circleUser,
|
FontAwesomeIcons.circleUser,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
'Thông tin cá nhân',
|
'Thông tin cá nhân',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -388,6 +383,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Full Name
|
// Full Name
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Họ và tên',
|
label: 'Họ và tên',
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -403,6 +399,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Phone (Read-only)
|
// Phone (Read-only)
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Số điện thoại',
|
label: 'Số điện thoại',
|
||||||
controller: phoneController,
|
controller: phoneController,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
@@ -412,6 +409,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Email (Read-only)
|
// Email (Read-only)
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
controller: emailController,
|
controller: emailController,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
@@ -422,6 +420,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
// Birth Date
|
// Birth Date
|
||||||
_buildDateField(
|
_buildDateField(
|
||||||
context: context,
|
context: context,
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Ngày sinh',
|
label: 'Ngày sinh',
|
||||||
controller: birthDateController,
|
controller: birthDateController,
|
||||||
hasChanges: hasChanges,
|
hasChanges: hasChanges,
|
||||||
@@ -431,6 +430,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Gender
|
// Gender
|
||||||
_buildDropdownField(
|
_buildDropdownField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Giới tính',
|
label: 'Giới tính',
|
||||||
value: selectedGender.value,
|
value: selectedGender.value,
|
||||||
items: const [
|
items: const [
|
||||||
@@ -450,6 +450,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Company Name
|
// Company Name
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Tên công ty/Cửa hàng',
|
label: 'Tên công ty/Cửa hàng',
|
||||||
controller: companyController,
|
controller: companyController,
|
||||||
),
|
),
|
||||||
@@ -458,6 +459,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Tax ID
|
// Tax ID
|
||||||
_buildTextField(
|
_buildTextField(
|
||||||
|
colorScheme: colorScheme,
|
||||||
label: 'Mã số thuế',
|
label: 'Mã số thuế',
|
||||||
controller: taxIdController,
|
controller: taxIdController,
|
||||||
),
|
),
|
||||||
@@ -472,15 +474,15 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEFF6FF),
|
color: colorScheme.primaryContainer,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
border: Border.all(color: Colors.blue),
|
border: Border.all(color: colorScheme.primary),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.circleInfo,
|
FontAwesomeIcons.circleInfo,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
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ợ.',
|
'Để 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(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.primaryBlue.withValues(alpha: 0.9),
|
color: colorScheme.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -521,8 +523,8 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
certificateImages: certificateImages.value,
|
certificateImages: certificateImages.value,
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -549,6 +551,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
Widget _buildVerificationTab({
|
Widget _buildVerificationTab({
|
||||||
required WidgetRef ref,
|
required WidgetRef ref,
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required ValueNotifier<File?> idCardFrontImage,
|
required ValueNotifier<File?> idCardFrontImage,
|
||||||
required ValueNotifier<File?> idCardBackImage,
|
required ValueNotifier<File?> idCardBackImage,
|
||||||
required ValueNotifier<List<File>> certificateImages,
|
required ValueNotifier<List<File>> certificateImages,
|
||||||
@@ -574,10 +577,10 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isVerified
|
color: isVerified
|
||||||
? const Color(0xFFF0FDF4) // Green for verified
|
? const Color(0xFFF0FDF4) // Green for verified
|
||||||
: const Color(0xFFEFF6FF), // Blue for not verified
|
: colorScheme.primaryContainer,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isVerified ? const Color(0xFFBBF7D0) : Colors.blue,
|
color: isVerified ? const Color(0xFFBBF7D0) : colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -586,7 +589,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
isVerified
|
isVerified
|
||||||
? FontAwesomeIcons.circleCheck
|
? FontAwesomeIcons.circleCheck
|
||||||
: FontAwesomeIcons.circleInfo,
|
: FontAwesomeIcons.circleInfo,
|
||||||
color: isVerified ? AppColors.success : AppColors.primaryBlue,
|
color: isVerified ? AppColors.success : colorScheme.primary,
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -599,7 +602,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: isVerified
|
color: isVerified
|
||||||
? const Color(0xFF166534)
|
? const Color(0xFF166534)
|
||||||
: AppColors.primaryBlue.withValues(alpha: 0.9),
|
: colorScheme.onPrimaryContainer,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -615,11 +618,11 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -629,20 +632,20 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Section Header
|
// Section Header
|
||||||
const Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.fileCircleCheck,
|
FontAwesomeIcons.fileCircleCheck,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Text(
|
Text(
|
||||||
'Thông tin xác thực',
|
'Thông tin xác thực',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -651,17 +654,18 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
|
|
||||||
// ID Card Front Upload
|
// ID Card Front Upload
|
||||||
const Text(
|
Text(
|
||||||
'Ảnh mặt trước CCCD/CMND',
|
'Ảnh mặt trước CCCD/CMND',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildUploadCard(
|
_buildUploadCard(
|
||||||
context: context,
|
context: context,
|
||||||
|
colorScheme: colorScheme,
|
||||||
icon: FontAwesomeIcons.camera,
|
icon: FontAwesomeIcons.camera,
|
||||||
title: 'Chụp ảnh hoặc chọn file',
|
title: 'Chụp ảnh hoặc chọn file',
|
||||||
subtitle: 'JPG, PNG tối đa 5MB',
|
subtitle: 'JPG, PNG tối đa 5MB',
|
||||||
@@ -675,17 +679,18 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// ID Card Back Upload
|
// ID Card Back Upload
|
||||||
const Text(
|
Text(
|
||||||
'Ảnh mặt sau CCCD/CMND',
|
'Ảnh mặt sau CCCD/CMND',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildUploadCard(
|
_buildUploadCard(
|
||||||
context: context,
|
context: context,
|
||||||
|
colorScheme: colorScheme,
|
||||||
icon: FontAwesomeIcons.camera,
|
icon: FontAwesomeIcons.camera,
|
||||||
title: 'Chụp ảnh hoặc chọn file',
|
title: 'Chụp ảnh hoặc chọn file',
|
||||||
subtitle: 'JPG, PNG tối đa 5MB',
|
subtitle: 'JPG, PNG tối đa 5MB',
|
||||||
@@ -699,17 +704,18 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Certificates Upload (Multiple)
|
// Certificates Upload (Multiple)
|
||||||
const Text(
|
Text(
|
||||||
'Chứng chỉ hành nghề',
|
'Chứng chỉ hành nghề',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildMultipleUploadCard(
|
_buildMultipleUploadCard(
|
||||||
context: context,
|
context: context,
|
||||||
|
colorScheme: colorScheme,
|
||||||
selectedImages: certificateImages,
|
selectedImages: certificateImages,
|
||||||
existingImageUrls: existingCertificateUrls,
|
existingImageUrls: existingCertificateUrls,
|
||||||
isVerified: isVerified,
|
isVerified: isVerified,
|
||||||
@@ -743,8 +749,8 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
certificateImages: certificateImages.value,
|
certificateImages: certificateImages.value,
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -770,6 +776,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
/// Build upload card for verification files
|
/// Build upload card for verification files
|
||||||
Widget _buildUploadCard({
|
Widget _buildUploadCard({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String title,
|
required String title,
|
||||||
required String subtitle,
|
required String subtitle,
|
||||||
@@ -790,14 +797,14 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
color: hasAnyImage
|
color: hasAnyImage
|
||||||
? const Color(0xFFF0FDF4)
|
? const Color(0xFFF0FDF4)
|
||||||
: isDisabled
|
: isDisabled
|
||||||
? const Color(0xFFF1F5F9) // Gray for disabled
|
? colorScheme.surfaceContainerHighest
|
||||||
: const Color(0xFFF8FAFC),
|
: colorScheme.surfaceContainerLowest,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: hasAnyImage
|
color: hasAnyImage
|
||||||
? const Color(0xFFBBF7D0)
|
? const Color(0xFFBBF7D0)
|
||||||
: isDisabled
|
: isDisabled
|
||||||
? const Color(0xFFCBD5E1)
|
? colorScheme.outlineVariant
|
||||||
: const Color(0xFFE2E8F0),
|
: colorScheme.outlineVariant,
|
||||||
width: 2,
|
width: 2,
|
||||||
style: BorderStyle.solid,
|
style: BorderStyle.solid,
|
||||||
),
|
),
|
||||||
@@ -877,7 +884,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
FaIcon(
|
FaIcon(
|
||||||
icon,
|
icon,
|
||||||
color: isDisabled ? AppColors.grey500 : AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -887,8 +894,8 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: isDisabled
|
color: isDisabled
|
||||||
? AppColors.grey500
|
? colorScheme.onSurfaceVariant
|
||||||
: const Color(0xFF1E293B),
|
: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -896,7 +903,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
subtitle,
|
subtitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
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)
|
/// Build multiple upload card for certificates (supports multiple images)
|
||||||
Widget _buildMultipleUploadCard({
|
Widget _buildMultipleUploadCard({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required ValueNotifier<List<File>> selectedImages,
|
required ValueNotifier<List<File>> selectedImages,
|
||||||
List<String>? existingImageUrls,
|
List<String>? existingImageUrls,
|
||||||
required bool isVerified,
|
required bool isVerified,
|
||||||
@@ -972,35 +980,35 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: colorScheme.surfaceContainerLowest,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: const Color(0xFFE2E8F0),
|
color: colorScheme.outlineVariant,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.folderPlus,
|
FontAwesomeIcons.folderPlus,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
allImages.isEmpty ? 'Chọn ảnh chứng chỉ' : 'Thêm ảnh chứng chỉ',
|
allImages.isEmpty ? 'Chọn ảnh chứng chỉ' : 'Thêm ảnh chứng chỉ',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
const Text(
|
Text(
|
||||||
'Có thể chọn nhiều ảnh - JPG, PNG tối đa 5MB mỗi ảnh',
|
'Có thể chọn nhiều ảnh - JPG, PNG tối đa 5MB mỗi ảnh',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -1014,27 +1022,27 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF1F5F9),
|
color: colorScheme.surfaceContainerHighest,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: const Color(0xFFCBD5E1),
|
color: colorScheme.outlineVariant,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
),
|
),
|
||||||
child: const Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.certificate,
|
FontAwesomeIcons.certificate,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Chưa có chứng chỉ',
|
'Chưa có chứng chỉ',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
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
|
/// Build avatar section with name, position, and status
|
||||||
Widget _buildAvatarAndStatusSection(
|
Widget _buildAvatarAndStatusSection(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
ValueNotifier<File?> selectedImage,
|
ValueNotifier<File?> selectedImage,
|
||||||
String initials,
|
String initials,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
@@ -1168,11 +1177,11 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -1190,11 +1199,11 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
height: 96,
|
height: 96,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
border: Border.all(color: Colors.white, width: 4),
|
border: Border.all(color: colorScheme.surface, width: 4),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.1),
|
color: colorScheme.shadow.withValues(alpha: 0.1),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -1237,22 +1246,22 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 3),
|
border: Border.all(color: colorScheme.surface, width: 3),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.15),
|
color: colorScheme.shadow.withValues(alpha: 0.15),
|
||||||
blurRadius: 4,
|
blurRadius: 4,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
FontAwesomeIcons.camera,
|
FontAwesomeIcons.camera,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: Colors.white,
|
color: colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1266,10 +1275,10 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
// Name
|
// Name
|
||||||
Text(
|
Text(
|
||||||
fullName,
|
fullName,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -1278,9 +1287,9 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
// Position
|
// Position
|
||||||
Text(
|
Text(
|
||||||
positionLabels[position] ?? position,
|
positionLabels[position] ?? position,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -1325,6 +1334,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
/// Build text field
|
/// Build text field
|
||||||
Widget _buildTextField({
|
Widget _buildTextField({
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required String label,
|
required String label,
|
||||||
required TextEditingController controller,
|
required TextEditingController controller,
|
||||||
bool required = false,
|
bool required = false,
|
||||||
@@ -1339,10 +1349,10 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
RichText(
|
RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
text: label,
|
text: label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
if (required)
|
if (required)
|
||||||
@@ -1363,27 +1373,27 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Nhập $label',
|
hintText: 'Nhập $label',
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(
|
||||||
color: AppColors.grey500.withValues(alpha: 0.6),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: readOnly ? const Color(0xFFF1F5F9) : const Color(0xFFF8FAFC),
|
fillColor: readOnly ? colorScheme.surfaceContainerHighest : colorScheme.surfaceContainerLowest,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1404,6 +1414,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
/// Build date field
|
/// Build date field
|
||||||
Widget _buildDateField({
|
Widget _buildDateField({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required String label,
|
required String label,
|
||||||
required TextEditingController controller,
|
required TextEditingController controller,
|
||||||
ValueNotifier<bool>? hasChanges,
|
ValueNotifier<bool>? hasChanges,
|
||||||
@@ -1413,19 +1424,19 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
@@ -1447,32 +1458,32 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Chọn ngày sinh',
|
hintText: 'Chọn ngày sinh',
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(
|
||||||
color: AppColors.grey500.withValues(alpha: 0.6),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFFF8FAFC),
|
fillColor: colorScheme.surfaceContainerLowest,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 16,
|
vertical: 16,
|
||||||
),
|
),
|
||||||
suffixIcon: const Icon(
|
suffixIcon: Icon(
|
||||||
Icons.calendar_today,
|
Icons.calendar_today,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1484,6 +1495,7 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
/// Build dropdown field
|
/// Build dropdown field
|
||||||
Widget _buildDropdownField({
|
Widget _buildDropdownField({
|
||||||
|
required ColorScheme colorScheme,
|
||||||
required String label,
|
required String label,
|
||||||
required String value,
|
required String value,
|
||||||
required List<Map<String, String>> items,
|
required List<Map<String, String>> items,
|
||||||
@@ -1494,43 +1506,43 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: value,
|
initialValue: value,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
icon: const Padding(
|
icon: Padding(
|
||||||
padding: EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
FontAwesomeIcons.chevronDown,
|
FontAwesomeIcons.chevronDown,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFFF8FAFC),
|
fillColor: colorScheme.surfaceContainerLowest,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ Future<GetUserInfo> getUserInfoUseCase(Ref ref) async {
|
|||||||
///
|
///
|
||||||
/// userInfoAsync.when(
|
/// userInfoAsync.when(
|
||||||
/// data: (userInfo) => Text(userInfo.fullName),
|
/// data: (userInfo) => Text(userInfo.fullName),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ String _$getUserInfoUseCaseHash() =>
|
|||||||
///
|
///
|
||||||
/// userInfoAsync.when(
|
/// userInfoAsync.when(
|
||||||
/// data: (userInfo) => Text(userInfo.fullName),
|
/// data: (userInfo) => Text(userInfo.fullName),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
///
|
///
|
||||||
@@ -184,7 +184,7 @@ const userInfoProvider = UserInfoProvider._();
|
|||||||
///
|
///
|
||||||
/// userInfoAsync.when(
|
/// userInfoAsync.when(
|
||||||
/// data: (userInfo) => Text(userInfo.fullName),
|
/// data: (userInfo) => Text(userInfo.fullName),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
///
|
///
|
||||||
@@ -206,7 +206,7 @@ final class UserInfoProvider
|
|||||||
///
|
///
|
||||||
/// userInfoAsync.when(
|
/// userInfoAsync.when(
|
||||||
/// data: (userInfo) => Text(userInfo.fullName),
|
/// data: (userInfo) => Text(userInfo.fullName),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
///
|
///
|
||||||
@@ -247,7 +247,7 @@ String _$userInfoHash() => r'ed28fdf0213dfd616592b9735cd291f147867047';
|
|||||||
///
|
///
|
||||||
/// userInfoAsync.when(
|
/// userInfoAsync.when(
|
||||||
/// data: (userInfo) => Text(userInfo.fullName),
|
/// data: (userInfo) => Text(userInfo.fullName),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
|
||||||
|
|
||||||
/// Account Menu Item Widget
|
/// Account Menu Item Widget
|
||||||
///
|
///
|
||||||
@@ -51,6 +50,8 @@ class AccountMenuItem extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -58,9 +59,9 @@ class AccountMenuItem extends StatelessWidget {
|
|||||||
horizontal: AppSpacing.md,
|
horizontal: AppSpacing.md,
|
||||||
vertical: AppSpacing.md,
|
vertical: AppSpacing.md,
|
||||||
),
|
),
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(color: AppColors.grey100, width: 1.0),
|
bottom: BorderSide(color: colorScheme.outlineVariant, width: 1.0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -70,16 +71,14 @@ class AccountMenuItem extends StatelessWidget {
|
|||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: iconBackgroundColor ?? colorScheme.primaryContainer,
|
||||||
iconBackgroundColor ??
|
|
||||||
AppColors.lightBlue.withValues(alpha: 0.1),
|
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
icon,
|
icon,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: iconColor ?? AppColors.primaryBlue,
|
color: iconColor ?? colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -92,19 +91,19 @@ class AccountMenuItem extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (subtitle != null) ...[
|
if (subtitle != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
subtitle!,
|
subtitle!,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -114,10 +113,10 @@ class AccountMenuItem extends StatelessWidget {
|
|||||||
|
|
||||||
// Trailing widget (default: chevron)
|
// Trailing widget (default: chevron)
|
||||||
trailing ??
|
trailing ??
|
||||||
const FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.chevronRight,
|
FontAwesomeIcons.chevronRight,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,25 +41,27 @@ class AddressCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
border: isDefault
|
border: isDefault
|
||||||
? Border.all(color: AppColors.primaryBlue, width: 2)
|
? Border.all(color: colorScheme.primary, width: 2)
|
||||||
: null,
|
: null,
|
||||||
boxShadow: isDefault
|
boxShadow: isDefault
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.primaryBlue.withValues(alpha: 0.15),
|
color: colorScheme.primary.withValues(alpha: 0.15),
|
||||||
blurRadius: 12,
|
blurRadius: 12,
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.shadow.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -78,7 +80,7 @@ class AddressCard extends StatelessWidget {
|
|||||||
value: true,
|
value: true,
|
||||||
groupValue: isSelected,
|
groupValue: isSelected,
|
||||||
onChanged: (_) => onRadioTap?.call(),
|
onChanged: (_) => onRadioTap?.call(),
|
||||||
activeColor: AppColors.primaryBlue,
|
activeColor: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -94,10 +96,10 @@ class AddressCard extends StatelessWidget {
|
|||||||
Flexible(
|
Flexible(
|
||||||
child: Text(
|
child: Text(
|
||||||
name,
|
name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@@ -110,15 +112,15 @@ class AddressCard extends StatelessWidget {
|
|||||||
vertical: 2,
|
vertical: 2,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Mặc định',
|
'Mặc định',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.white,
|
color: colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -133,18 +135,18 @@ class AddressCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.primaryBlue.withValues(
|
color: colorScheme.primary.withValues(
|
||||||
alpha: 0.3,
|
alpha: 0.3,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Đặt mặc định',
|
'Đặt mặc định',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -157,9 +159,9 @@ class AddressCard extends StatelessWidget {
|
|||||||
// Phone
|
// Phone
|
||||||
Text(
|
Text(
|
||||||
phone,
|
phone,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -168,9 +170,9 @@ class AddressCard extends StatelessWidget {
|
|||||||
// Address Text
|
// Address Text
|
||||||
Text(
|
Text(
|
||||||
address,
|
address,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -194,14 +196,14 @@ class AddressCard extends StatelessWidget {
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
FontAwesomeIcons.penToSquare,
|
FontAwesomeIcons.penToSquare,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -221,7 +223,7 @@ class AddressCard extends StatelessWidget {
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
|
|||||||
@@ -121,19 +121,21 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: AppBarSpecs.elevation,
|
elevation: AppBarSpecs.elevation,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Đơn vị kinh doanh',
|
'Đơn vị kinh doanh',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -141,7 +143,7 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
|
|||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const FaIcon(FontAwesomeIcons.circleInfo, color: Colors.black, size: 20),
|
icon: FaIcon(FontAwesomeIcons.circleInfo, color: colorScheme.onSurface, size: 20),
|
||||||
onPressed: _showInfoDialog,
|
onPressed: _showInfoDialog,
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
@@ -164,20 +166,20 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
|
|||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: const Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'DBIZ',
|
'DBIZ',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onPrimary,
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Worker App',
|
'Worker App',
|
||||||
style: TextStyle(color: Colors.white, fontSize: 12),
|
style: TextStyle(color: colorScheme.onPrimary, fontSize: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -187,22 +189,22 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
|
|||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
// Welcome Message
|
// Welcome Message
|
||||||
const Text(
|
Text(
|
||||||
'Chọn đơn vị kinh doanh để tiếp tục',
|
'Chọn đơn vị kinh doanh để tiếp tục',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: AppColors.grey500, fontSize: 14),
|
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 14),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Đơn vị kinh doanh',
|
'Đơn vị kinh doanh',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -227,11 +229,11 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
|
|||||||
bottom: isLast ? 0 : AppSpacing.xs,
|
bottom: isLast ? 0 : AppSpacing.xs,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.primaryBlue
|
? colorScheme.primary
|
||||||
: AppColors.grey100,
|
: colorScheme.surfaceContainerHighest,
|
||||||
width: isSelected ? 2 : 1,
|
width: isSelected ? 2 : 1,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.vertical(
|
borderRadius: BorderRadius.vertical(
|
||||||
@@ -249,7 +251,7 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
|
|||||||
boxShadow: isSelected
|
boxShadow: isSelected
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.primaryBlue.withValues(
|
color: colorScheme.primary.withValues(
|
||||||
alpha: 0.1,
|
alpha: 0.1,
|
||||||
),
|
),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
@@ -289,17 +291,17 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
|
|||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.primaryBlue.withValues(
|
? colorScheme.primary.withValues(
|
||||||
alpha: 0.1,
|
alpha: 0.1,
|
||||||
)
|
)
|
||||||
: AppColors.grey50,
|
: colorScheme.surfaceContainerLowest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
FontAwesomeIcons.building,
|
FontAwesomeIcons.building,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.primaryBlue
|
? colorScheme.primary
|
||||||
: AppColors.grey500,
|
: colorScheme.onSurfaceVariant,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -317,17 +319,17 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
|
|||||||
? FontWeight.w600
|
? FontWeight.w600
|
||||||
: FontWeight.w500,
|
: FontWeight.w500,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.primaryBlue
|
? colorScheme.primary
|
||||||
: AppColors.grey900,
|
: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (unit.description != null) ...[
|
if (unit.description != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
unit.description!,
|
unit.description!,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -342,19 +344,19 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.primaryBlue
|
? colorScheme.primary
|
||||||
: AppColors.grey500,
|
: colorScheme.onSurfaceVariant,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? AppColors.primaryBlue
|
? colorScheme.primary
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
child: isSelected
|
child: isSelected
|
||||||
? const Icon(
|
? Icon(
|
||||||
FontAwesomeIcons.solidCircle,
|
FontAwesomeIcons.solidCircle,
|
||||||
size: 10,
|
size: 10,
|
||||||
color: AppColors.white,
|
color: colorScheme.onPrimary,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -376,8 +378,8 @@ class _BusinessUnitSelectionPageState extends State<BusinessUnitSelectionPage> {
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _handleContinue,
|
onPressed: _handleContinue,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.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/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
import 'package:worker/core/utils/validators.dart';
|
import 'package:worker/core/utils/validators.dart';
|
||||||
@@ -137,10 +138,12 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: AppBarSpecs.elevation,
|
elevation: AppBarSpecs.elevation,
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'Quên mật khẩu',
|
'Quên mật khẩu',
|
||||||
@@ -166,27 +169,27 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
|||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
// Icon
|
// Icon
|
||||||
_buildIcon(),
|
_buildIcon(colorScheme),
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
// Title & Instructions
|
// Title & Instructions
|
||||||
_buildInstructions(),
|
_buildInstructions(colorScheme),
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
// Form Card
|
// Form Card
|
||||||
_buildFormCard(),
|
_buildFormCard(colorScheme),
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
// Back to Login Link
|
// Back to Login Link
|
||||||
_buildBackToLoginLink(),
|
_buildBackToLoginLink(colorScheme),
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
// Support Link
|
// Support Link
|
||||||
_buildSupportLink(),
|
_buildSupportLink(colorScheme),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -196,45 +199,45 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build icon
|
/// Build icon
|
||||||
Widget _buildIcon() {
|
Widget _buildIcon(ColorScheme colorScheme) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primaryBlue.withValues(alpha: 0.1),
|
color: colorScheme.primary.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
FontAwesomeIcons.key,
|
FontAwesomeIcons.key,
|
||||||
size: 50,
|
size: 50,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build instructions
|
/// Build instructions
|
||||||
Widget _buildInstructions() {
|
Widget _buildInstructions(ColorScheme colorScheme) {
|
||||||
return const Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Đặt lại mật khẩu',
|
'Đặt lại mật khẩu',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 28.0,
|
fontSize: 28.0,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
child: Text(
|
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.',
|
'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,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15.0,
|
fontSize: 15.0,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -244,11 +247,11 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build form card
|
/// Build form card
|
||||||
Widget _buildFormCard() {
|
Widget _buildFormCard(ColorScheme colorScheme) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -282,25 +285,19 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _isLoading ? null : _handleSubmit,
|
onPressed: _isLoading ? null : _handleSubmit,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: AppColors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
disabledBackgroundColor: AppColors.grey100,
|
disabledBackgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
disabledForegroundColor: AppColors.grey500,
|
disabledForegroundColor: colorScheme.onSurfaceVariant,
|
||||||
elevation: ButtonSpecs.elevation,
|
elevation: ButtonSpecs.elevation,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(ButtonSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(ButtonSpecs.borderRadius),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const SizedBox(
|
? CustomLoadingIndicator(
|
||||||
height: 20.0,
|
color: colorScheme.onPrimary,
|
||||||
width: 20.0,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.0,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
AppColors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'Gửi mã OTP',
|
'Gửi mã OTP',
|
||||||
@@ -317,21 +314,21 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build back to login link
|
/// Build back to login link
|
||||||
Widget _buildBackToLoginLink() {
|
Widget _buildBackToLoginLink(ColorScheme colorScheme) {
|
||||||
return Center(
|
return Center(
|
||||||
child: RichText(
|
child: RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
text: 'Nhớ mật khẩu? ',
|
text: 'Nhớ mật khẩu? ',
|
||||||
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
style: TextStyle(fontSize: 14.0, color: colorScheme.onSurfaceVariant),
|
||||||
children: [
|
children: [
|
||||||
WidgetSpan(
|
WidgetSpan(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => context.pop(),
|
onTap: () => context.pop(),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Đăng nhập',
|
'Đăng nhập',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.0,
|
fontSize: 14.0,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
decoration: TextDecoration.none,
|
decoration: TextDecoration.none,
|
||||||
),
|
),
|
||||||
@@ -345,20 +342,20 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build support link
|
/// Build support link
|
||||||
Widget _buildSupportLink() {
|
Widget _buildSupportLink(ColorScheme colorScheme) {
|
||||||
return Center(
|
return Center(
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: _showSupport,
|
onPressed: _showSupport,
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
FontAwesomeIcons.headset,
|
FontAwesomeIcons.headset,
|
||||||
size: AppIconSize.sm,
|
size: AppIconSize.sm,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
label: const Text(
|
label: Text(
|
||||||
'Hỗ trợ khách hàng',
|
'Hỗ trợ khách hàng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.0,
|
fontSize: 14.0,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.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/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/router/app_router.dart';
|
import 'package:worker/core/router/app_router.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
@@ -166,9 +167,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
// Watch auth state for loading indicator
|
// Watch auth state for loading indicator
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
final isPasswordVisible = ref.watch(passwordVisibilityProvider);
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
@@ -185,22 +187,22 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
// Welcome Message
|
// Welcome Message
|
||||||
_buildWelcomeMessage(),
|
_buildWelcomeMessage(colorScheme),
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
// Login Form Card
|
// Login Form Card
|
||||||
_buildLoginForm(authState, isPasswordVisible),
|
_buildLoginForm(authState, isPasswordVisible, colorScheme),
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
// Register Link
|
// Register Link
|
||||||
_buildRegisterLink(),
|
_buildRegisterLink(colorScheme),
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.xl),
|
const SizedBox(height: AppSpacing.xl),
|
||||||
|
|
||||||
// Support Link
|
// Support Link
|
||||||
_buildSupportLink(),
|
_buildSupportLink(colorScheme),
|
||||||
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -228,7 +230,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
Text(
|
Text(
|
||||||
'EUROTILE',
|
'EUROTILE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.white,
|
color: Colors.white,
|
||||||
fontSize: 32.0,
|
fontSize: 32.0,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
@@ -238,7 +240,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
Text(
|
Text(
|
||||||
'Worker App',
|
'Worker App',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.white,
|
color: Colors.white,
|
||||||
fontSize: 12.0,
|
fontSize: 12.0,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
@@ -250,21 +252,21 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build welcome message
|
/// Build welcome message
|
||||||
Widget _buildWelcomeMessage() {
|
Widget _buildWelcomeMessage(ColorScheme colorScheme) {
|
||||||
return const Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Xin chào!',
|
'Xin chào!',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 32.0,
|
fontSize: 32.0,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: AppSpacing.xs),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
Text(
|
Text(
|
||||||
'Đăng nhập để tiếp tục',
|
'Đă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(
|
Widget _buildLoginForm(
|
||||||
AsyncValue<dynamic> authState,
|
AsyncValue<dynamic> authState,
|
||||||
bool isPasswordVisible,
|
bool isPasswordVisible,
|
||||||
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
final isLoading = authState.isLoading;
|
final isLoading = authState.isLoading;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -314,30 +317,30 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
enabled: !isLoading,
|
enabled: !isLoading,
|
||||||
obscureText: !isPasswordVisible,
|
obscureText: !isPasswordVisible,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: InputFieldSpecs.fontSize,
|
fontSize: InputFieldSpecs.fontSize,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Mật khẩu',
|
labelText: 'Mật khẩu',
|
||||||
labelStyle: const TextStyle(
|
labelStyle: TextStyle(
|
||||||
fontSize: InputFieldSpecs.labelFontSize,
|
fontSize: InputFieldSpecs.labelFontSize,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
hintText: 'Nhập mật khẩu',
|
hintText: 'Nhập mật khẩu',
|
||||||
hintStyle: const TextStyle(
|
hintStyle: TextStyle(
|
||||||
fontSize: InputFieldSpecs.hintFontSize,
|
fontSize: InputFieldSpecs.hintFontSize,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: Icon(
|
||||||
FontAwesomeIcons.lock,
|
FontAwesomeIcons.lock,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
size: AppIconSize.md,
|
size: AppIconSize.md,
|
||||||
),
|
),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isPasswordVisible ? FontAwesomeIcons.eye : FontAwesomeIcons.eyeSlash,
|
isPasswordVisible ? FontAwesomeIcons.eye : FontAwesomeIcons.eyeSlash,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
size: AppIconSize.md,
|
size: AppIconSize.md,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -345,14 +348,14 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppColors.white,
|
fillColor: colorScheme.surface,
|
||||||
contentPadding: InputFieldSpecs.contentPadding,
|
contentPadding: InputFieldSpecs.contentPadding,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
InputFieldSpecs.borderRadius,
|
InputFieldSpecs.borderRadius,
|
||||||
),
|
),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
width: 1.0,
|
width: 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -360,8 +363,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
InputFieldSpecs.borderRadius,
|
InputFieldSpecs.borderRadius,
|
||||||
),
|
),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
width: 1.0,
|
width: 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -369,8 +372,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
InputFieldSpecs.borderRadius,
|
InputFieldSpecs.borderRadius,
|
||||||
),
|
),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2.0,
|
width: 2.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -424,7 +427,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
_rememberMe = value ?? false;
|
_rememberMe = value ?? false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
activeColor: AppColors.primaryBlue,
|
activeColor: colorScheme.primary,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(4.0),
|
borderRadius: BorderRadius.circular(4.0),
|
||||||
),
|
),
|
||||||
@@ -437,11 +440,11 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
_rememberMe = !_rememberMe;
|
_rememberMe = !_rememberMe;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Ghi nhớ đăng nhập',
|
'Ghi nhớ đăng nhập',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.0,
|
fontSize: 14.0,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -455,7 +458,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
'Quên mật khẩu?',
|
'Quên mật khẩu?',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.0,
|
fontSize: 14.0,
|
||||||
color: isLoading ? AppColors.grey500 : AppColors.primaryBlue,
|
color: isLoading ? colorScheme.onSurfaceVariant : colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -471,25 +474,19 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: isLoading ? null : _handleLogin,
|
onPressed: isLoading ? null : _handleLogin,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: AppColors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
disabledBackgroundColor: AppColors.grey100,
|
disabledBackgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
disabledForegroundColor: AppColors.grey500,
|
disabledForegroundColor: colorScheme.onSurfaceVariant,
|
||||||
elevation: ButtonSpecs.elevation,
|
elevation: ButtonSpecs.elevation,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(ButtonSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(ButtonSpecs.borderRadius),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: isLoading
|
child: isLoading
|
||||||
? const SizedBox(
|
? CustomLoadingIndicator(
|
||||||
height: 20.0,
|
color: colorScheme.onPrimary,
|
||||||
width: 20.0,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.0,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
AppColors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'Đăng nhập',
|
'Đăng nhập',
|
||||||
@@ -506,21 +503,21 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build register link
|
/// Build register link
|
||||||
Widget _buildRegisterLink() {
|
Widget _buildRegisterLink(ColorScheme colorScheme) {
|
||||||
return Center(
|
return Center(
|
||||||
child: RichText(
|
child: RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
text: 'Chưa có tài khoản? ',
|
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: [
|
children: [
|
||||||
WidgetSpan(
|
WidgetSpan(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: _navigateToRegister,
|
onTap: _navigateToRegister,
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Đăng ký ngay',
|
'Đăng ký ngay',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.0,
|
fontSize: 14.0,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
decoration: TextDecoration.none,
|
decoration: TextDecoration.none,
|
||||||
),
|
),
|
||||||
@@ -534,20 +531,20 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build support link
|
/// Build support link
|
||||||
Widget _buildSupportLink() {
|
Widget _buildSupportLink(ColorScheme colorScheme) {
|
||||||
return Center(
|
return Center(
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: _showSupport,
|
onPressed: _showSupport,
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
FontAwesomeIcons.headset,
|
FontAwesomeIcons.headset,
|
||||||
size: AppIconSize.sm,
|
size: AppIconSize.sm,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
label: const Text(
|
label: Text(
|
||||||
'Hỗ trợ khách hàng',
|
'Hỗ trợ khách hàng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.0,
|
fontSize: 14.0,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.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/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/router/app_router.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
|
/// OTP Verification Page
|
||||||
///
|
///
|
||||||
@@ -237,19 +238,21 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.grey50,
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: AppBarSpecs.elevation,
|
elevation: AppBarSpecs.elevation,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Xác thực OTP',
|
'Xác thực OTP',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -276,14 +279,14 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [AppColors.primaryBlue, AppColors.lightBlue],
|
colors: [AppColors.primaryBlue, AppColors.lightBlue], // Keep brand colors
|
||||||
),
|
),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
FontAwesomeIcons.shieldHalved,
|
FontAwesomeIcons.shieldHalved,
|
||||||
size: 36,
|
size: 36,
|
||||||
color: AppColors.white,
|
color: colorScheme.onPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -291,24 +294,24 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
// Instructions
|
// Instructions
|
||||||
const Text(
|
Text(
|
||||||
'Nhập mã xác thực',
|
'Nhập mã xác thực',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
const Text(
|
Text(
|
||||||
'Mã OTP đã được gửi đến số điện thoại',
|
'Mã OTP đã được gửi đến số điện thoại',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -317,10 +320,10 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
Text(
|
Text(
|
||||||
_formatPhoneNumber(widget.phoneNumber),
|
_formatPhoneNumber(widget.phoneNumber),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -329,7 +332,7 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
// OTP Input Card
|
// OTP Input Card
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -351,7 +354,7 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: index > 0 ? 8 : 0,
|
left: index > 0 ? 8 : 0,
|
||||||
),
|
),
|
||||||
child: _buildOtpInput(index),
|
child: _buildOtpInput(index, colorScheme),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -365,8 +368,8 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _isLoading ? null : _handleVerifyOtp,
|
onPressed: _isLoading ? null : _handleVerifyOtp,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: AppColors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
@@ -375,15 +378,9 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const SizedBox(
|
? CustomLoadingIndicator(
|
||||||
height: 20,
|
color: colorScheme.onPrimary,
|
||||||
width: 20,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
AppColors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'Xác nhận',
|
'Xác nhận',
|
||||||
@@ -405,9 +402,9 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
child: Text.rich(
|
child: Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: 'Không nhận được mã? ',
|
text: 'Không nhận được mã? ',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
WidgetSpan(
|
WidgetSpan(
|
||||||
@@ -421,8 +418,8 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: _countdown > 0
|
color: _countdown > 0
|
||||||
? AppColors.grey500
|
? colorScheme.onSurfaceVariant
|
||||||
: AppColors.primaryBlue,
|
: colorScheme.primary,
|
||||||
decoration: _countdown == 0
|
decoration: _countdown == 0
|
||||||
? TextDecoration.none
|
? TextDecoration.none
|
||||||
: TextDecoration.none,
|
: TextDecoration.none,
|
||||||
@@ -445,7 +442,7 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build single OTP input box
|
/// Build single OTP input box
|
||||||
Widget _buildOtpInput(int index) {
|
Widget _buildOtpInput(int index, ColorScheme colorScheme) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
@@ -455,10 +452,10 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
maxLength: 1,
|
maxLength: 1,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
@@ -468,20 +465,20 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
filled: false,
|
filled: false,
|
||||||
fillColor: AppColors.white,
|
fillColor: colorScheme.surface,
|
||||||
),
|
),
|
||||||
onChanged: (value) => _onOtpChanged(index, value),
|
onChanged: (value) => _onOtpChanged(index, value),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:image_picker/image_picker.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/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/router/app_router.dart';
|
import 'package:worker/core/router/app_router.dart';
|
||||||
@@ -379,6 +380,9 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Get color scheme at the start of build method
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Initialize data on first build
|
// Initialize data on first build
|
||||||
if (!_hasInitialized) {
|
if (!_hasInitialized) {
|
||||||
// Use addPostFrameCallback to avoid calling setState during build
|
// Use addPostFrameCallback to avoid calling setState during build
|
||||||
@@ -388,18 +392,18 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: AppBarSpecs.elevation,
|
elevation: AppBarSpecs.elevation,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Đăng ký tài khoản',
|
'Đăng ký tài khoản',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -407,18 +411,8 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
),
|
),
|
||||||
body: _isLoadingData
|
body: _isLoadingData
|
||||||
? const Center(
|
? const CustomLoadingIndicator(
|
||||||
child: Column(
|
message: 'Đang tải dữ liệu...',
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(),
|
|
||||||
SizedBox(height: AppSpacing.md),
|
|
||||||
Text(
|
|
||||||
'Đang tải dữ liệu...',
|
|
||||||
style: TextStyle(color: AppColors.grey500),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: SafeArea(
|
: SafeArea(
|
||||||
child: Form(
|
child: Form(
|
||||||
@@ -429,19 +423,19 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Welcome section
|
// Welcome section
|
||||||
const Text(
|
Text(
|
||||||
'Tạo tài khoản mới',
|
'Tạo tài khoản mới',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xs),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
const Text(
|
Text(
|
||||||
'Điền thông tin để bắt đầu',
|
'Đ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,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
@@ -449,7 +443,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
// Form card
|
// Form card
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -464,7 +458,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Full Name
|
// Full Name
|
||||||
_buildLabel('Họ và tên *'),
|
_buildLabel('Họ và tên *', colorScheme),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _fullNameController,
|
controller: _fullNameController,
|
||||||
focusNode: _fullNameFocus,
|
focusNode: _fullNameFocus,
|
||||||
@@ -472,6 +466,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
decoration: _buildInputDecoration(
|
decoration: _buildInputDecoration(
|
||||||
hintText: 'Nhập họ và tên',
|
hintText: 'Nhập họ và tên',
|
||||||
prefixIcon: FontAwesomeIcons.user,
|
prefixIcon: FontAwesomeIcons.user,
|
||||||
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
validator: (value) => Validators.minLength(
|
validator: (value) => Validators.minLength(
|
||||||
value,
|
value,
|
||||||
@@ -482,7 +477,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Phone Number
|
// Phone Number
|
||||||
_buildLabel('Số điện thoại *'),
|
_buildLabel('Số điện thoại *', colorScheme),
|
||||||
PhoneInputField(
|
PhoneInputField(
|
||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
focusNode: _phoneFocus,
|
focusNode: _phoneFocus,
|
||||||
@@ -491,7 +486,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Email
|
// Email
|
||||||
_buildLabel('Email *'),
|
_buildLabel('Email *', colorScheme),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
focusNode: _emailFocus,
|
focusNode: _emailFocus,
|
||||||
@@ -500,13 +495,14 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
decoration: _buildInputDecoration(
|
decoration: _buildInputDecoration(
|
||||||
hintText: 'Nhập email',
|
hintText: 'Nhập email',
|
||||||
prefixIcon: FontAwesomeIcons.envelope,
|
prefixIcon: FontAwesomeIcons.envelope,
|
||||||
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
validator: Validators.email,
|
validator: Validators.email,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Password
|
// Password
|
||||||
_buildLabel('Mật khẩu *'),
|
_buildLabel('Mật khẩu *', colorScheme),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
focusNode: _passwordFocus,
|
focusNode: _passwordFocus,
|
||||||
@@ -515,12 +511,13 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
decoration: _buildInputDecoration(
|
decoration: _buildInputDecoration(
|
||||||
hintText: 'Tạo mật khẩu mới',
|
hintText: 'Tạo mật khẩu mới',
|
||||||
prefixIcon: FontAwesomeIcons.lock,
|
prefixIcon: FontAwesomeIcons.lock,
|
||||||
|
colorScheme: colorScheme,
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_passwordVisible
|
_passwordVisible
|
||||||
? FontAwesomeIcons.eye
|
? FontAwesomeIcons.eye
|
||||||
: FontAwesomeIcons.eyeSlash,
|
: FontAwesomeIcons.eyeSlash,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -533,28 +530,28 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
Validators.passwordSimple(value, minLength: 6),
|
Validators.passwordSimple(value, minLength: 6),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xs),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
const Text(
|
Text(
|
||||||
'Mật khẩu tối thiểu 6 ký tự',
|
'Mật khẩu tối thiểu 6 ký tự',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Role Selection (Customer Groups)
|
// Role Selection (Customer Groups)
|
||||||
_buildLabel('Vai trò *'),
|
_buildLabel('Vai trò *', colorScheme),
|
||||||
_buildCustomerGroupDropdown(),
|
_buildCustomerGroupDropdown(colorScheme),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Verification Section (conditional)
|
// Verification Section (conditional)
|
||||||
if (_shouldShowVerification) ...[
|
if (_shouldShowVerification) ...[
|
||||||
_buildVerificationSection(),
|
_buildVerificationSection(colorScheme),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Company Name (optional)
|
// 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(
|
TextFormField(
|
||||||
controller: _companyController,
|
controller: _companyController,
|
||||||
focusNode: _companyFocus,
|
focusNode: _companyFocus,
|
||||||
@@ -562,13 +559,14 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
decoration: _buildInputDecoration(
|
decoration: _buildInputDecoration(
|
||||||
hintText: 'Nhập tên công ty (không bắt buộc)',
|
hintText: 'Nhập tên công ty (không bắt buộc)',
|
||||||
prefixIcon: FontAwesomeIcons.building,
|
prefixIcon: FontAwesomeIcons.building,
|
||||||
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// City/Province
|
// City/Province
|
||||||
_buildLabel('Tỉnh/Thành phố *'),
|
_buildLabel('Tỉnh/Thành phố *', colorScheme),
|
||||||
_buildCityDropdown(),
|
_buildCityDropdown(colorScheme),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Terms and Conditions
|
// Terms and Conditions
|
||||||
@@ -582,7 +580,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
_termsAccepted = value ?? false;
|
_termsAccepted = value ?? false;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
activeColor: AppColors.primaryBlue,
|
activeColor: colorScheme.primary,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -593,23 +591,23 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
_termsAccepted = !_termsAccepted;
|
_termsAccepted = !_termsAccepted;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
child: const Text.rich(
|
child: Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: 'Tôi đồng ý với ',
|
text: 'Tôi đồng ý với ',
|
||||||
style: TextStyle(fontSize: 13),
|
style: const TextStyle(fontSize: 13),
|
||||||
children: [
|
children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: 'Điều khoản sử dụng',
|
text: 'Điều khoản sử dụng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextSpan(text: ' và '),
|
const TextSpan(text: ' và '),
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: 'Chính sách bảo mật',
|
text: 'Chính sách bảo mật',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -629,8 +627,8 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: _isLoading ? null : _handleRegister,
|
onPressed: _isLoading ? null : _handleRegister,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: AppColors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(
|
borderRadius: BorderRadius.circular(
|
||||||
@@ -639,15 +637,9 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const SizedBox(
|
? CustomLoadingIndicator(
|
||||||
height: 20,
|
color: colorScheme.onPrimary,
|
||||||
width: 20,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
AppColors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'Đăng ký',
|
'Đăng ký',
|
||||||
@@ -667,17 +659,17 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Đã có tài khoản? ',
|
'Đã có tài khoản? ',
|
||||||
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
style: TextStyle(fontSize: 13, color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => context.pop(),
|
onTap: () => context.pop(),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Đăng nhập',
|
'Đăng nhập',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -694,15 +686,15 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build label widget
|
/// Build label widget
|
||||||
Widget _buildLabel(String text) {
|
Widget _buildLabel(String text, ColorScheme colorScheme) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -712,34 +704,35 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
InputDecoration _buildInputDecoration({
|
InputDecoration _buildInputDecoration({
|
||||||
required String hintText,
|
required String hintText,
|
||||||
required IconData prefixIcon,
|
required IconData prefixIcon,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
Widget? suffixIcon,
|
Widget? suffixIcon,
|
||||||
}) {
|
}) {
|
||||||
return InputDecoration(
|
return InputDecoration(
|
||||||
hintText: hintText,
|
hintText: hintText,
|
||||||
hintStyle: const TextStyle(
|
hintStyle: TextStyle(
|
||||||
fontSize: InputFieldSpecs.hintFontSize,
|
fontSize: InputFieldSpecs.hintFontSize,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
prefixIcon: Icon(
|
prefixIcon: Icon(
|
||||||
prefixIcon,
|
prefixIcon,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
size: AppIconSize.md,
|
size: AppIconSize.md,
|
||||||
),
|
),
|
||||||
suffixIcon: suffixIcon,
|
suffixIcon: suffixIcon,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppColors.white,
|
fillColor: colorScheme.surface,
|
||||||
contentPadding: InputFieldSpecs.contentPadding,
|
contentPadding: InputFieldSpecs.contentPadding,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2.0),
|
borderSide: BorderSide(color: colorScheme.primary, width: 2.0),
|
||||||
),
|
),
|
||||||
errorBorder: OutlineInputBorder(
|
errorBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
@@ -753,7 +746,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build customer group dropdown
|
/// Build customer group dropdown
|
||||||
Widget _buildCustomerGroupDropdown() {
|
Widget _buildCustomerGroupDropdown(ColorScheme colorScheme) {
|
||||||
final customerGroupsAsync = ref.watch(customerGroupsProvider);
|
final customerGroupsAsync = ref.watch(customerGroupsProvider);
|
||||||
|
|
||||||
return customerGroupsAsync.when(
|
return customerGroupsAsync.when(
|
||||||
@@ -763,6 +756,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
decoration: _buildInputDecoration(
|
decoration: _buildInputDecoration(
|
||||||
hintText: 'Chọn vai trò',
|
hintText: 'Chọn vai trò',
|
||||||
prefixIcon: FontAwesomeIcons.briefcase,
|
prefixIcon: FontAwesomeIcons.briefcase,
|
||||||
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
items: groups
|
items: groups
|
||||||
.map(
|
.map(
|
||||||
@@ -792,9 +786,12 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const SizedBox(
|
loading: () => SizedBox(
|
||||||
height: 48,
|
height: 48,
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: CustomLoadingIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
error: (error, stack) => Container(
|
error: (error, stack) => Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
@@ -825,7 +822,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build city dropdown
|
/// Build city dropdown
|
||||||
Widget _buildCityDropdown() {
|
Widget _buildCityDropdown(ColorScheme colorScheme) {
|
||||||
final citiesAsync = ref.watch(citiesProvider);
|
final citiesAsync = ref.watch(citiesProvider);
|
||||||
|
|
||||||
return citiesAsync.when(
|
return citiesAsync.when(
|
||||||
@@ -835,6 +832,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
decoration: _buildInputDecoration(
|
decoration: _buildInputDecoration(
|
||||||
hintText: 'Chọn tỉnh/thành phố',
|
hintText: 'Chọn tỉnh/thành phố',
|
||||||
prefixIcon: Icons.location_city,
|
prefixIcon: Icons.location_city,
|
||||||
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
items: cities
|
items: cities
|
||||||
.map(
|
.map(
|
||||||
@@ -857,9 +855,12 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const SizedBox(
|
loading: () => SizedBox(
|
||||||
height: 48,
|
height: 48,
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: CustomLoadingIndicator(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
error: (error, stack) => Container(
|
error: (error, stack) => Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
@@ -890,11 +891,11 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build verification section
|
/// Build verification section
|
||||||
Widget _buildVerificationSection() {
|
Widget _buildVerificationSection(ColorScheme colorScheme) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: colorScheme.surfaceContainerLowest,
|
||||||
border: Border.all(color: const Color(0xFFE2E8F0), width: 2),
|
border: Border.all(color: colorScheme.outlineVariant, width: 2),
|
||||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
@@ -905,28 +906,28 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.shield, color: AppColors.primaryBlue, size: 20),
|
Icon(Icons.shield, color: colorScheme.primary, size: 20),
|
||||||
const SizedBox(width: AppSpacing.xs),
|
const SizedBox(width: AppSpacing.xs),
|
||||||
const Text(
|
Text(
|
||||||
'Thông tin xác thực',
|
'Thông tin xác thực',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xs),
|
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',
|
'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,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// ID Number
|
// ID Number
|
||||||
_buildLabel('Số CCCD/CMND'),
|
_buildLabel('Số CCCD/CMND', colorScheme),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _idNumberController,
|
controller: _idNumberController,
|
||||||
focusNode: _idNumberFocus,
|
focusNode: _idNumberFocus,
|
||||||
@@ -935,12 +936,13 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
decoration: _buildInputDecoration(
|
decoration: _buildInputDecoration(
|
||||||
hintText: 'Nhập số CCCD/CMND',
|
hintText: 'Nhập số CCCD/CMND',
|
||||||
prefixIcon: Icons.badge,
|
prefixIcon: Icons.badge,
|
||||||
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Tax Code
|
// Tax Code
|
||||||
_buildLabel('Mã số thuế cá nhân/Công ty'),
|
_buildLabel('Mã số thuế cá nhân/Công ty', colorScheme),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _taxCodeController,
|
controller: _taxCodeController,
|
||||||
focusNode: _taxCodeFocus,
|
focusNode: _taxCodeFocus,
|
||||||
@@ -949,13 +951,14 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
decoration: _buildInputDecoration(
|
decoration: _buildInputDecoration(
|
||||||
hintText: 'Nhập mã số thuế (không bắt buộc)',
|
hintText: 'Nhập mã số thuế (không bắt buộc)',
|
||||||
prefixIcon: Icons.receipt_long,
|
prefixIcon: Icons.receipt_long,
|
||||||
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
validator: Validators.taxIdOptional,
|
validator: Validators.taxIdOptional,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// ID Card Upload
|
// ID Card Upload
|
||||||
_buildLabel('Ảnh mặt trước CCCD/CMND'),
|
_buildLabel('Ảnh mặt trước CCCD/CMND', colorScheme),
|
||||||
FileUploadCard(
|
FileUploadCard(
|
||||||
file: _idCardFile,
|
file: _idCardFile,
|
||||||
onTap: () => _pickImage(true),
|
onTap: () => _pickImage(true),
|
||||||
@@ -967,7 +970,7 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Certificate Upload
|
// 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(
|
FileUploadCard(
|
||||||
file: _certificateFile,
|
file: _certificateFile,
|
||||||
onTap: () => _pickImage(false),
|
onTap: () => _pickImage(false),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
|
|
||||||
/// Splash Page
|
/// Splash Page
|
||||||
@@ -15,8 +16,10 @@ class SplashPage extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -37,7 +40,7 @@ class SplashPage extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'EUROTILE',
|
'EUROTILE',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.white,
|
color: Colors.white,
|
||||||
fontSize: 32.0,
|
fontSize: 32.0,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 1.5,
|
letterSpacing: 1.5,
|
||||||
@@ -47,7 +50,7 @@ class SplashPage extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Worker App',
|
'Worker App',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.white,
|
color: Colors.white,
|
||||||
fontSize: 12.0,
|
fontSize: 12.0,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
@@ -59,19 +62,16 @@ class SplashPage extends StatelessWidget {
|
|||||||
const SizedBox(height: 48.0),
|
const SizedBox(height: 48.0),
|
||||||
|
|
||||||
// Loading Indicator
|
// Loading Indicator
|
||||||
const CircularProgressIndicator(
|
const CustomLoadingIndicator(),
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryBlue),
|
|
||||||
strokeWidth: 3.0,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16.0),
|
const SizedBox(height: 16.0),
|
||||||
|
|
||||||
// Loading Text
|
// Loading Text
|
||||||
const Text(
|
Text(
|
||||||
'Đang tải...',
|
'Đang tải...',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14.0,
|
fontSize: 14.0,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
/// Uses Riverpod 3.0 with code generation for type-safe state management.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:worker/core/constants/api_constants.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/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_local_datasource.dart';
|
||||||
import 'package:worker/features/auth/data/datasources/auth_remote_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';
|
import 'package:worker/features/auth/domain/entities/user.dart';
|
||||||
|
|
||||||
part 'auth_provider.g.dart';
|
part 'auth_provider.g.dart';
|
||||||
@@ -80,10 +78,6 @@ class Auth extends _$Auth {
|
|||||||
Future<FrappeAuthService> get _frappeAuthService async =>
|
Future<FrappeAuthService> get _frappeAuthService async =>
|
||||||
await ref.read(frappeAuthServiceProvider.future);
|
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
|
/// Initialize with saved session if available
|
||||||
@override
|
@override
|
||||||
Future<User?> build() async {
|
Future<User?> build() async {
|
||||||
@@ -170,7 +164,6 @@ class Auth extends _$Auth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final frappeService = await _frappeAuthService;
|
final frappeService = await _frappeAuthService;
|
||||||
final remoteDataSource = await _remoteDataSource;
|
|
||||||
|
|
||||||
// Get current session (should exist from app startup)
|
// Get current session (should exist from app startup)
|
||||||
final currentSession = await frappeService.getStoredSession();
|
final currentSession = await frappeService.getStoredSession();
|
||||||
@@ -183,22 +176,8 @@ class Auth extends _$Auth {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get stored session again
|
// Call login API and store session
|
||||||
final session = await frappeService.getStoredSession();
|
final loginResponse = await frappeService.login(phoneNumber, password: password);
|
||||||
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);
|
|
||||||
|
|
||||||
// Save rememberMe preference
|
// Save rememberMe preference
|
||||||
await _localDataSource.saveRememberMe(rememberMe);
|
await _localDataSource.saveRememberMe(rememberMe);
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
|
|||||||
Auth create() => Auth();
|
Auth create() => Auth();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$authHash() => r'f0438cf6eb9eb17c0afc6b23055acd09926b21ae';
|
String _$authHash() => r'd851980cad7a624f00eba69e19d8a4fee22008e7';
|
||||||
|
|
||||||
/// Authentication Provider
|
/// Authentication Provider
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -77,25 +77,27 @@ class FileUploadCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
// Show preview with remove option
|
// Show preview with remove option
|
||||||
return _buildPreview(context);
|
return _buildPreview(context, colorScheme);
|
||||||
} else {
|
} else {
|
||||||
// Show upload area
|
// Show upload area
|
||||||
return _buildUploadArea(context);
|
return _buildUploadArea(context, colorScheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build upload area
|
/// Build upload area
|
||||||
Widget _buildUploadArea(BuildContext context) {
|
Widget _buildUploadArea(BuildContext context, ColorScheme colorScheme) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: const Color(0xFFCBD5E1),
|
color: colorScheme.outlineVariant,
|
||||||
width: 2,
|
width: 2,
|
||||||
strokeAlign: BorderSide.strokeAlignInside,
|
strokeAlign: BorderSide.strokeAlignInside,
|
||||||
),
|
),
|
||||||
@@ -105,16 +107,16 @@ class FileUploadCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Icon
|
// Icon
|
||||||
Icon(icon, size: 32, color: AppColors.grey500),
|
Icon(icon, size: 32, color: colorScheme.onSurfaceVariant),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.xs),
|
const SizedBox(height: AppSpacing.xs),
|
||||||
@@ -122,7 +124,7 @@ class FileUploadCard extends StatelessWidget {
|
|||||||
// Subtitle
|
// Subtitle
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
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
|
/// Build preview with remove button
|
||||||
Widget _buildPreview(BuildContext context) {
|
Widget _buildPreview(BuildContext context, ColorScheme colorScheme) {
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
border: Border.all(color: AppColors.grey100, width: 1),
|
border: Border.all(color: colorScheme.surfaceContainerHighest, width: 1),
|
||||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||||
@@ -153,10 +155,10 @@ class FileUploadCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: const Icon(
|
child: Icon(
|
||||||
FontAwesomeIcons.image,
|
FontAwesomeIcons.image,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -173,10 +175,10 @@ class FileUploadCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_getFileName(file!.path),
|
_getFileName(file!.path),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -188,9 +190,9 @@ class FileUploadCard extends StatelessWidget {
|
|||||||
if (snapshot.hasData) {
|
if (snapshot.hasData) {
|
||||||
return Text(
|
return Text(
|
||||||
_formatFileSize(snapshot.data!),
|
_formatFileSize(snapshot.data!),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.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
|
/// Phone Input Field
|
||||||
///
|
///
|
||||||
@@ -65,6 +65,8 @@ class PhoneInputField extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return TextFormField(
|
return TextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
@@ -78,41 +80,41 @@ class PhoneInputField extends StatelessWidget {
|
|||||||
// Limit to reasonable phone length
|
// Limit to reasonable phone length
|
||||||
LengthLimitingTextInputFormatter(15),
|
LengthLimitingTextInputFormatter(15),
|
||||||
],
|
],
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: InputFieldSpecs.fontSize,
|
fontSize: InputFieldSpecs.fontSize,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Số điện thoại',
|
labelText: 'Số điện thoại',
|
||||||
labelStyle: const TextStyle(
|
labelStyle: TextStyle(
|
||||||
fontSize: InputFieldSpecs.labelFontSize,
|
fontSize: InputFieldSpecs.labelFontSize,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
hintText: 'Nhập số điện thoại',
|
hintText: 'Nhập số điện thoại',
|
||||||
hintStyle: const TextStyle(
|
hintStyle: TextStyle(
|
||||||
fontSize: InputFieldSpecs.hintFontSize,
|
fontSize: InputFieldSpecs.hintFontSize,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: Icon(
|
||||||
FontAwesomeIcons.phone,
|
FontAwesomeIcons.phone,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
size: AppIconSize.md,
|
size: AppIconSize.md,
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppColors.white,
|
fillColor: colorScheme.surface,
|
||||||
contentPadding: InputFieldSpecs.contentPadding,
|
contentPadding: InputFieldSpecs.contentPadding,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2.0,
|
width: 2.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -54,34 +54,36 @@ class RoleDropdown extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return DropdownButtonFormField<String>(
|
return DropdownButtonFormField<String>(
|
||||||
initialValue: value,
|
initialValue: value,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Chọn vai trò của bạn',
|
hintText: 'Chọn vai trò của bạn',
|
||||||
hintStyle: const TextStyle(
|
hintStyle: TextStyle(
|
||||||
fontSize: InputFieldSpecs.hintFontSize,
|
fontSize: InputFieldSpecs.hintFontSize,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: Icon(
|
||||||
FontAwesomeIcons.briefcase,
|
FontAwesomeIcons.briefcase,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
size: AppIconSize.md,
|
size: AppIconSize.md,
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: AppColors.white,
|
fillColor: colorScheme.surface,
|
||||||
contentPadding: InputFieldSpecs.contentPadding,
|
contentPadding: InputFieldSpecs.contentPadding,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
borderSide: const BorderSide(color: AppColors.grey100, width: 1.0),
|
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest, width: 1.0),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2.0,
|
width: 2.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -105,11 +107,11 @@ class RoleDropdown extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
icon: const FaIcon(FontAwesomeIcons.chevronDown, color: AppColors.grey500, size: 16),
|
icon: FaIcon(FontAwesomeIcons.chevronDown, color: colorScheme.onSurfaceVariant, size: 16),
|
||||||
dropdownColor: AppColors.white,
|
dropdownColor: colorScheme.surface,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: InputFieldSpecs.fontSize,
|
fontSize: InputFieldSpecs.fontSize,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ abstract class CartRemoteDataSource {
|
|||||||
/// Add items to cart
|
/// Add items to cart
|
||||||
///
|
///
|
||||||
/// [items] - List of items with item_id, quantity, and amount
|
/// [items] - List of items with item_id, quantity, and amount
|
||||||
/// Returns list of cart items from API
|
/// Returns true if successful
|
||||||
Future<List<CartItemModel>> addToCart({
|
Future<bool> addToCart({
|
||||||
required List<Map<String, dynamic>> items,
|
required List<Map<String, dynamic>> items,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ class CartRemoteDataSourceImpl implements CartRemoteDataSource {
|
|||||||
final DioClient _dioClient;
|
final DioClient _dioClient;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CartItemModel>> addToCart({
|
Future<bool> addToCart({
|
||||||
required List<Map<String, dynamic>> items,
|
required List<Map<String, dynamic>> items,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
@@ -78,8 +78,7 @@ class CartRemoteDataSourceImpl implements CartRemoteDataSource {
|
|||||||
throw const ParseException('Invalid response format from add to cart API');
|
throw const ParseException('Invalid response format from add to cart API');
|
||||||
}
|
}
|
||||||
|
|
||||||
// After adding, fetch updated cart
|
return true;
|
||||||
return await getUserCart();
|
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
throw _handleDioException(e);
|
throw _handleDioException(e);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -191,15 +190,21 @@ class CartRemoteDataSourceImpl implements CartRemoteDataSource {
|
|||||||
try {
|
try {
|
||||||
// Map API response to CartItemModel
|
// Map API response to CartItemModel
|
||||||
// API fields: name, item, quantity, amount, item_code, item_name, image, conversion_of_sm
|
// 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(
|
final cartItem = CartItemModel(
|
||||||
cartItemId: item['name'] as String? ?? '',
|
cartItemId: item['name'] as String? ?? '',
|
||||||
cartId: 'user_cart', // Fixed cart ID for user's cart
|
cartId: 'user_cart', // Fixed cart ID for user's cart
|
||||||
productId: item['item_code'] as String? ?? item['item'] as String? ?? '',
|
productId: item['item_code'] as String? ?? item['item'] as String? ?? '',
|
||||||
quantity: (item['quantity'] as num?)?.toDouble() ?? 0.0,
|
quantity: quantity,
|
||||||
unitPrice: (item['amount'] as num?)?.toDouble() ?? 0.0,
|
unitPrice: unitPrice,
|
||||||
subtotal: ((item['quantity'] as num?)?.toDouble() ?? 0.0) *
|
subtotal: quantity * unitPrice,
|
||||||
((item['amount'] as num?)?.toDouble() ?? 0.0),
|
|
||||||
addedAt: DateTime.now(), // API doesn't provide timestamp
|
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);
|
cartItems.add(cartItem);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
import 'package:worker/core/constants/storage_constants.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';
|
part 'cart_item_model.g.dart';
|
||||||
|
|
||||||
/// Cart Item Model - Type ID: 5
|
/// Cart Item Model - Type ID: 5
|
||||||
|
///
|
||||||
|
/// Includes product details from cart API to avoid fetching each product.
|
||||||
@HiveType(typeId: HiveTypeIds.cartItemModel)
|
@HiveType(typeId: HiveTypeIds.cartItemModel)
|
||||||
class CartItemModel extends HiveObject {
|
class CartItemModel extends HiveObject {
|
||||||
CartItemModel({
|
CartItemModel({
|
||||||
@@ -14,6 +17,9 @@ class CartItemModel extends HiveObject {
|
|||||||
required this.unitPrice,
|
required this.unitPrice,
|
||||||
required this.subtotal,
|
required this.subtotal,
|
||||||
required this.addedAt,
|
required this.addedAt,
|
||||||
|
this.itemName,
|
||||||
|
this.image,
|
||||||
|
this.conversionOfSm,
|
||||||
});
|
});
|
||||||
|
|
||||||
@HiveField(0)
|
@HiveField(0)
|
||||||
@@ -37,6 +43,18 @@ class CartItemModel extends HiveObject {
|
|||||||
@HiveField(6)
|
@HiveField(6)
|
||||||
final DateTime addedAt;
|
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) {
|
factory CartItemModel.fromJson(Map<String, dynamic> json) {
|
||||||
return CartItemModel(
|
return CartItemModel(
|
||||||
cartItemId: json['cart_item_id'] as String,
|
cartItemId: json['cart_item_id'] as String,
|
||||||
@@ -67,6 +85,9 @@ class CartItemModel extends HiveObject {
|
|||||||
double? unitPrice,
|
double? unitPrice,
|
||||||
double? subtotal,
|
double? subtotal,
|
||||||
DateTime? addedAt,
|
DateTime? addedAt,
|
||||||
|
String? itemName,
|
||||||
|
String? image,
|
||||||
|
double? conversionOfSm,
|
||||||
}) => CartItemModel(
|
}) => CartItemModel(
|
||||||
cartItemId: cartItemId ?? this.cartItemId,
|
cartItemId: cartItemId ?? this.cartItemId,
|
||||||
cartId: cartId ?? this.cartId,
|
cartId: cartId ?? this.cartId,
|
||||||
@@ -75,5 +96,22 @@ class CartItemModel extends HiveObject {
|
|||||||
unitPrice: unitPrice ?? this.unitPrice,
|
unitPrice: unitPrice ?? this.unitPrice,
|
||||||
subtotal: subtotal ?? this.subtotal,
|
subtotal: subtotal ?? this.subtotal,
|
||||||
addedAt: addedAt ?? this.addedAt,
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,13 +24,16 @@ class CartItemModelAdapter extends TypeAdapter<CartItemModel> {
|
|||||||
unitPrice: (fields[4] as num).toDouble(),
|
unitPrice: (fields[4] as num).toDouble(),
|
||||||
subtotal: (fields[5] as num).toDouble(),
|
subtotal: (fields[5] as num).toDouble(),
|
||||||
addedAt: fields[6] as DateTime,
|
addedAt: fields[6] as DateTime,
|
||||||
|
itemName: fields[7] as String?,
|
||||||
|
image: fields[8] as String?,
|
||||||
|
conversionOfSm: (fields[9] as num?)?.toDouble(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void write(BinaryWriter writer, CartItemModel obj) {
|
void write(BinaryWriter writer, CartItemModel obj) {
|
||||||
writer
|
writer
|
||||||
..writeByte(7)
|
..writeByte(10)
|
||||||
..writeByte(0)
|
..writeByte(0)
|
||||||
..write(obj.cartItemId)
|
..write(obj.cartItemId)
|
||||||
..writeByte(1)
|
..writeByte(1)
|
||||||
@@ -44,7 +47,13 @@ class CartItemModelAdapter extends TypeAdapter<CartItemModel> {
|
|||||||
..writeByte(5)
|
..writeByte(5)
|
||||||
..write(obj.subtotal)
|
..write(obj.subtotal)
|
||||||
..writeByte(6)
|
..writeByte(6)
|
||||||
..write(obj.addedAt);
|
..write(obj.addedAt)
|
||||||
|
..writeByte(7)
|
||||||
|
..write(obj.itemName)
|
||||||
|
..writeByte(8)
|
||||||
|
..write(obj.image)
|
||||||
|
..writeByte(9)
|
||||||
|
..write(obj.conversionOfSm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ class CartRepositoryImpl implements CartRepository {
|
|||||||
final CartLocalDataSource _localDataSource;
|
final CartLocalDataSource _localDataSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CartItem>> addToCart({
|
Future<bool> addToCart({
|
||||||
required List<String> itemIds,
|
required List<String> itemIds,
|
||||||
required List<double> quantities,
|
required List<double> quantities,
|
||||||
required List<double> prices,
|
required List<double> prices,
|
||||||
|
List<double?>? conversionFactors,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Validate input
|
// Validate input
|
||||||
@@ -48,40 +49,52 @@ class CartRepositoryImpl implements CartRepository {
|
|||||||
// Build API request items
|
// Build API request items
|
||||||
final items = <Map<String, dynamic>>[];
|
final items = <Map<String, dynamic>>[];
|
||||||
for (int i = 0; i < itemIds.length; i++) {
|
for (int i = 0; i < itemIds.length; i++) {
|
||||||
items.add({
|
final item = <String, dynamic>{
|
||||||
'item_id': itemIds[i],
|
'item_id': itemIds[i],
|
||||||
'quantity': quantities[i],
|
'quantity': quantities[i],
|
||||||
'amount': prices[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 API first
|
||||||
try {
|
try {
|
||||||
final cartItemModels = await _remoteDataSource.addToCart(items: items);
|
final success = await _remoteDataSource.addToCart(items: items);
|
||||||
|
|
||||||
// Sync to local storage
|
// Also save to local storage for offline access
|
||||||
await _localDataSource.saveCartItems(cartItemModels);
|
if (success) {
|
||||||
|
|
||||||
// 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
|
|
||||||
for (int i = 0; i < itemIds.length; i++) {
|
for (int i = 0; i < itemIds.length; i++) {
|
||||||
final cartItemModel = _createCartItemModel(
|
final cartItemModel = _createCartItemModel(
|
||||||
productId: itemIds[i],
|
productId: itemIds[i],
|
||||||
quantity: quantities[i],
|
quantity: quantities[i],
|
||||||
unitPrice: prices[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);
|
await _localDataSource.addCartItem(cartItemModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Queue for sync when online
|
// TODO: Queue for sync when online
|
||||||
|
|
||||||
// Return local cart items
|
return true;
|
||||||
final localItems = await _localDataSource.getCartItems();
|
|
||||||
return localItems.map(_modelToEntity).toList();
|
|
||||||
}
|
}
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
@@ -167,10 +180,11 @@ class CartRepositoryImpl implements CartRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<CartItem>> updateQuantity({
|
Future<bool> updateQuantity({
|
||||||
required String itemId,
|
required String itemId,
|
||||||
required double quantity,
|
required double quantity,
|
||||||
required double price,
|
required double price,
|
||||||
|
double? conversionFactor,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// API doesn't have update endpoint, use add with new quantity
|
// API doesn't have update endpoint, use add with new quantity
|
||||||
@@ -179,6 +193,7 @@ class CartRepositoryImpl implements CartRepository {
|
|||||||
itemIds: [itemId],
|
itemIds: [itemId],
|
||||||
quantities: [quantity],
|
quantities: [quantity],
|
||||||
prices: [price],
|
prices: [price],
|
||||||
|
conversionFactors: conversionFactor != null ? [conversionFactor] : null,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw UnknownException('Failed to update cart item quantity', e);
|
throw UnknownException('Failed to update cart item quantity', e);
|
||||||
@@ -263,6 +278,9 @@ class CartRepositoryImpl implements CartRepository {
|
|||||||
unitPrice: model.unitPrice,
|
unitPrice: model.unitPrice,
|
||||||
subtotal: model.subtotal,
|
subtotal: model.subtotal,
|
||||||
addedAt: model.addedAt,
|
addedAt: model.addedAt,
|
||||||
|
itemName: model.itemName,
|
||||||
|
image: model.image,
|
||||||
|
conversionOfSm: model.conversionOfSm,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +289,7 @@ class CartRepositoryImpl implements CartRepository {
|
|||||||
required String productId,
|
required String productId,
|
||||||
required double quantity,
|
required double quantity,
|
||||||
required double unitPrice,
|
required double unitPrice,
|
||||||
|
double? conversionOfSm,
|
||||||
}) {
|
}) {
|
||||||
return CartItemModel(
|
return CartItemModel(
|
||||||
cartItemId: DateTime.now().millisecondsSinceEpoch.toString(),
|
cartItemId: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
@@ -280,6 +299,7 @@ class CartRepositoryImpl implements CartRepository {
|
|||||||
unitPrice: unitPrice,
|
unitPrice: unitPrice,
|
||||||
subtotal: quantity * unitPrice,
|
subtotal: quantity * unitPrice,
|
||||||
addedAt: DateTime.now(),
|
addedAt: DateTime.now(),
|
||||||
|
conversionOfSm: conversionOfSm,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ library;
|
|||||||
/// Cart Item Entity
|
/// Cart Item Entity
|
||||||
///
|
///
|
||||||
/// Contains item-level information:
|
/// Contains item-level information:
|
||||||
/// - Product reference
|
/// - Product reference and basic info
|
||||||
/// - Quantity
|
/// - Quantity
|
||||||
/// - Pricing
|
/// - Pricing
|
||||||
class CartItem {
|
class CartItem {
|
||||||
@@ -31,6 +31,15 @@ class CartItem {
|
|||||||
/// Timestamp when item was added
|
/// Timestamp when item was added
|
||||||
final DateTime addedAt;
|
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({
|
const CartItem({
|
||||||
required this.cartItemId,
|
required this.cartItemId,
|
||||||
required this.cartId,
|
required this.cartId,
|
||||||
@@ -39,6 +48,9 @@ class CartItem {
|
|||||||
required this.unitPrice,
|
required this.unitPrice,
|
||||||
required this.subtotal,
|
required this.subtotal,
|
||||||
required this.addedAt,
|
required this.addedAt,
|
||||||
|
this.itemName,
|
||||||
|
this.image,
|
||||||
|
this.conversionOfSm,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Calculate subtotal (for verification)
|
/// Calculate subtotal (for verification)
|
||||||
@@ -53,6 +65,9 @@ class CartItem {
|
|||||||
double? unitPrice,
|
double? unitPrice,
|
||||||
double? subtotal,
|
double? subtotal,
|
||||||
DateTime? addedAt,
|
DateTime? addedAt,
|
||||||
|
String? itemName,
|
||||||
|
String? image,
|
||||||
|
double? conversionOfSm,
|
||||||
}) {
|
}) {
|
||||||
return CartItem(
|
return CartItem(
|
||||||
cartItemId: cartItemId ?? this.cartItemId,
|
cartItemId: cartItemId ?? this.cartItemId,
|
||||||
@@ -62,6 +77,9 @@ class CartItem {
|
|||||||
unitPrice: unitPrice ?? this.unitPrice,
|
unitPrice: unitPrice ?? this.unitPrice,
|
||||||
subtotal: subtotal ?? this.subtotal,
|
subtotal: subtotal ?? this.subtotal,
|
||||||
addedAt: addedAt ?? this.addedAt,
|
addedAt: addedAt ?? this.addedAt,
|
||||||
|
itemName: itemName ?? this.itemName,
|
||||||
|
image: image ?? this.image,
|
||||||
|
conversionOfSm: conversionOfSm ?? this.conversionOfSm,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,17 +22,18 @@ import 'package:worker/features/cart/domain/entities/cart_item.dart';
|
|||||||
abstract class CartRepository {
|
abstract class CartRepository {
|
||||||
/// Add items to cart
|
/// Add items to cart
|
||||||
///
|
///
|
||||||
/// [items] - List of cart items to add
|
|
||||||
/// [itemIds] - Product ERPNext item codes
|
/// [itemIds] - Product ERPNext item codes
|
||||||
/// [quantities] - Quantities for each item
|
/// [quantities] - Quantities for each item
|
||||||
/// [prices] - Unit prices 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.
|
/// Throws exceptions on failure.
|
||||||
Future<List<CartItem>> addToCart({
|
Future<bool> addToCart({
|
||||||
required List<String> itemIds,
|
required List<String> itemIds,
|
||||||
required List<double> quantities,
|
required List<double> quantities,
|
||||||
required List<double> prices,
|
required List<double> prices,
|
||||||
|
List<double?>? conversionFactors,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Remove items from cart
|
/// Remove items from cart
|
||||||
@@ -56,13 +57,15 @@ abstract class CartRepository {
|
|||||||
/// [itemId] - Product ERPNext item code
|
/// [itemId] - Product ERPNext item code
|
||||||
/// [quantity] - New quantity
|
/// [quantity] - New quantity
|
||||||
/// [price] - Unit price
|
/// [price] - Unit price
|
||||||
|
/// [conversionFactor] - Conversion factor (m² to tiles)
|
||||||
///
|
///
|
||||||
/// Returns updated cart item list.
|
/// Returns true if successful.
|
||||||
/// Throws exceptions on failure.
|
/// Throws exceptions on failure.
|
||||||
Future<List<CartItem>> updateQuantity({
|
Future<bool> updateQuantity({
|
||||||
required String itemId,
|
required String itemId,
|
||||||
required double quantity,
|
required double quantity,
|
||||||
required double price,
|
required double price,
|
||||||
|
double? conversionFactor,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Clear all items from cart
|
/// Clear all items from cart
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
/// Shopping cart screen with selection and checkout.
|
/// Shopping cart screen with selection and checkout.
|
||||||
/// Features expanded item list with total price at bottom.
|
/// Features expanded item list with total price at bottom.
|
||||||
library;
|
library;
|
||||||
|
import 'package:worker/core/utils/extensions.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.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/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/router/app_router.dart';
|
import 'package:worker/core/router/app_router.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
@@ -34,14 +35,8 @@ class CartPage extends ConsumerStatefulWidget {
|
|||||||
class _CartPageState extends ConsumerState<CartPage> {
|
class _CartPageState extends ConsumerState<CartPage> {
|
||||||
bool _isSyncing = false;
|
bool _isSyncing = false;
|
||||||
|
|
||||||
@override
|
// Cart is initialized once in home_page.dart at app startup
|
||||||
void initState() {
|
// Provider has keepAlive: true, so no need to reload here
|
||||||
super.initState();
|
|
||||||
// Initialize cart from API on mount
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
ref.read(cartProvider.notifier).initialize();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: Sync is handled in PopScope.onPopInvokedWithResult for back navigation
|
// Note: Sync is handled in PopScope.onPopInvokedWithResult for back navigation
|
||||||
// and in checkout button handler for checkout flow.
|
// and in checkout button handler for checkout flow.
|
||||||
@@ -49,13 +44,10 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final cartState = ref.watch(cartProvider);
|
final cartState = ref.watch(cartProvider);
|
||||||
|
|
||||||
final currencyFormatter = NumberFormat.currency(
|
|
||||||
locale: 'vi_VN',
|
|
||||||
symbol: 'đ',
|
|
||||||
decimalDigits: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
final itemCount = cartState.itemCount;
|
final itemCount = cartState.itemCount;
|
||||||
final hasSelection = cartState.selectedCount > 0;
|
final hasSelection = cartState.selectedCount > 0;
|
||||||
@@ -69,26 +61,26 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
icon: FaIcon(FontAwesomeIcons.arrowLeft, color: colorScheme.onSurface, size: 20),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Giỏ hàng ($itemCount)',
|
'Giỏ hàng ($itemCount)',
|
||||||
style: const TextStyle(color: Colors.black),
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
elevation: AppBarSpecs.elevation,
|
elevation: AppBarSpecs.elevation,
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
foregroundColor: AppColors.grey900,
|
foregroundColor: colorScheme.onSurface,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
if (cartState.isNotEmpty)
|
if (cartState.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
FontAwesomeIcons.trashCan,
|
FontAwesomeIcons.trashCan,
|
||||||
color: hasSelection ? AppColors.danger : AppColors.grey500,
|
color: hasSelection ? AppColors.danger : colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
onPressed: hasSelection
|
onPressed: hasSelection
|
||||||
? () {
|
? () {
|
||||||
@@ -101,7 +93,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: cartState.isLoading && cartState.isEmpty
|
body: cartState.isLoading && cartState.isEmpty
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const CustomLoadingIndicator()
|
||||||
: cartState.errorMessage != null && cartState.isEmpty
|
: cartState.errorMessage != null && cartState.isEmpty
|
||||||
? _buildErrorState(context, cartState.errorMessage!)
|
? _buildErrorState(context, cartState.errorMessage!)
|
||||||
: cartState.isEmpty
|
: cartState.isEmpty
|
||||||
@@ -130,10 +122,8 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
// Loading overlay
|
// Loading overlay
|
||||||
if (cartState.isLoading)
|
if (cartState.isLoading)
|
||||||
Container(
|
Container(
|
||||||
color: Colors.black.withValues(alpha: 0.1),
|
color: colorScheme.onSurface.withValues(alpha: 0.1),
|
||||||
child: const Center(
|
child: const CustomLoadingIndicator(),
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -144,7 +134,11 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
context,
|
context,
|
||||||
cartState,
|
cartState,
|
||||||
ref,
|
ref,
|
||||||
currencyFormatter,
|
NumberFormat.currency(
|
||||||
|
locale: 'vi_VN',
|
||||||
|
symbol: 'đ',
|
||||||
|
decimalDigits: 0,
|
||||||
|
),
|
||||||
hasSelection,
|
hasSelection,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -155,15 +149,17 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
|
|
||||||
/// Build select all section
|
/// Build select all section
|
||||||
Widget _buildSelectAllSection(CartState cartState, WidgetRef ref) {
|
Widget _buildSelectAllSection(CartState cartState, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.onSurface.withValues(alpha: 0.05),
|
||||||
blurRadius: 4,
|
blurRadius: 4,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -200,7 +196,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
Text(
|
Text(
|
||||||
'Đã chọn: ${cartState.selectedCount}/${cartState.itemCount}',
|
'Đã chọn: ${cartState.selectedCount}/${cartState.itemCount}',
|
||||||
style: AppTypography.bodyMedium.copyWith(
|
style: AppTypography.bodyMedium.copyWith(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
@@ -218,15 +214,17 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
NumberFormat currencyFormatter,
|
NumberFormat currencyFormatter,
|
||||||
bool hasSelection,
|
bool hasSelection,
|
||||||
) {
|
) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
border: const Border(
|
border: Border(
|
||||||
top: BorderSide(color: Color(0xFFF0F0F0), width: 2),
|
top: BorderSide(color: colorScheme.outlineVariant, width: 2),
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.08),
|
color: colorScheme.onSurface.withValues(alpha: 0.08),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, -2),
|
offset: const Offset(0, -2),
|
||||||
),
|
),
|
||||||
@@ -245,14 +243,14 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
Text(
|
Text(
|
||||||
'Tổng tạm tính (${cartState.selectedCount} sản phẩm)',
|
'Tổng tạm tính (${cartState.selectedCount} sản phẩm)',
|
||||||
style: AppTypography.bodyMedium.copyWith(
|
style: AppTypography.bodyMedium.copyWith(
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
currencyFormatter.format(cartState.selectedTotal),
|
currencyFormatter.format(cartState.selectedTotal),
|
||||||
style: AppTypography.headlineSmall.copyWith(
|
style: AppTypography.headlineSmall.copyWith(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
),
|
),
|
||||||
@@ -302,27 +300,22 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
disabledBackgroundColor: AppColors.grey100,
|
disabledBackgroundColor: colorScheme.inverseSurface.withValues(alpha: 0.6),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
child: _isSyncing
|
child: _isSyncing
|
||||||
? const SizedBox(
|
? CustomLoadingIndicator(
|
||||||
width: 20,
|
color: colorScheme.surface,
|
||||||
height: 20,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor:
|
|
||||||
AlwaysStoppedAnimation<Color>(AppColors.white),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
'Tiến hành đặt hàng',
|
'Tiến hành đặt hàng',
|
||||||
style: AppTypography.labelLarge.copyWith(
|
style: AppTypography.labelLarge.copyWith(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
@@ -359,6 +352,8 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
|
|
||||||
/// Build error state (shown when cart fails to load and is empty)
|
/// Build error state (shown when cart fails to load and is empty)
|
||||||
Widget _buildErrorState(BuildContext context, String errorMessage) {
|
Widget _buildErrorState(BuildContext context, String errorMessage) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -374,7 +369,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
errorMessage,
|
errorMessage,
|
||||||
style: AppTypography.bodyMedium.copyWith(color: AppColors.grey500),
|
style: AppTypography.bodyMedium.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -393,6 +388,8 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
|
|
||||||
/// Build empty cart state
|
/// Build empty cart state
|
||||||
Widget _buildEmptyCart(BuildContext context) {
|
Widget _buildEmptyCart(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -400,23 +397,23 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
Icon(
|
Icon(
|
||||||
FontAwesomeIcons.cartShopping,
|
FontAwesomeIcons.cartShopping,
|
||||||
size: 80,
|
size: 80,
|
||||||
color: AppColors.grey500.withValues(alpha: 0.5),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Giỏ hàng trống',
|
'Giỏ hàng trống',
|
||||||
style: AppTypography.headlineMedium.copyWith(
|
style: AppTypography.headlineMedium.copyWith(
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Hãy thêm sản phẩm vào giỏ hàng',
|
'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),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => context.go(RouteNames.products),
|
onPressed: () => context.push(RouteNames.products),
|
||||||
icon: const FaIcon(FontAwesomeIcons.bagShopping, size: 20),
|
icon: const FaIcon(FontAwesomeIcons.bagShopping, size: 20),
|
||||||
label: const Text('Xem sản phẩm'),
|
label: const Text('Xem sản phẩm'),
|
||||||
),
|
),
|
||||||
@@ -475,24 +472,26 @@ class _CustomCheckbox extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => onChanged?.call(!value),
|
onTap: () => onChanged?.call(!value),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: value ? AppColors.primaryBlue : AppColors.white,
|
color: value ? colorScheme.primary : colorScheme.surface,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: value ? AppColors.primaryBlue : const Color(0xFFCBD5E1),
|
color: value ? colorScheme.primary : colorScheme.outlineVariant,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: value
|
child: value
|
||||||
? const Icon(
|
? Icon(
|
||||||
FontAwesomeIcons.check,
|
FontAwesomeIcons.check,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
@@ -42,6 +43,8 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Form key for validation
|
// Form key for validation
|
||||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||||
|
|
||||||
@@ -102,22 +105,22 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
final total = subtotal - memberDiscount + shipping;
|
final total = subtotal - memberDiscount + shipping;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(
|
icon: FaIcon(
|
||||||
FontAwesomeIcons.arrowLeft,
|
FontAwesomeIcons.arrowLeft,
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: Text(
|
||||||
'Thanh toán',
|
'Thanh toán',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -165,29 +168,27 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.onSurface.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: const CustomLoadingIndicator(),
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
error: (error, stack) => Container(
|
error: (error, stack) => Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.onSurface.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -203,9 +204,9 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Không thể tải phương thức thanh toán',
|
'Không thể tải phương thức thanh toán',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -225,7 +226,7 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Discount Code Section
|
// Discount Code Section
|
||||||
_buildDiscountCodeSection(),
|
_buildDiscountCodeSection(context),
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
@@ -263,13 +264,13 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
activeColor: AppColors.warning,
|
activeColor: AppColors.warning,
|
||||||
),
|
),
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Yêu cầu hợp đồng',
|
'Yêu cầu hợp đồng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -281,20 +282,20 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Terms and Conditions
|
// Terms and Conditions
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
child: Text.rich(
|
child: Text.rich(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: 'Bằng cách đặt hàng, bạn đồng ý với ',
|
text: 'Bằng cách đặt hàng, bạn đồng ý với ',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Color(0xFF6B7280),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: 'Điều khoản & Điều kiện',
|
text: 'Điều khoản & Điều kiện',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -328,16 +329,18 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build Discount Code Section (Card 4 from HTML)
|
/// Build Discount Code Section (Card 4 from HTML)
|
||||||
Widget _buildDiscountCodeSection() {
|
Widget _buildDiscountCodeSection(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
color: colorScheme.onSurface.withValues(alpha: 0.05),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
@@ -351,16 +354,16 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
FontAwesomeIcons.ticket,
|
FontAwesomeIcons.ticket,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text(
|
Text(
|
||||||
'Mã giảm giá',
|
'Mã giảm giá',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -377,16 +380,16 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
hintText: 'Nhập mã giảm giá',
|
hintText: 'Nhập mã giảm giá',
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D5DB)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: const BorderSide(color: Color(0xFFD1D5DB)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -403,7 +406,7 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
// TODO: Apply discount code
|
// TODO: Apply discount code
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
@@ -413,10 +416,10 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Áp dụng',
|
'Áp dụng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: colorScheme.onPrimary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -436,18 +439,18 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
const Icon(
|
||||||
FontAwesomeIcons.circleCheck,
|
FontAwesomeIcons.circleCheck,
|
||||||
color: AppColors.success,
|
color: AppColors.success,
|
||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Bạn được giảm 15% (hạng Diamond)',
|
'Bạn được giảm 15% (hạng Diamond)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Color(0xFF166534),
|
color: const Color(0xFF166534),
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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_data_providers.dart';
|
||||||
import 'package:worker/features/cart/presentation/providers/cart_state.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/domain/entities/product.dart';
|
||||||
import 'package:worker/features/products/presentation/providers/products_provider.dart';
|
|
||||||
|
|
||||||
part 'cart_provider.g.dart';
|
part 'cart_provider.g.dart';
|
||||||
|
|
||||||
@@ -46,8 +45,12 @@ class Cart extends _$Cart {
|
|||||||
|
|
||||||
/// Initialize cart by loading from API
|
/// 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 {
|
Future<void> initialize() async {
|
||||||
|
// Skip if already loaded
|
||||||
|
if (state.items.isNotEmpty) return;
|
||||||
|
|
||||||
final repository = await ref.read(cartRepositoryProvider.future);
|
final repository = await ref.read(cartRepositoryProvider.future);
|
||||||
|
|
||||||
// Set loading state
|
// Set loading state
|
||||||
@@ -55,6 +58,7 @@ class Cart extends _$Cart {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Load cart items from API (with Hive fallback)
|
// 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();
|
final cartItems = await repository.getCartItems();
|
||||||
|
|
||||||
// Get member tier from user profile
|
// Get member tier from user profile
|
||||||
@@ -63,41 +67,47 @@ class Cart extends _$Cart {
|
|||||||
const memberDiscountPercent = 15.0;
|
const memberDiscountPercent = 15.0;
|
||||||
|
|
||||||
// Convert CartItem entities to CartItemData for UI
|
// Convert CartItem entities to CartItemData for UI
|
||||||
|
// Use product data from cart API directly - no need to fetch each product
|
||||||
final items = <CartItemData>[];
|
final items = <CartItemData>[];
|
||||||
final selectedItems = <String, bool>{};
|
final selectedItems = <String, bool>{};
|
||||||
|
|
||||||
// Fetch product details for each cart item
|
|
||||||
final productsRepository = await ref.read(productsRepositoryProvider.future);
|
|
||||||
|
|
||||||
for (final cartItem in cartItems) {
|
for (final cartItem in cartItems) {
|
||||||
try {
|
// Create minimal Product from cart item data (no need to fetch from API)
|
||||||
// Fetch full product entity from products repository
|
final now = DateTime.now();
|
||||||
final product = await productsRepository.getProductById(cartItem.productId);
|
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
|
// Calculate conversion for this item
|
||||||
final converted = _calculateConversion(
|
final converted = _calculateConversion(
|
||||||
cartItem.quantity,
|
cartItem.quantity,
|
||||||
product.conversionOfSm,
|
product.conversionOfSm,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create CartItemData with full product info
|
// Create CartItemData with product info from cart API
|
||||||
items.add(
|
items.add(
|
||||||
CartItemData(
|
CartItemData(
|
||||||
product: product,
|
product: product,
|
||||||
quantity: cartItem.quantity,
|
quantity: cartItem.quantity,
|
||||||
quantityConverted: converted.convertedQuantity,
|
quantityConverted: converted.convertedQuantity,
|
||||||
boxes: converted.boxes,
|
boxes: converted.boxes,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize as not selected by default
|
// Initialize as not selected by default
|
||||||
selectedItems[product.productId] = false;
|
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final newState = CartState(
|
final newState = CartState(
|
||||||
@@ -150,6 +160,7 @@ class Cart extends _$Cart {
|
|||||||
itemIds: [product.erpnextItemCode ?? product.productId],
|
itemIds: [product.erpnextItemCode ?? product.productId],
|
||||||
quantities: [quantity],
|
quantities: [quantity],
|
||||||
prices: [product.basePrice],
|
prices: [product.basePrice],
|
||||||
|
conversionFactors: [product.conversionOfSm],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Calculate conversion
|
// Calculate conversion
|
||||||
@@ -332,6 +343,7 @@ class Cart extends _$Cart {
|
|||||||
itemId: item.product.erpnextItemCode ?? productId,
|
itemId: item.product.erpnextItemCode ?? productId,
|
||||||
quantity: quantity,
|
quantity: quantity,
|
||||||
price: item.product.basePrice,
|
price: item.product.basePrice,
|
||||||
|
conversionFactor: item.product.conversionOfSm,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silent fail - keep local state, user can retry later
|
// Silent fail - keep local state, user can retry later
|
||||||
@@ -370,6 +382,7 @@ class Cart extends _$Cart {
|
|||||||
itemId: item.product.erpnextItemCode ?? productId,
|
itemId: item.product.erpnextItemCode ?? productId,
|
||||||
quantity: newQuantity,
|
quantity: newQuantity,
|
||||||
price: item.product.basePrice,
|
price: item.product.basePrice,
|
||||||
|
conversionFactor: item.product.conversionOfSm,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ final class CartProvider extends $NotifierProvider<Cart, CartState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$cartHash() => r'706de28734e7059b2e9484f3b1d94226a0e90bb9';
|
String _$cartHash() => r'a12712db833127ef66b75f52cba8376409806afa';
|
||||||
|
|
||||||
/// Cart Notifier
|
/// Cart Notifier
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:intl/intl.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/core/theme/typography.dart';
|
||||||
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
|
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
|
||||||
import 'package:worker/features/cart/presentation/providers/cart_state.dart';
|
import 'package:worker/features/cart/presentation/providers/cart_state.dart';
|
||||||
@@ -74,21 +75,17 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final cartState = ref.watch(cartProvider);
|
final cartState = ref.watch(cartProvider);
|
||||||
final isSelected =
|
final isSelected =
|
||||||
cartState.selectedItems[widget.item.product.productId] ?? false;
|
cartState.selectedItems[widget.item.product.productId] ?? false;
|
||||||
|
|
||||||
final currencyFormatter = NumberFormat.currency(
|
|
||||||
locale: 'vi_VN',
|
|
||||||
symbol: 'đ',
|
|
||||||
decimalDigits: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -120,25 +117,29 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: CachedNetworkImage(
|
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,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CustomLoadingIndicator(color: colorScheme.primary, size: 20),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: const FaIcon(
|
child: FaIcon(
|
||||||
FontAwesomeIcons.image,
|
FontAwesomeIcons.image,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -167,9 +168,9 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
|
|
||||||
// Price
|
// Price
|
||||||
Text(
|
Text(
|
||||||
'${currencyFormatter.format(widget.item.product.basePrice)}/m²',
|
'${widget.item.product.basePrice.toVNCurrency}/m²',
|
||||||
style: AppTypography.titleMedium.copyWith(
|
style: AppTypography.titleMedium.copyWith(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
@@ -209,22 +210,22 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Color(0xFFE0E0E0),
|
color: colorScheme.outlineVariant,
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Color(0xFFE0E0E0),
|
color: colorScheme.outlineVariant,
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -254,7 +255,7 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
Text(
|
Text(
|
||||||
'm²',
|
'm²',
|
||||||
style: AppTypography.bodySmall.copyWith(
|
style: AppTypography.bodySmall.copyWith(
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -266,7 +267,7 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
RichText(
|
RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
style: AppTypography.bodySmall.copyWith(
|
style: AppTypography.bodySmall.copyWith(
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
@@ -305,24 +306,25 @@ class _CustomCheckbox extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => onChanged?.call(!value),
|
onTap: () => onChanged?.call(!value),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: value ? AppColors.primaryBlue : AppColors.white,
|
color: value ? colorScheme.primary : colorScheme.surface,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: value ? AppColors.primaryBlue : const Color(0xFFCBD5E1),
|
color: value ? colorScheme.primary : colorScheme.outlineVariant,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: value
|
child: value
|
||||||
? const Icon(
|
? Icon(
|
||||||
FontAwesomeIcons.check,
|
FontAwesomeIcons.check,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@@ -341,6 +343,7 @@ class _QuantityButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onPressed,
|
onTap: onPressed,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
@@ -348,11 +351,11 @@ class _QuantityButton extends StatelessWidget {
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: const Color(0xFFE0E0E0), width: 2),
|
border: Border.all(color: colorScheme.outlineVariant, width: 2),
|
||||||
borderRadius: BorderRadius.circular(6),
|
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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
|
||||||
|
|
||||||
/// Checkout Date Picker Field
|
/// Checkout Date Picker Field
|
||||||
///
|
///
|
||||||
@@ -24,15 +23,17 @@ class CheckoutDatePickerField extends HookWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Ngày nhận hàng mong muốn',
|
'Ngày nhận hàng mong muốn',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -51,9 +52,9 @@ class CheckoutDatePickerField extends HookWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: colorScheme.surfaceContainerLowest,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@@ -65,14 +66,14 @@ class CheckoutDatePickerField extends HookWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: selectedDate.value != null
|
color: selectedDate.value != null
|
||||||
? const Color(0xFF212121)
|
? colorScheme.onSurface
|
||||||
: AppColors.grey500.withValues(alpha: 0.6),
|
: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Icon(
|
Icon(
|
||||||
FontAwesomeIcons.calendar,
|
FontAwesomeIcons.calendar,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,16 +28,18 @@ class CheckoutDropdownField extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
RichText(
|
RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
text: label,
|
text: label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
if (required)
|
if (required)
|
||||||
@@ -53,23 +55,23 @@ class CheckoutDropdownField extends StatelessWidget {
|
|||||||
initialValue: value,
|
initialValue: value,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFFF8FAFC),
|
fillColor: colorScheme.surfaceContainerLowest,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/router/app_router.dart';
|
import 'package:worker/core/router/app_router.dart';
|
||||||
@@ -42,6 +43,8 @@ class CheckoutSubmitButton extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
@@ -66,8 +69,8 @@ class CheckoutSubmitButton extends HookConsumerWidget {
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: ignorePricingRule
|
backgroundColor: ignorePricingRule
|
||||||
? AppColors.warning
|
? AppColors.warning
|
||||||
: AppColors.primaryBlue,
|
: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.surface,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -91,8 +94,8 @@ class CheckoutSubmitButton extends HookConsumerWidget {
|
|||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => const Center(
|
builder: (context) => Center(
|
||||||
child: CircularProgressIndicator(),
|
child: CustomLoadingIndicator(color: Theme.of(context).colorScheme.primary, size: 40),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -32,16 +32,18 @@ class CheckoutTextField extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
RichText(
|
RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
text: label,
|
text: label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF1E293B),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
if (required)
|
if (required)
|
||||||
@@ -61,27 +63,27 @@ class CheckoutTextField extends StatelessWidget {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: hintText ?? 'Nhập $label',
|
hintText: hintText ?? 'Nhập $label',
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(
|
||||||
color: AppColors.grey500.withValues(alpha: 0.6),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFFF8FAFC),
|
fillColor: colorScheme.surfaceContainerLowest,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.input),
|
borderRadius: BorderRadius.circular(AppRadius.input),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.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/domain/entities/address.dart';
|
||||||
import 'package:worker/features/account/presentation/providers/address_provider.dart';
|
import 'package:worker/features/account/presentation/providers/address_provider.dart';
|
||||||
import 'package:worker/features/cart/presentation/widgets/checkout_date_picker_field.dart';
|
import 'package:worker/features/cart/presentation/widgets/checkout_date_picker_field.dart';
|
||||||
@@ -33,6 +32,8 @@ class DeliveryInformationSection extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Watch the default address
|
// Watch the default address
|
||||||
final defaultAddr = ref.watch(defaultAddressProvider);
|
final defaultAddr = ref.watch(defaultAddressProvider);
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ class DeliveryInformationSection extends HookConsumerWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -70,18 +71,18 @@ class DeliveryInformationSection extends HookConsumerWidget {
|
|||||||
// Section Title
|
// Section Title
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.truck,
|
FontAwesomeIcons.truck,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
const Text(
|
Text(
|
||||||
'Thông tin giao hàng',
|
'Thông tin giao hàng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -93,12 +94,12 @@ class DeliveryInformationSection extends HookConsumerWidget {
|
|||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Địa chỉ nhận hàng',
|
'Địa chỉ nhận hàng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF424242),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
@@ -125,7 +126,7 @@ class DeliveryInformationSection extends HookConsumerWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
border: Border.all(color: colorScheme.outline),
|
||||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -137,10 +138,10 @@ class DeliveryInformationSection extends HookConsumerWidget {
|
|||||||
// Name
|
// Name
|
||||||
Text(
|
Text(
|
||||||
selectedAddress.value!.addressTitle,
|
selectedAddress.value!.addressTitle,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -148,9 +149,9 @@ class DeliveryInformationSection extends HookConsumerWidget {
|
|||||||
// Phone
|
// Phone
|
||||||
Text(
|
Text(
|
||||||
selectedAddress.value!.phone,
|
selectedAddress.value!.phone,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF757575),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -158,19 +159,19 @@ class DeliveryInformationSection extends HookConsumerWidget {
|
|||||||
// Address
|
// Address
|
||||||
Text(
|
Text(
|
||||||
selectedAddress.value!.fullAddress,
|
selectedAddress.value!.fullAddress,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF757575),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
const FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.chevronRight,
|
FontAwesomeIcons.chevronRight,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: Color(0xFF9E9E9E),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -194,26 +195,26 @@ class DeliveryInformationSection extends HookConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
style: BorderStyle.solid,
|
style: BorderStyle.solid,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.plus,
|
FontAwesomeIcons.plus,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
Text(
|
Text(
|
||||||
'Thêm địa chỉ giao hàng',
|
'Thêm địa chỉ giao hàng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.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';
|
import 'package:worker/features/account/presentation/providers/address_provider.dart';
|
||||||
|
|
||||||
/// Invoice Section
|
/// Invoice Section
|
||||||
@@ -22,6 +21,8 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Watch the default address
|
// Watch the default address
|
||||||
final defaultAddr = ref.watch(defaultAddressProvider);
|
final defaultAddr = ref.watch(defaultAddressProvider);
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -45,19 +46,19 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
// Header with Toggle
|
// Header with Toggle
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.fileInvoice,
|
FontAwesomeIcons.fileInvoice,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
size: 16,
|
size: 16,
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Phát hành hóa đơn',
|
'Phát hành hóa đơn',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -67,7 +68,7 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
needsInvoice.value = 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)
|
// Invoice Information (visible when toggle is ON)
|
||||||
if (needsInvoice.value) ...[
|
if (needsInvoice.value) ...[
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
const Divider(color: Color(0xFFE0E0E0)),
|
Divider(color: colorScheme.outlineVariant),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Address Card
|
// Address Card
|
||||||
@@ -89,7 +90,7 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -101,10 +102,10 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
// Company/Address Title
|
// Company/Address Title
|
||||||
Text(
|
Text(
|
||||||
defaultAddr.addressTitle,
|
defaultAddr.addressTitle,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -114,9 +115,9 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
defaultAddr.taxCode!.isNotEmpty) ...[
|
defaultAddr.taxCode!.isNotEmpty) ...[
|
||||||
Text(
|
Text(
|
||||||
'Mã số thuế: ${defaultAddr.taxCode}',
|
'Mã số thuế: ${defaultAddr.taxCode}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF757575),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -125,9 +126,9 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
// Phone
|
// Phone
|
||||||
Text(
|
Text(
|
||||||
'Số điện thoại: ${defaultAddr.phone}',
|
'Số điện thoại: ${defaultAddr.phone}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF757575),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -137,9 +138,9 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
defaultAddr.email!.isNotEmpty) ...[
|
defaultAddr.email!.isNotEmpty) ...[
|
||||||
Text(
|
Text(
|
||||||
'Email: ${defaultAddr.email}',
|
'Email: ${defaultAddr.email}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF757575),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -148,19 +149,19 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
// Address
|
// Address
|
||||||
Text(
|
Text(
|
||||||
'Địa chỉ: ${defaultAddr.fullAddress}',
|
'Địa chỉ: ${defaultAddr.fullAddress}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Color(0xFF757575),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
const FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.chevronRight,
|
FontAwesomeIcons.chevronRight,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: Color(0xFF9E9E9E),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -177,26 +178,26 @@ class InvoiceSection extends HookConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
style: BorderStyle.solid,
|
style: BorderStyle.solid,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.plus,
|
FontAwesomeIcons.plus,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
Text(
|
Text(
|
||||||
'Thêm địa chỉ xuất hóa đơn',
|
'Thêm địa chỉ xuất hóa đơn',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ class OrderSummarySection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -47,32 +49,32 @@ class OrderSummarySection extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Section Title
|
// Section Title
|
||||||
const Text(
|
Text(
|
||||||
'Tóm tắt đơn hàng',
|
'Tóm tắt đơn hàng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
|
||||||
// Cart Items with conversion details
|
// Cart Items with conversion details
|
||||||
...cartItems.map((item) => _buildCartItemWithConversion(item)),
|
...cartItems.map((item) => _buildCartItemWithConversion(context, item)),
|
||||||
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
|
|
||||||
// Subtotal
|
// Subtotal
|
||||||
_buildSummaryRow('Tạm tính', subtotal),
|
_buildSummaryRow(context, 'Tạm tính', subtotal),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Member Tier Discount (Diamond 15%)
|
// 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),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Shipping
|
// 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),
|
const Divider(height: 24),
|
||||||
|
|
||||||
@@ -80,20 +82,20 @@ class OrderSummarySection extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Text(
|
||||||
'Tổng thanh toán',
|
'Tổng thanh toán',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
_formatCurrency(total),
|
_formatCurrency(total),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
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
|
/// 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
|
// Get real conversion data from CartItemData
|
||||||
final quantity = item['quantity'] as double;
|
final quantity = item['quantity'] as double;
|
||||||
final quantityConverted = item['quantityConverted'] as double;
|
final quantityConverted = item['quantityConverted'] as double;
|
||||||
@@ -125,10 +129,10 @@ class OrderSummarySection extends StatelessWidget {
|
|||||||
// Line 1: Product name
|
// Line 1: Product name
|
||||||
Text(
|
Text(
|
||||||
item['name'] as String,
|
item['name'] as String,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -137,9 +141,9 @@ class OrderSummarySection extends StatelessWidget {
|
|||||||
// Line 2: Conversion details (muted text)
|
// Line 2: Conversion details (muted text)
|
||||||
Text(
|
Text(
|
||||||
'${quantity.toStringAsFixed(2)} m² ($boxes viên / ${quantityConverted.toStringAsFixed(2)} m²)',
|
'${quantity.toStringAsFixed(2)} m² ($boxes viên / ${quantityConverted.toStringAsFixed(2)} m²)',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
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
|
// Price (right side) - using converted quantity for accurate billing
|
||||||
Text(
|
Text(
|
||||||
_formatCurrency(price * quantityConverted),
|
_formatCurrency(price * quantityConverted),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -164,24 +168,27 @@ class OrderSummarySection extends StatelessWidget {
|
|||||||
|
|
||||||
/// Build summary row
|
/// Build summary row
|
||||||
Widget _buildSummaryRow(
|
Widget _buildSummaryRow(
|
||||||
|
BuildContext context,
|
||||||
String label,
|
String label,
|
||||||
double amount, {
|
double amount, {
|
||||||
bool isDiscount = false,
|
bool isDiscount = false,
|
||||||
bool isFree = false,
|
bool isFree = false,
|
||||||
}) {
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(fontSize: 14, color: AppColors.grey500),
|
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
isFree ? 'Miễn phí' : _formatCurrency(amount),
|
isFree ? 'Miễn phí' : _formatCurrency(amount),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: isDiscount ? AppColors.success : const Color(0xFF212121),
|
color: isDiscount ? AppColors.success : colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.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';
|
import 'package:worker/features/orders/domain/entities/payment_term.dart';
|
||||||
|
|
||||||
/// Payment Method Section
|
/// Payment Method Section
|
||||||
@@ -25,13 +24,15 @@ class PaymentMethodSection extends HookWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Show empty state if no payment terms available
|
// Show empty state if no payment terms available
|
||||||
if (paymentTerms.isEmpty) {
|
if (paymentTerms.isEmpty) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -41,12 +42,12 @@ class PaymentMethodSection extends HookWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Không có phương thức thanh toán khả dụng',
|
'Không có phương thức thanh toán khả dụng',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -57,7 +58,7 @@ class PaymentMethodSection extends HookWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.card),
|
borderRadius: BorderRadius.circular(AppRadius.card),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -71,12 +72,12 @@ class PaymentMethodSection extends HookWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Section Title
|
// Section Title
|
||||||
const Text(
|
Text(
|
||||||
'Phương thức thanh toán',
|
'Phương thức thanh toán',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -109,12 +110,12 @@ class PaymentMethodSection extends HookWidget {
|
|||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
paymentMethod.value = value!;
|
paymentMethod.value = value!;
|
||||||
},
|
},
|
||||||
activeColor: AppColors.primaryBlue,
|
activeColor: colorScheme.primary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Icon(
|
Icon(
|
||||||
icon,
|
icon,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -132,9 +133,9 @@ class PaymentMethodSection extends HookWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
term.customDescription,
|
term.customDescription,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class PriceNegotiationSection extends HookWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||||
padding: const EdgeInsets.all(AppSpacing.md),
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
@@ -35,7 +37,7 @@ class PriceNegotiationSection extends HookWidget {
|
|||||||
},
|
},
|
||||||
activeColor: AppColors.warning,
|
activeColor: AppColors.warning,
|
||||||
),
|
),
|
||||||
const Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -44,13 +46,16 @@ class PriceNegotiationSection extends HookWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xFF212121),
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Gửi yêu cầu đàm phán giá cho đơn hàng này',
|
'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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ class FavoritesPage extends ConsumerWidget {
|
|||||||
return ProductCard(productId: productId);
|
return ProductCard(productId: productId);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
loading: () => CircularProgressIndicator(),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => Text('Error: $error'),
|
error: (error, stack) => Text('Error: $error'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class FavoritesPage extends ConsumerWidget {
|
|||||||
return ProductTile(productId: productId);
|
return ProductTile(productId: productId);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
loading: () => CircularProgressIndicator(),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => ErrorWidget(error),
|
error: (error, stack) => ErrorWidget(error),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')),
|
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: const CustomLoadingIndicator()),
|
||||||
error: (error, stack) => Center(
|
error: (error, stack) => Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -368,7 +368,7 @@ class FavoriteProductsList extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
loading: () => const CircularProgressIndicator(),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => Text('Error: $error'),
|
error: (error, stack) => Text('Error: $error'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -417,7 +417,7 @@ class FavoritesPageWithRefresh extends ConsumerWidget {
|
|||||||
return ListTile(title: Text('Product: $productId'));
|
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')),
|
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -466,7 +466,7 @@ class FavoriteButtonWithLoadingState extends ConsumerWidget {
|
|||||||
loading: () => const SizedBox(
|
loading: () => const SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CustomLoadingIndicator(strokeWidth: 2),
|
||||||
),
|
),
|
||||||
error: (error, stack) => IconButton(
|
error: (error, stack) => IconButton(
|
||||||
icon: const Icon(Icons.error, color: Colors.grey),
|
icon: const Icon(Icons.error, color: Colors.grey),
|
||||||
|
|||||||
@@ -60,6 +60,48 @@ class FavoriteProductsLocalDataSource {
|
|||||||
bool isBoxOpen() {
|
bool isBoxOpen() {
|
||||||
return Hive.isBoxOpen(HiveBoxNames.favoriteProductsBox);
|
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
|
/// Debug print helper
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -78,6 +79,8 @@ class FavoritesPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Search controller
|
// Search controller
|
||||||
final searchController = useTextEditingController();
|
final searchController = useTextEditingController();
|
||||||
final searchQuery = useState('');
|
final searchQuery = useState('');
|
||||||
@@ -104,20 +107,20 @@ class FavoritesPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const FaIcon(
|
icon: FaIcon(
|
||||||
FontAwesomeIcons.arrowLeft,
|
FontAwesomeIcons.arrowLeft,
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () => context.pop(),
|
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,
|
elevation: AppBarSpecs.elevation,
|
||||||
backgroundColor: AppColors.white,
|
backgroundColor: colorScheme.surface,
|
||||||
foregroundColor: AppColors.grey900,
|
foregroundColor: colorScheme.onSurface,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
actions: [
|
actions: [
|
||||||
// Count badge
|
// Count badge
|
||||||
@@ -127,10 +130,10 @@ class FavoritesPage extends HookConsumerWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
'($favoriteCount)',
|
'($favoriteCount)',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16.0,
|
fontSize: 16.0,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -139,9 +142,9 @@ class FavoritesPage extends HookConsumerWidget {
|
|||||||
// Clear all button
|
// Clear all button
|
||||||
if (favoriteCount > 0)
|
if (favoriteCount > 0)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const FaIcon(
|
icon: FaIcon(
|
||||||
FontAwesomeIcons.trashCan,
|
FontAwesomeIcons.trashCan,
|
||||||
color: Colors.black,
|
color: colorScheme.onSurface,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
tooltip: 'Xóa tất cả',
|
tooltip: 'Xóa tất cả',
|
||||||
@@ -177,16 +180,16 @@ class FavoritesPage extends HookConsumerWidget {
|
|||||||
onChanged: (value) => searchQuery.value = value,
|
onChanged: (value) => searchQuery.value = value,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Tìm kiếm sản phẩm...',
|
hintText: 'Tìm kiếm sản phẩm...',
|
||||||
hintStyle: const TextStyle(color: AppColors.grey500),
|
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
prefixIcon: const Icon(
|
prefixIcon: Icon(
|
||||||
Icons.search,
|
Icons.search,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
suffixIcon: searchQuery.value.isNotEmpty
|
suffixIcon: searchQuery.value.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: const Icon(
|
icon: Icon(
|
||||||
Icons.clear,
|
Icons.clear,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
searchController.clear();
|
searchController.clear();
|
||||||
@@ -195,19 +198,19 @@ class FavoritesPage extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: colorScheme.surface,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||||
borderSide: const BorderSide(color: AppColors.grey100),
|
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||||
borderSide: const BorderSide(color: AppColors.grey100),
|
borderSide: BorderSide(color: colorScheme.surfaceContainerHighest),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||||
borderSide: const BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -229,9 +232,9 @@ class FavoritesPage extends HookConsumerWidget {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text(
|
child: Text(
|
||||||
'Tìm thấy ${filteredProducts.length} sản phẩm',
|
'Tìm thấy ${filteredProducts.length} sản phẩm',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -245,26 +248,26 @@ class FavoritesPage extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.magnifyingGlass,
|
FontAwesomeIcons.magnifyingGlass,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.md),
|
const SizedBox(height: AppSpacing.md),
|
||||||
Text(
|
Text(
|
||||||
'Không tìm thấy "${searchQuery.value}"',
|
'Không tìm thấy "${searchQuery.value}"',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
const Text(
|
Text(
|
||||||
'Thử tìm kiếm với từ khóa khác',
|
'Thử tìm kiếm với từ khóa khác',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -300,14 +303,14 @@ class FavoritesPage extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
child: _FavoritesGrid(products: previousValue),
|
child: _FavoritesGrid(products: previousValue),
|
||||||
),
|
),
|
||||||
const Positioned(
|
Positioned(
|
||||||
top: 16,
|
top: 16,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Card(
|
child: Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16.0,
|
horizontal: 16.0,
|
||||||
vertical: 8.0,
|
vertical: 8.0,
|
||||||
),
|
),
|
||||||
@@ -317,12 +320,10 @@ class FavoritesPage extends HookConsumerWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(
|
child: CustomLoadingIndicator(color: colorScheme.primary, size: 20),
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('Đang tải...'),
|
const Text('Đang tải...'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -431,6 +432,8 @@ class _EmptyState extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||||
@@ -441,18 +444,18 @@ class _EmptyState extends StatelessWidget {
|
|||||||
FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.heart,
|
FontAwesomeIcons.heart,
|
||||||
size: 80.0,
|
size: 80.0,
|
||||||
color: AppColors.grey500.withValues(alpha: 0.5),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
// Heading
|
// Heading
|
||||||
const Text(
|
Text(
|
||||||
'Chưa có sản phẩm yêu thích',
|
'Chưa có sản phẩm yêu thích',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18.0,
|
fontSize: 18.0,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -460,9 +463,9 @@ class _EmptyState extends StatelessWidget {
|
|||||||
const SizedBox(height: AppSpacing.sm),
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
|
||||||
// Subtext
|
// Subtext
|
||||||
const Text(
|
Text(
|
||||||
'Thêm sản phẩm vào danh sách yêu thích để xem lại sau',
|
'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,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -475,8 +478,8 @@ class _EmptyState extends StatelessWidget {
|
|||||||
context.pushReplacement('/products');
|
context.pushReplacement('/products');
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: AppColors.white,
|
foregroundColor: colorScheme.surface,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: AppSpacing.xl,
|
horizontal: AppSpacing.xl,
|
||||||
vertical: AppSpacing.md,
|
vertical: AppSpacing.md,
|
||||||
@@ -527,23 +530,25 @@ class _LoadingState extends StatelessWidget {
|
|||||||
class _ShimmerCard extends StatelessWidget {
|
class _ShimmerCard extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: ProductCardSpecs.elevation,
|
elevation: ProductCardSpecs.elevation,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(ProductCardSpecs.borderRadius),
|
borderRadius: BorderRadius.circular(ProductCardSpecs.borderRadius),
|
||||||
),
|
),
|
||||||
child: Shimmer.fromColors(
|
child: Shimmer.fromColors(
|
||||||
baseColor: AppColors.grey100,
|
baseColor: colorScheme.surfaceContainerHighest,
|
||||||
highlightColor: AppColors.grey50,
|
highlightColor: colorScheme.surfaceContainerLowest,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Image placeholder
|
// Image placeholder
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.vertical(
|
borderRadius: const BorderRadius.vertical(
|
||||||
top: Radius.circular(ProductCardSpecs.borderRadius),
|
top: Radius.circular(ProductCardSpecs.borderRadius),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -561,7 +566,7 @@ class _ShimmerCard extends StatelessWidget {
|
|||||||
height: 14.0,
|
height: 14.0,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(4.0),
|
borderRadius: BorderRadius.circular(4.0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -573,7 +578,7 @@ class _ShimmerCard extends StatelessWidget {
|
|||||||
height: 12.0,
|
height: 12.0,
|
||||||
width: 80.0,
|
width: 80.0,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(4.0),
|
borderRadius: BorderRadius.circular(4.0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -585,7 +590,7 @@ class _ShimmerCard extends StatelessWidget {
|
|||||||
height: 16.0,
|
height: 16.0,
|
||||||
width: 100.0,
|
width: 100.0,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(4.0),
|
borderRadius: BorderRadius.circular(4.0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -597,7 +602,7 @@ class _ShimmerCard extends StatelessWidget {
|
|||||||
height: 36.0,
|
height: 36.0,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(AppRadius.button),
|
borderRadius: BorderRadius.circular(AppRadius.button),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -626,6 +631,8 @@ class _ErrorState extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||||
@@ -642,12 +649,12 @@ class _ErrorState extends StatelessWidget {
|
|||||||
const SizedBox(height: AppSpacing.lg),
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
const Text(
|
Text(
|
||||||
'Có lỗi xảy ra',
|
'Có lỗi xảy ra',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18.0,
|
fontSize: 18.0,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.grey900,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -657,7 +664,7 @@ class _ErrorState extends StatelessWidget {
|
|||||||
// Error message
|
// Error message
|
||||||
Text(
|
Text(
|
||||||
error.toString(),
|
error.toString(),
|
||||||
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
style: TextStyle(fontSize: 14.0, color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -669,8 +676,8 @@ class _ErrorState extends StatelessWidget {
|
|||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: onRetry,
|
onPressed: onRetry,
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: AppColors.primaryBlue,
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: AppColors.white,
|
foregroundColor: colorScheme.surface,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: AppSpacing.xl,
|
horizontal: AppSpacing.xl,
|
||||||
vertical: AppSpacing.md,
|
vertical: AppSpacing.md,
|
||||||
|
|||||||
@@ -71,7 +71,12 @@ class FavoriteProducts extends _$FavoriteProducts {
|
|||||||
@override
|
@override
|
||||||
Future<List<Product>> build() async {
|
Future<List<Product>> build() async {
|
||||||
_repository = await ref.read(favoritesRepositoryProvider.future);
|
_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
|
/// 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.
|
/// No userId needed - the API uses the authenticated session.
|
||||||
Future<void> addFavorite(String productId) async {
|
Future<void> addFavorite(String productId) async {
|
||||||
try {
|
try {
|
||||||
_debugPrint('Adding product to favorites: $productId');
|
_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)
|
// Call repository to add to favorites (uses auth token from session)
|
||||||
await _repository.addFavorite(productId);
|
await _repository.addFavorite(productId);
|
||||||
|
|
||||||
// Refresh the products list after successful addition
|
|
||||||
await refresh();
|
|
||||||
|
|
||||||
_debugPrint('Successfully added favorite: $productId');
|
_debugPrint('Successfully added favorite: $productId');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Rollback optimistic update on error
|
||||||
|
ref.read(favoriteIdsLocalProvider.notifier).removeId(productId);
|
||||||
_debugPrint('Error adding favorite: $e');
|
_debugPrint('Error adding favorite: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
@@ -120,20 +127,22 @@ class FavoriteProducts extends _$FavoriteProducts {
|
|||||||
|
|
||||||
/// Remove a product from favorites
|
/// 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.
|
/// No userId needed - the API uses the authenticated session.
|
||||||
Future<void> removeFavorite(String productId) async {
|
Future<void> removeFavorite(String productId) async {
|
||||||
try {
|
try {
|
||||||
_debugPrint('Removing product from favorites: $productId');
|
_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)
|
// Call repository to remove from favorites (uses auth token from session)
|
||||||
await _repository.removeFavorite(productId);
|
await _repository.removeFavorite(productId);
|
||||||
|
|
||||||
// Refresh the products list after successful removal
|
|
||||||
await refresh();
|
|
||||||
|
|
||||||
_debugPrint('Successfully removed favorite: $productId');
|
_debugPrint('Successfully removed favorite: $productId');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Rollback optimistic update on error
|
||||||
|
ref.read(favoriteIdsLocalProvider.notifier).addId(productId);
|
||||||
_debugPrint('Error removing favorite: $e');
|
_debugPrint('Error removing favorite: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
@@ -143,9 +152,11 @@ class FavoriteProducts extends _$FavoriteProducts {
|
|||||||
///
|
///
|
||||||
/// If the product is favorited, it will be removed.
|
/// If the product is favorited, it will be removed.
|
||||||
/// If the product is not favorited, it will be added.
|
/// If the product is not favorited, it will be added.
|
||||||
|
/// Checks from local state for instant response.
|
||||||
Future<void> toggleFavorite(String productId) async {
|
Future<void> toggleFavorite(String productId) async {
|
||||||
final currentProducts = state.value ?? [];
|
// Check from local IDs (instant, no API call)
|
||||||
final isFavorited = currentProducts.any((p) => p.productId == productId);
|
final localIds = ref.read(favoriteIdsLocalProvider);
|
||||||
|
final isFavorited = localIds.contains(productId);
|
||||||
|
|
||||||
if (isFavorited) {
|
if (isFavorited) {
|
||||||
await removeFavorite(productId);
|
await removeFavorite(productId);
|
||||||
@@ -170,20 +181,48 @@ class FavoriteProducts extends _$FavoriteProducts {
|
|||||||
// HELPER PROVIDERS
|
// 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.
|
/// Reads directly from Hive local cache for instant response.
|
||||||
/// Returns true if the product is in the user's favorites, false otherwise.
|
/// This is used in product detail page to avoid unnecessary API calls.
|
||||||
/// Safe to use in build methods - will return false during loading/error states.
|
/// The cache is synced when favorites are loaded or modified.
|
||||||
@riverpod
|
@riverpod
|
||||||
bool isFavorite(Ref ref, String productId) {
|
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(
|
final localDataSource = ref.read(favoriteProductsLocalDataSourceProvider);
|
||||||
data: (products) => products.any((p) => p.productId == productId),
|
return localDataSource.isFavorite(productId);
|
||||||
loading: () => false,
|
}
|
||||||
error: (_, __) => false,
|
|
||||||
);
|
/// 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
|
/// Get the total count of favorites
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ final class FavoriteProductsProvider
|
|||||||
FavoriteProducts create() => FavoriteProducts();
|
FavoriteProducts create() => FavoriteProducts();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$favoriteProductsHash() => r'd43c41db210259021df104f9fecdd00cf474d196';
|
String _$favoriteProductsHash() => r'6d042f469a1f71bb06f8b5b76014bf24e30e6758';
|
||||||
|
|
||||||
/// Manages favorite products with full Product data from wishlist API
|
/// 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.
|
/// Reads directly from Hive local cache for instant response.
|
||||||
/// Returns true if the product is in the user's favorites, false otherwise.
|
/// This is used in product detail page to avoid unnecessary API calls.
|
||||||
/// Safe to use in build methods - will return false during loading/error states.
|
/// The cache is synced when favorites are loaded or modified.
|
||||||
|
|
||||||
@ProviderFor(isFavorite)
|
@ProviderFor(isFavorite)
|
||||||
const isFavoriteProvider = IsFavoriteFamily._();
|
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.
|
/// Reads directly from Hive local cache for instant response.
|
||||||
/// Returns true if the product is in the user's favorites, false otherwise.
|
/// This is used in product detail page to avoid unnecessary API calls.
|
||||||
/// Safe to use in build methods - will return false during loading/error states.
|
/// The cache is synced when favorites are loaded or modified.
|
||||||
|
|
||||||
final class IsFavoriteProvider extends $FunctionalProvider<bool, bool, bool>
|
final class IsFavoriteProvider extends $FunctionalProvider<bool, bool, bool>
|
||||||
with $Provider<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.
|
/// Reads directly from Hive local cache for instant response.
|
||||||
/// Returns true if the product is in the user's favorites, false otherwise.
|
/// This is used in product detail page to avoid unnecessary API calls.
|
||||||
/// Safe to use in build methods - will return false during loading/error states.
|
/// The cache is synced when favorites are loaded or modified.
|
||||||
const IsFavoriteProvider._({
|
const IsFavoriteProvider._({
|
||||||
required IsFavoriteFamily super.from,
|
required IsFavoriteFamily super.from,
|
||||||
required String super.argument,
|
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.
|
/// Reads directly from Hive local cache for instant response.
|
||||||
/// Returns true if the product is in the user's favorites, false otherwise.
|
/// This is used in product detail page to avoid unnecessary API calls.
|
||||||
/// Safe to use in build methods - will return false during loading/error states.
|
/// The cache is synced when favorites are loaded or modified.
|
||||||
|
|
||||||
final class IsFavoriteFamily extends $Family
|
final class IsFavoriteFamily extends $Family
|
||||||
with $FunctionalFamilyOverride<bool, String> {
|
with $FunctionalFamilyOverride<bool, String> {
|
||||||
@@ -361,11 +361,11 @@ final class IsFavoriteFamily extends $Family
|
|||||||
isAutoDispose: true,
|
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.
|
/// Reads directly from Hive local cache for instant response.
|
||||||
/// Returns true if the product is in the user's favorites, false otherwise.
|
/// This is used in product detail page to avoid unnecessary API calls.
|
||||||
/// Safe to use in build methods - will return false during loading/error states.
|
/// The cache is synced when favorites are loaded or modified.
|
||||||
|
|
||||||
IsFavoriteProvider call(String productId) =>
|
IsFavoriteProvider call(String productId) =>
|
||||||
IsFavoriteProvider._(argument: productId, from: this);
|
IsFavoriteProvider._(argument: productId, from: this);
|
||||||
@@ -374,6 +374,77 @@ final class IsFavoriteFamily extends $Family
|
|||||||
String toString() => r'isFavoriteProvider';
|
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
|
/// Get the total count of favorites
|
||||||
///
|
///
|
||||||
/// Derived from the favorite products list.
|
/// Derived from the favorite products list.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:shimmer/shimmer.dart';
|
import 'package:shimmer/shimmer.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.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/favorites/presentation/providers/favorites_provider.dart';
|
||||||
import 'package:worker/features/products/domain/entities/product.dart';
|
import 'package:worker/features/products/domain/entities/product.dart';
|
||||||
|
|
||||||
@@ -76,6 +76,8 @@ class FavoriteProductCard extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: ProductCardSpecs.elevation,
|
elevation: ProductCardSpecs.elevation,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -101,16 +103,16 @@ class FavoriteProductCard extends ConsumerWidget {
|
|||||||
memCacheWidth: ImageSpecs.productImageCacheWidth,
|
memCacheWidth: ImageSpecs.productImageCacheWidth,
|
||||||
memCacheHeight: ImageSpecs.productImageCacheHeight,
|
memCacheHeight: ImageSpecs.productImageCacheHeight,
|
||||||
placeholder: (context, url) => Shimmer.fromColors(
|
placeholder: (context, url) => Shimmer.fromColors(
|
||||||
baseColor: AppColors.grey100,
|
baseColor: colorScheme.surfaceContainerHighest,
|
||||||
highlightColor: AppColors.grey50,
|
highlightColor: colorScheme.surfaceContainerLowest,
|
||||||
child: Container(color: AppColors.grey100),
|
child: Container(color: colorScheme.surfaceContainerHighest),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
color: AppColors.grey100,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: const FaIcon(
|
child: FaIcon(
|
||||||
FontAwesomeIcons.image,
|
FontAwesomeIcons.image,
|
||||||
size: 48.0,
|
size: 48.0,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -122,7 +124,7 @@ class FavoriteProductCard extends ConsumerWidget {
|
|||||||
right: AppSpacing.sm,
|
right: AppSpacing.sm,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.white,
|
color: colorScheme.surface,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
@@ -176,9 +178,9 @@ class FavoriteProductCard extends ConsumerWidget {
|
|||||||
if (product.erpnextItemCode != null)
|
if (product.erpnextItemCode != null)
|
||||||
Text(
|
Text(
|
||||||
'Mã: ${product.erpnextItemCode}',
|
'Mã: ${product.erpnextItemCode}',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12.0,
|
fontSize: 12.0,
|
||||||
color: AppColors.grey500,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -189,10 +191,10 @@ class FavoriteProductCard extends ConsumerWidget {
|
|||||||
// Price
|
// Price
|
||||||
Text(
|
Text(
|
||||||
_formatPrice(product.effectivePrice),
|
_formatPrice(product.effectivePrice),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16.0,
|
fontSize: 16.0,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -208,9 +210,9 @@ class FavoriteProductCard extends ConsumerWidget {
|
|||||||
context.push('/products/${product.productId}');
|
context.push('/products/${product.productId}');
|
||||||
},
|
},
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: AppColors.primaryBlue,
|
foregroundColor: colorScheme.primary,
|
||||||
side: const BorderSide(
|
side: BorderSide(
|
||||||
color: AppColors.primaryBlue,
|
color: colorScheme.primary,
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.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/router/app_router.dart';
|
||||||
import 'package:worker/core/utils/extensions.dart';
|
import 'package:worker/core/utils/extensions.dart';
|
||||||
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
|
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
|
||||||
@@ -81,7 +83,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
child: const CustomLoadingIndicator(),
|
||||||
),
|
),
|
||||||
error: (error, stack) => Container(
|
error: (error, stack) => Container(
|
||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.all(16),
|
||||||
@@ -133,10 +135,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
loading: () => const Padding(
|
loading: () => _buildPromotionsShimmer(colorScheme),
|
||||||
padding: EdgeInsets.all(16),
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
),
|
|
||||||
error: (error, stack) => const SizedBox.shrink(),
|
error: (error, stack) => const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -241,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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ part 'member_card_provider.g.dart';
|
|||||||
///
|
///
|
||||||
/// memberCardAsync.when(
|
/// memberCardAsync.when(
|
||||||
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ part of 'member_card_provider.dart';
|
|||||||
///
|
///
|
||||||
/// memberCardAsync.when(
|
/// memberCardAsync.when(
|
||||||
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@@ -40,7 +40,7 @@ const memberCardProvider = MemberCardNotifierProvider._();
|
|||||||
///
|
///
|
||||||
/// memberCardAsync.when(
|
/// memberCardAsync.when(
|
||||||
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@@ -58,7 +58,7 @@ final class MemberCardNotifierProvider
|
|||||||
///
|
///
|
||||||
/// memberCardAsync.when(
|
/// memberCardAsync.when(
|
||||||
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@@ -96,7 +96,7 @@ String _$memberCardNotifierHash() =>
|
|||||||
///
|
///
|
||||||
/// memberCardAsync.when(
|
/// memberCardAsync.when(
|
||||||
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
/// Provider: Promotions Provider
|
/// Provider: Promotions Provider
|
||||||
///
|
///
|
||||||
/// Manages the state of promotions data using Riverpod.
|
/// Manages the state of promotions data using Riverpod.
|
||||||
/// Provides access to active promotions throughout the app.
|
/// Uses the same data source as news articles (single API call).
|
||||||
///
|
///
|
||||||
/// Uses AsyncNotifierProvider for automatic loading, error, and data states.
|
/// Uses AsyncNotifierProvider for automatic loading, error, and data states.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:worker/features/home/data/datasources/home_local_datasource.dart';
|
|
||||||
import 'package:worker/features/home/data/repositories/home_repository_impl.dart';
|
|
||||||
import 'package:worker/features/home/domain/entities/promotion.dart';
|
import 'package:worker/features/home/domain/entities/promotion.dart';
|
||||||
import 'package:worker/features/home/domain/usecases/get_promotions.dart';
|
import 'package:worker/features/news/presentation/providers/news_provider.dart';
|
||||||
|
|
||||||
part 'promotions_provider.g.dart';
|
part 'promotions_provider.g.dart';
|
||||||
|
|
||||||
|
/// Max number of promotions to display on home page
|
||||||
|
const int _maxPromotions = 5;
|
||||||
|
|
||||||
/// Promotions Provider
|
/// Promotions Provider
|
||||||
///
|
///
|
||||||
/// Fetches and caches the list of active promotions.
|
/// Uses the same data source as news articles to avoid duplicate API calls.
|
||||||
/// Automatically handles loading, error, and data states.
|
/// Converts NewsArticle to Promotion entity for display in PromotionSlider.
|
||||||
|
/// Limited to 5 items max.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
@@ -26,38 +28,27 @@ part 'promotions_provider.g.dart';
|
|||||||
///
|
///
|
||||||
/// promotionsAsync.when(
|
/// promotionsAsync.when(
|
||||||
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
@riverpod
|
@riverpod
|
||||||
class PromotionsNotifier extends _$PromotionsNotifier {
|
Future<List<Promotion>> promotions(Ref ref) async {
|
||||||
@override
|
// Use newsArticles provider (same API call, no duplicate request)
|
||||||
Future<List<Promotion>> build() async {
|
final articles = await ref.watch(newsArticlesProvider.future);
|
||||||
// Initialize dependencies
|
|
||||||
final localDataSource = const HomeLocalDataSourceImpl();
|
|
||||||
final repository = HomeRepositoryImpl(localDataSource: localDataSource);
|
|
||||||
final useCase = GetPromotions(repository);
|
|
||||||
|
|
||||||
// Fetch promotions (only active ones)
|
// Take max 5 articles and convert to Promotion
|
||||||
return await useCase();
|
final limitedArticles = articles.take(_maxPromotions).toList();
|
||||||
}
|
|
||||||
|
|
||||||
/// Refresh promotions data
|
return limitedArticles.map((article) {
|
||||||
///
|
final now = DateTime.now();
|
||||||
/// Forces a refresh from the server (when API is available).
|
return Promotion(
|
||||||
/// Updates the cached state with fresh data.
|
id: article.id,
|
||||||
Future<void> refresh() async {
|
title: article.title,
|
||||||
// Set loading state
|
description: article.excerpt,
|
||||||
state = const AsyncValue.loading();
|
imageUrl: article.imageUrl,
|
||||||
|
startDate: article.publishedDate,
|
||||||
// Fetch fresh data
|
endDate: now.add(const Duration(days: 365)), // Always active
|
||||||
state = await AsyncValue.guard(() async {
|
);
|
||||||
final localDataSource = const HomeLocalDataSourceImpl();
|
}).toList();
|
||||||
final repository = HomeRepositoryImpl(localDataSource: localDataSource);
|
|
||||||
final useCase = GetPromotions(repository);
|
|
||||||
|
|
||||||
return await useCase.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ part of 'promotions_provider.dart';
|
|||||||
// ignore_for_file: type=lint, type=warning
|
// ignore_for_file: type=lint, type=warning
|
||||||
/// Promotions Provider
|
/// Promotions Provider
|
||||||
///
|
///
|
||||||
/// Fetches and caches the list of active promotions.
|
/// Uses the same data source as news articles to avoid duplicate API calls.
|
||||||
/// Automatically handles loading, error, and data states.
|
/// Converts NewsArticle to Promotion entity for display in PromotionSlider.
|
||||||
|
/// Limited to 5 items max.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
@@ -20,18 +21,19 @@ part of 'promotions_provider.dart';
|
|||||||
///
|
///
|
||||||
/// promotionsAsync.when(
|
/// promotionsAsync.when(
|
||||||
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@ProviderFor(PromotionsNotifier)
|
@ProviderFor(promotions)
|
||||||
const promotionsProvider = PromotionsNotifierProvider._();
|
const promotionsProvider = PromotionsProvider._();
|
||||||
|
|
||||||
/// Promotions Provider
|
/// Promotions Provider
|
||||||
///
|
///
|
||||||
/// Fetches and caches the list of active promotions.
|
/// Uses the same data source as news articles to avoid duplicate API calls.
|
||||||
/// Automatically handles loading, error, and data states.
|
/// Converts NewsArticle to Promotion entity for display in PromotionSlider.
|
||||||
|
/// Limited to 5 items max.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
@@ -40,16 +42,24 @@ const promotionsProvider = PromotionsNotifierProvider._();
|
|||||||
///
|
///
|
||||||
/// promotionsAsync.when(
|
/// promotionsAsync.when(
|
||||||
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
final class PromotionsNotifierProvider
|
|
||||||
extends $AsyncNotifierProvider<PromotionsNotifier, List<Promotion>> {
|
final class PromotionsProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AsyncValue<List<Promotion>>,
|
||||||
|
List<Promotion>,
|
||||||
|
FutureOr<List<Promotion>>
|
||||||
|
>
|
||||||
|
with $FutureModifier<List<Promotion>>, $FutureProvider<List<Promotion>> {
|
||||||
/// Promotions Provider
|
/// Promotions Provider
|
||||||
///
|
///
|
||||||
/// Fetches and caches the list of active promotions.
|
/// Uses the same data source as news articles to avoid duplicate API calls.
|
||||||
/// Automatically handles loading, error, and data states.
|
/// Converts NewsArticle to Promotion entity for display in PromotionSlider.
|
||||||
|
/// Limited to 5 items max.
|
||||||
///
|
///
|
||||||
/// Usage:
|
/// Usage:
|
||||||
/// ```dart
|
/// ```dart
|
||||||
@@ -58,11 +68,11 @@ final class PromotionsNotifierProvider
|
|||||||
///
|
///
|
||||||
/// promotionsAsync.when(
|
/// promotionsAsync.when(
|
||||||
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
||||||
/// loading: () => CircularProgressIndicator(),
|
/// loading: () => const CustomLoadingIndicator(),
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
/// error: (error, stack) => ErrorWidget(error),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
const PromotionsNotifierProvider._()
|
const PromotionsProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
argument: null,
|
argument: null,
|
||||||
@@ -74,48 +84,18 @@ final class PromotionsNotifierProvider
|
|||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String debugGetCreateSourceHash() => _$promotionsNotifierHash();
|
String debugGetCreateSourceHash() => _$promotionsHash();
|
||||||
|
|
||||||
@$internal
|
@$internal
|
||||||
@override
|
@override
|
||||||
PromotionsNotifier create() => PromotionsNotifier();
|
$FutureProviderElement<List<Promotion>> $createElement(
|
||||||
}
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
String _$promotionsNotifierHash() =>
|
|
||||||
r'3cd866c74ba11c6519e9b63521e1757ef117c7a9';
|
|
||||||
|
|
||||||
/// Promotions Provider
|
|
||||||
///
|
|
||||||
/// Fetches and caches the list of active promotions.
|
|
||||||
/// Automatically handles loading, error, and data states.
|
|
||||||
///
|
|
||||||
/// Usage:
|
|
||||||
/// ```dart
|
|
||||||
/// // In a ConsumerWidget
|
|
||||||
/// final promotionsAsync = ref.watch(promotionsProvider);
|
|
||||||
///
|
|
||||||
/// promotionsAsync.when(
|
|
||||||
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
|
||||||
/// loading: () => CircularProgressIndicator(),
|
|
||||||
/// error: (error, stack) => ErrorWidget(error),
|
|
||||||
/// );
|
|
||||||
/// ```
|
|
||||||
|
|
||||||
abstract class _$PromotionsNotifier extends $AsyncNotifier<List<Promotion>> {
|
|
||||||
FutureOr<List<Promotion>> build();
|
|
||||||
@$mustCallSuper
|
|
||||||
@override
|
@override
|
||||||
void runBuild() {
|
FutureOr<List<Promotion>> create(Ref ref) {
|
||||||
final created = build();
|
return promotions(ref);
|
||||||
final ref = this.ref as $Ref<AsyncValue<List<Promotion>>, List<Promotion>>;
|
|
||||||
final element =
|
|
||||||
ref.element
|
|
||||||
as $ClassProviderElement<
|
|
||||||
AnyNotifier<AsyncValue<List<Promotion>>, List<Promotion>>,
|
|
||||||
AsyncValue<List<Promotion>>,
|
|
||||||
Object?,
|
|
||||||
Object?
|
|
||||||
>;
|
|
||||||
element.handleValue(ref, created);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _$promotionsHash() => r'2eac0298d2b84ad5cc50faa6b8a015dbf7b7a1d3';
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
/// Widget: Promotion Slider
|
/// Widget: Promotion Slider
|
||||||
///
|
///
|
||||||
/// Horizontal scrolling list of promotional banners.
|
/// Auto-sliding carousel of promotional banners.
|
||||||
/// Displays promotion images, titles, and descriptions.
|
/// Displays promotion images, titles, and descriptions.
|
||||||
|
/// Auto-advances every 4 seconds with smooth animation.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:worker/core/router/app_router.dart';
|
import 'package:worker/core/router/app_router.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:worker/features/home/domain/entities/promotion.dart';
|
import 'package:worker/features/home/domain/entities/promotion.dart';
|
||||||
|
|
||||||
/// Promotion Slider Widget
|
/// Promotion Slider Widget
|
||||||
///
|
///
|
||||||
/// Displays a horizontal scrollable list of promotion cards.
|
/// Displays an auto-sliding carousel of promotion cards.
|
||||||
/// Each card shows an image, title, and brief description.
|
/// Each card shows an image, title, and brief description.
|
||||||
class PromotionSlider extends StatelessWidget {
|
/// Auto-advances every 4 seconds with page indicators.
|
||||||
|
class PromotionSlider extends StatefulWidget {
|
||||||
const PromotionSlider({
|
const PromotionSlider({
|
||||||
super.key,
|
super.key,
|
||||||
required this.promotions,
|
required this.promotions,
|
||||||
this.onPromotionTap,
|
this.onPromotionTap,
|
||||||
|
this.autoSlideDuration = const Duration(seconds: 4),
|
||||||
});
|
});
|
||||||
|
|
||||||
/// List of promotions to display
|
/// List of promotions to display
|
||||||
@@ -28,9 +34,56 @@ class PromotionSlider extends StatelessWidget {
|
|||||||
/// Callback when a promotion is tapped
|
/// Callback when a promotion is tapped
|
||||||
final void Function(Promotion promotion)? onPromotionTap;
|
final void Function(Promotion promotion)? onPromotionTap;
|
||||||
|
|
||||||
|
/// Duration between auto-slides
|
||||||
|
final Duration autoSlideDuration;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PromotionSlider> createState() => _PromotionSliderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PromotionSliderState extends State<PromotionSlider> {
|
||||||
|
late final ScrollController _scrollController;
|
||||||
|
Timer? _autoSlideTimer;
|
||||||
|
int _currentIndex = 0;
|
||||||
|
|
||||||
|
static const double _cardWidth = 280;
|
||||||
|
static const double _cardMargin = 12;
|
||||||
|
static const double _scrollOffset = _cardWidth + _cardMargin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController = ScrollController();
|
||||||
|
_startAutoSlide();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_autoSlideTimer?.cancel();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startAutoSlide() {
|
||||||
|
if (widget.promotions.length <= 1) return;
|
||||||
|
|
||||||
|
_autoSlideTimer = Timer.periodic(widget.autoSlideDuration, (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
_currentIndex = (_currentIndex + 1) % widget.promotions.length;
|
||||||
|
final targetOffset = _currentIndex * _scrollOffset;
|
||||||
|
|
||||||
|
_scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: const Duration(milliseconds: 400),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (promotions.isEmpty) {
|
if (widget.promotions.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +97,7 @@ class PromotionSlider extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Chương trình ưu đãi',
|
'Tin tức nổi bật',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -54,22 +107,22 @@ class PromotionSlider extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 210, // 140px image + 54px text area
|
height: 210,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
itemCount: promotions.length,
|
itemCount: widget.promotions.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return _PromotionCard(
|
return _PromotionCard(
|
||||||
promotion: promotions[index],
|
promotion: widget.promotions[index],
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (onPromotionTap != null) {
|
if (widget.onPromotionTap != null) {
|
||||||
onPromotionTap!(promotions[index]);
|
widget.onPromotionTap!(widget.promotions[index]);
|
||||||
} else {
|
} else {
|
||||||
// Navigate to promotion detail page
|
|
||||||
context.pushNamed(
|
context.pushNamed(
|
||||||
RouteNames.promotionDetail,
|
RouteNames.promotionDetail,
|
||||||
extra: promotions[index],
|
extra: widget.promotions[index],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -126,7 +179,7 @@ class _PromotionCard extends StatelessWidget {
|
|||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
height: 140,
|
height: 140,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
child: const CustomLoadingIndicator(),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
height: 140,
|
height: 140,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/// Invoice Remote Data Source
|
||||||
|
///
|
||||||
|
/// Handles API calls for invoice-related data.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:worker/core/constants/api_constants.dart';
|
||||||
|
import 'package:worker/core/network/dio_client.dart';
|
||||||
|
import 'package:worker/features/invoices/data/models/invoice_model.dart';
|
||||||
|
|
||||||
|
/// Invoice Remote Data Source
|
||||||
|
class InvoiceRemoteDataSource {
|
||||||
|
const InvoiceRemoteDataSource(this._dioClient);
|
||||||
|
|
||||||
|
final DioClient _dioClient;
|
||||||
|
|
||||||
|
/// Get invoices list
|
||||||
|
///
|
||||||
|
/// Calls: POST /api/method/building_material.building_material.api.invoice.get_list
|
||||||
|
/// Returns: List of invoices
|
||||||
|
Future<List<InvoiceModel>> getInvoicesList({
|
||||||
|
int limitStart = 0,
|
||||||
|
int limitPageLength = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||||
|
'${ApiConstants.frappeApiMethod}${ApiConstants.getInvoiceList}',
|
||||||
|
data: {
|
||||||
|
'limit_start': limitStart,
|
||||||
|
'limit_page_length': limitPageLength,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = response.data;
|
||||||
|
if (data == null) {
|
||||||
|
throw Exception('No data received from getInvoicesList API');
|
||||||
|
}
|
||||||
|
|
||||||
|
// API returns: { "message": [...] }
|
||||||
|
final message = data['message'];
|
||||||
|
if (message == null) {
|
||||||
|
throw Exception('No message field in getInvoicesList response');
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<dynamic> invoicesList = message as List<dynamic>;
|
||||||
|
return invoicesList
|
||||||
|
.map((json) => InvoiceModel.fromJson(json as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to get invoices list: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get invoice detail
|
||||||
|
///
|
||||||
|
/// Calls: POST /api/method/building_material.building_material.api.invoice.get_detail
|
||||||
|
/// Returns: Invoice detail
|
||||||
|
Future<InvoiceModel> getInvoiceDetail(String name) async {
|
||||||
|
try {
|
||||||
|
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||||
|
'${ApiConstants.frappeApiMethod}${ApiConstants.getInvoiceDetail}',
|
||||||
|
data: {'name': name},
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = response.data;
|
||||||
|
if (data == null) {
|
||||||
|
throw Exception('No data received from getInvoiceDetail API');
|
||||||
|
}
|
||||||
|
|
||||||
|
// API returns: { "message": {...} }
|
||||||
|
final message = data['message'];
|
||||||
|
if (message == null) {
|
||||||
|
throw Exception('No message field in getInvoiceDetail response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return InvoiceModel.fromJson(message as Map<String, dynamic>);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to get invoice detail: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
306
lib/features/invoices/data/models/invoice_model.dart
Normal file
306
lib/features/invoices/data/models/invoice_model.dart
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
/// Data Model: Invoice Model
|
||||||
|
///
|
||||||
|
/// Model for invoice data with API serialization.
|
||||||
|
/// Not stored in local database.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:worker/features/invoices/domain/entities/invoice.dart';
|
||||||
|
|
||||||
|
/// Seller Info Model
|
||||||
|
class SellerInfoModel {
|
||||||
|
final String? phone;
|
||||||
|
final String? email;
|
||||||
|
final String? fax;
|
||||||
|
final String? taxCode;
|
||||||
|
final String? companyName;
|
||||||
|
final String? addressLine1;
|
||||||
|
final String? cityCode;
|
||||||
|
final String? wardCode;
|
||||||
|
final String? cityName;
|
||||||
|
final String? wardName;
|
||||||
|
|
||||||
|
const SellerInfoModel({
|
||||||
|
this.phone,
|
||||||
|
this.email,
|
||||||
|
this.fax,
|
||||||
|
this.taxCode,
|
||||||
|
this.companyName,
|
||||||
|
this.addressLine1,
|
||||||
|
this.cityCode,
|
||||||
|
this.wardCode,
|
||||||
|
this.cityName,
|
||||||
|
this.wardName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SellerInfoModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SellerInfoModel(
|
||||||
|
phone: json['phone'] as String?,
|
||||||
|
email: json['email'] as String?,
|
||||||
|
fax: json['fax'] as String?,
|
||||||
|
taxCode: json['tax_code'] as String?,
|
||||||
|
companyName: json['company_name'] as String?,
|
||||||
|
addressLine1: json['address_line1'] as String?,
|
||||||
|
cityCode: json['city_code'] as String?,
|
||||||
|
wardCode: json['ward_code'] as String?,
|
||||||
|
cityName: json['city_name'] as String?,
|
||||||
|
wardName: json['ward_name'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'phone': phone,
|
||||||
|
'email': email,
|
||||||
|
'fax': fax,
|
||||||
|
'tax_code': taxCode,
|
||||||
|
'company_name': companyName,
|
||||||
|
'address_line1': addressLine1,
|
||||||
|
'city_code': cityCode,
|
||||||
|
'ward_code': wardCode,
|
||||||
|
'city_name': cityName,
|
||||||
|
'ward_name': wardName,
|
||||||
|
};
|
||||||
|
|
||||||
|
SellerInfo toEntity() => SellerInfo(
|
||||||
|
phone: phone,
|
||||||
|
email: email,
|
||||||
|
fax: fax,
|
||||||
|
taxCode: taxCode,
|
||||||
|
companyName: companyName,
|
||||||
|
addressLine1: addressLine1,
|
||||||
|
cityCode: cityCode,
|
||||||
|
wardCode: wardCode,
|
||||||
|
cityName: cityName,
|
||||||
|
wardName: wardName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buyer Info Model
|
||||||
|
class BuyerInfoModel {
|
||||||
|
final String? name;
|
||||||
|
final String? addressTitle;
|
||||||
|
final String? addressLine1;
|
||||||
|
final String? phone;
|
||||||
|
final String? email;
|
||||||
|
final String? fax;
|
||||||
|
final String? taxCode;
|
||||||
|
final String? cityCode;
|
||||||
|
final String? wardCode;
|
||||||
|
final String? cityName;
|
||||||
|
final String? wardName;
|
||||||
|
|
||||||
|
const BuyerInfoModel({
|
||||||
|
this.name,
|
||||||
|
this.addressTitle,
|
||||||
|
this.addressLine1,
|
||||||
|
this.phone,
|
||||||
|
this.email,
|
||||||
|
this.fax,
|
||||||
|
this.taxCode,
|
||||||
|
this.cityCode,
|
||||||
|
this.wardCode,
|
||||||
|
this.cityName,
|
||||||
|
this.wardName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BuyerInfoModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BuyerInfoModel(
|
||||||
|
name: json['name'] as String?,
|
||||||
|
addressTitle: json['address_title'] as String?,
|
||||||
|
addressLine1: json['address_line1'] as String?,
|
||||||
|
phone: json['phone'] as String?,
|
||||||
|
email: json['email'] as String?,
|
||||||
|
fax: json['fax'] as String?,
|
||||||
|
taxCode: json['tax_code'] as String?,
|
||||||
|
cityCode: json['city_code'] as String?,
|
||||||
|
wardCode: json['ward_code'] as String?,
|
||||||
|
cityName: json['city_name'] as String?,
|
||||||
|
wardName: json['ward_name'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'name': name,
|
||||||
|
'address_title': addressTitle,
|
||||||
|
'address_line1': addressLine1,
|
||||||
|
'phone': phone,
|
||||||
|
'email': email,
|
||||||
|
'fax': fax,
|
||||||
|
'tax_code': taxCode,
|
||||||
|
'city_code': cityCode,
|
||||||
|
'ward_code': wardCode,
|
||||||
|
'city_name': cityName,
|
||||||
|
'ward_name': wardName,
|
||||||
|
};
|
||||||
|
|
||||||
|
BuyerInfo toEntity() => BuyerInfo(
|
||||||
|
name: name,
|
||||||
|
addressTitle: addressTitle,
|
||||||
|
addressLine1: addressLine1,
|
||||||
|
phone: phone,
|
||||||
|
email: email,
|
||||||
|
fax: fax,
|
||||||
|
taxCode: taxCode,
|
||||||
|
cityCode: cityCode,
|
||||||
|
wardCode: wardCode,
|
||||||
|
cityName: cityName,
|
||||||
|
wardName: wardName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoice Item Model
|
||||||
|
class InvoiceItemModel {
|
||||||
|
final String itemName;
|
||||||
|
final String itemCode;
|
||||||
|
final double qty;
|
||||||
|
final double rate;
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
const InvoiceItemModel({
|
||||||
|
required this.itemName,
|
||||||
|
required this.itemCode,
|
||||||
|
required this.qty,
|
||||||
|
required this.rate,
|
||||||
|
required this.amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory InvoiceItemModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return InvoiceItemModel(
|
||||||
|
itemName: json['item_name'] as String? ?? '',
|
||||||
|
itemCode: json['item_code'] as String? ?? '',
|
||||||
|
qty: (json['qty'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
rate: (json['rate'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
amount: (json['amount'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'item_name': itemName,
|
||||||
|
'item_code': itemCode,
|
||||||
|
'qty': qty,
|
||||||
|
'rate': rate,
|
||||||
|
'amount': amount,
|
||||||
|
};
|
||||||
|
|
||||||
|
InvoiceItem toEntity() => InvoiceItem(
|
||||||
|
itemName: itemName,
|
||||||
|
itemCode: itemCode,
|
||||||
|
qty: qty,
|
||||||
|
rate: rate,
|
||||||
|
amount: amount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoice Model
|
||||||
|
///
|
||||||
|
/// Model for API parsing only (no Hive storage).
|
||||||
|
class InvoiceModel {
|
||||||
|
final String name;
|
||||||
|
final String postingDate;
|
||||||
|
final String status;
|
||||||
|
final String statusColor;
|
||||||
|
final String? orderId;
|
||||||
|
final double grandTotal;
|
||||||
|
|
||||||
|
// Detail-only fields
|
||||||
|
final String? customerName;
|
||||||
|
final SellerInfoModel? sellerInfo;
|
||||||
|
final BuyerInfoModel? buyerInfo;
|
||||||
|
final List<InvoiceItemModel>? items;
|
||||||
|
final double? total;
|
||||||
|
final double? discountAmount;
|
||||||
|
|
||||||
|
const InvoiceModel({
|
||||||
|
required this.name,
|
||||||
|
required this.postingDate,
|
||||||
|
required this.status,
|
||||||
|
required this.statusColor,
|
||||||
|
this.orderId,
|
||||||
|
required this.grandTotal,
|
||||||
|
this.customerName,
|
||||||
|
this.sellerInfo,
|
||||||
|
this.buyerInfo,
|
||||||
|
this.items,
|
||||||
|
this.total,
|
||||||
|
this.discountAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Create from JSON (API response - list item)
|
||||||
|
factory InvoiceModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return InvoiceModel(
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
postingDate: json['posting_date'] as String? ?? '',
|
||||||
|
status: json['status'] as String? ?? '',
|
||||||
|
statusColor: json['status_color'] as String? ?? 'Secondary',
|
||||||
|
orderId: json['order_id'] as String?,
|
||||||
|
grandTotal: (json['grand_total'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
customerName: json['customer_name'] as String?,
|
||||||
|
sellerInfo: json['seller_info'] != null
|
||||||
|
? SellerInfoModel.fromJson(json['seller_info'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
buyerInfo: json['buyer_info'] != null
|
||||||
|
? BuyerInfoModel.fromJson(json['buyer_info'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
items: json['items'] != null
|
||||||
|
? (json['items'] as List<dynamic>)
|
||||||
|
.map((e) => InvoiceItemModel.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList()
|
||||||
|
: null,
|
||||||
|
total: (json['total'] as num?)?.toDouble(),
|
||||||
|
discountAmount: (json['discount_amount'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to JSON
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'name': name,
|
||||||
|
'posting_date': postingDate,
|
||||||
|
'status': status,
|
||||||
|
'status_color': statusColor,
|
||||||
|
'order_id': orderId,
|
||||||
|
'grand_total': grandTotal,
|
||||||
|
'customer_name': customerName,
|
||||||
|
'seller_info': sellerInfo?.toJson(),
|
||||||
|
'buyer_info': buyerInfo?.toJson(),
|
||||||
|
'items': items?.map((e) => e.toJson()).toList(),
|
||||||
|
'total': total,
|
||||||
|
'discount_amount': discountAmount,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Convert to domain entity
|
||||||
|
Invoice toEntity() {
|
||||||
|
return Invoice(
|
||||||
|
name: name,
|
||||||
|
postingDate: DateTime.tryParse(postingDate) ?? DateTime.now(),
|
||||||
|
status: status,
|
||||||
|
statusColor: statusColor,
|
||||||
|
orderId: orderId,
|
||||||
|
grandTotal: grandTotal,
|
||||||
|
customerName: customerName,
|
||||||
|
sellerInfo: sellerInfo?.toEntity(),
|
||||||
|
buyerInfo: buyerInfo?.toEntity(),
|
||||||
|
items: items?.map((e) => e.toEntity()).toList(),
|
||||||
|
total: total,
|
||||||
|
discountAmount: discountAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create from domain entity
|
||||||
|
factory InvoiceModel.fromEntity(Invoice entity) {
|
||||||
|
return InvoiceModel(
|
||||||
|
name: entity.name,
|
||||||
|
postingDate: entity.postingDate.toIso8601String().split('T').first,
|
||||||
|
status: entity.status,
|
||||||
|
statusColor: entity.statusColor,
|
||||||
|
orderId: entity.orderId,
|
||||||
|
grandTotal: entity.grandTotal,
|
||||||
|
customerName: entity.customerName,
|
||||||
|
total: entity.total,
|
||||||
|
discountAmount: entity.discountAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'InvoiceModel(name: $name, status: $status, grandTotal: $grandTotal)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/// Invoice Repository Implementation
|
||||||
|
///
|
||||||
|
/// Implements the invoice repository interface.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:worker/features/invoices/data/datasources/invoice_remote_datasource.dart';
|
||||||
|
import 'package:worker/features/invoices/domain/entities/invoice.dart';
|
||||||
|
import 'package:worker/features/invoices/domain/repositories/invoice_repository.dart';
|
||||||
|
|
||||||
|
/// Invoice Repository Implementation
|
||||||
|
class InvoiceRepositoryImpl implements InvoiceRepository {
|
||||||
|
const InvoiceRepositoryImpl(this._remoteDataSource);
|
||||||
|
|
||||||
|
final InvoiceRemoteDataSource _remoteDataSource;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Invoice>> getInvoicesList({
|
||||||
|
int limitStart = 0,
|
||||||
|
int limitPageLength = 0,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final invoicesData = await _remoteDataSource.getInvoicesList(
|
||||||
|
limitStart: limitStart,
|
||||||
|
limitPageLength: limitPageLength,
|
||||||
|
);
|
||||||
|
// Convert Model → Entity
|
||||||
|
return invoicesData.map((model) => model.toEntity()).toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to get invoices list: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Invoice> getInvoiceDetail(String name) async {
|
||||||
|
try {
|
||||||
|
final invoiceData = await _remoteDataSource.getInvoiceDetail(name);
|
||||||
|
// Convert Model → Entity
|
||||||
|
return invoiceData.toEntity();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to get invoice detail: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/features/invoices/domain/entities/invoice.dart
Normal file
272
lib/features/invoices/domain/entities/invoice.dart
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/// Domain Entity: Invoice
|
||||||
|
///
|
||||||
|
/// Represents an invoice from the API.
|
||||||
|
/// Used for both list and detail views.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Seller/Company Information
|
||||||
|
class SellerInfo extends Equatable {
|
||||||
|
final String? phone;
|
||||||
|
final String? email;
|
||||||
|
final String? fax;
|
||||||
|
final String? taxCode;
|
||||||
|
final String? companyName;
|
||||||
|
final String? addressLine1;
|
||||||
|
final String? cityCode;
|
||||||
|
final String? wardCode;
|
||||||
|
final String? cityName;
|
||||||
|
final String? wardName;
|
||||||
|
|
||||||
|
const SellerInfo({
|
||||||
|
this.phone,
|
||||||
|
this.email,
|
||||||
|
this.fax,
|
||||||
|
this.taxCode,
|
||||||
|
this.companyName,
|
||||||
|
this.addressLine1,
|
||||||
|
this.cityCode,
|
||||||
|
this.wardCode,
|
||||||
|
this.cityName,
|
||||||
|
this.wardName,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Get formatted full address
|
||||||
|
String get fullAddress {
|
||||||
|
final parts = <String>[];
|
||||||
|
if (addressLine1 != null && addressLine1!.isNotEmpty) {
|
||||||
|
parts.add(addressLine1!);
|
||||||
|
}
|
||||||
|
if (wardName != null && wardName!.isNotEmpty) {
|
||||||
|
parts.add(wardName!);
|
||||||
|
}
|
||||||
|
if (cityName != null && cityName!.isNotEmpty) {
|
||||||
|
parts.add(cityName!);
|
||||||
|
}
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
fax,
|
||||||
|
taxCode,
|
||||||
|
companyName,
|
||||||
|
addressLine1,
|
||||||
|
cityCode,
|
||||||
|
wardCode,
|
||||||
|
cityName,
|
||||||
|
wardName,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Buyer/Customer Information
|
||||||
|
class BuyerInfo extends Equatable {
|
||||||
|
final String? name;
|
||||||
|
final String? addressTitle;
|
||||||
|
final String? addressLine1;
|
||||||
|
final String? phone;
|
||||||
|
final String? email;
|
||||||
|
final String? fax;
|
||||||
|
final String? taxCode;
|
||||||
|
final String? cityCode;
|
||||||
|
final String? wardCode;
|
||||||
|
final String? cityName;
|
||||||
|
final String? wardName;
|
||||||
|
|
||||||
|
const BuyerInfo({
|
||||||
|
this.name,
|
||||||
|
this.addressTitle,
|
||||||
|
this.addressLine1,
|
||||||
|
this.phone,
|
||||||
|
this.email,
|
||||||
|
this.fax,
|
||||||
|
this.taxCode,
|
||||||
|
this.cityCode,
|
||||||
|
this.wardCode,
|
||||||
|
this.cityName,
|
||||||
|
this.wardName,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Get formatted full address
|
||||||
|
String get fullAddress {
|
||||||
|
final parts = <String>[];
|
||||||
|
if (addressLine1 != null && addressLine1!.isNotEmpty) {
|
||||||
|
parts.add(addressLine1!);
|
||||||
|
}
|
||||||
|
if (wardName != null && wardName!.isNotEmpty) {
|
||||||
|
parts.add(wardName!);
|
||||||
|
}
|
||||||
|
if (cityName != null && cityName!.isNotEmpty) {
|
||||||
|
parts.add(cityName!);
|
||||||
|
}
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
name,
|
||||||
|
addressTitle,
|
||||||
|
addressLine1,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
fax,
|
||||||
|
taxCode,
|
||||||
|
cityCode,
|
||||||
|
wardCode,
|
||||||
|
cityName,
|
||||||
|
wardName,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoice Line Item
|
||||||
|
class InvoiceItem extends Equatable {
|
||||||
|
final String itemName;
|
||||||
|
final String itemCode;
|
||||||
|
final double qty;
|
||||||
|
final double rate;
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
const InvoiceItem({
|
||||||
|
required this.itemName,
|
||||||
|
required this.itemCode,
|
||||||
|
required this.qty,
|
||||||
|
required this.rate,
|
||||||
|
required this.amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [itemName, itemCode, qty, rate, amount];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invoice Entity
|
||||||
|
///
|
||||||
|
/// Contains invoice information from API:
|
||||||
|
/// - name: Invoice ID (e.g., "ACC-SINV-2025-00041")
|
||||||
|
/// - postingDate: Invoice date
|
||||||
|
/// - status: Status label (Vietnamese)
|
||||||
|
/// - statusColor: Status color (Danger, Success, etc.)
|
||||||
|
/// - orderId: Related order ID (nullable)
|
||||||
|
/// - grandTotal: Total amount
|
||||||
|
/// - customerName: Customer name (detail only)
|
||||||
|
/// - sellerInfo: Seller company info (detail only)
|
||||||
|
/// - buyerInfo: Buyer info (detail only)
|
||||||
|
/// - items: Invoice line items (detail only)
|
||||||
|
/// - total: Subtotal before discount (detail only)
|
||||||
|
/// - discountAmount: Discount amount (detail only)
|
||||||
|
class Invoice extends Equatable {
|
||||||
|
/// Invoice ID (e.g., "ACC-SINV-2025-00041")
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// Invoice posting date
|
||||||
|
final DateTime postingDate;
|
||||||
|
|
||||||
|
/// Status label (Vietnamese)
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
/// Status color (Danger, Success, Warning, etc.)
|
||||||
|
final String statusColor;
|
||||||
|
|
||||||
|
/// Related order ID (nullable)
|
||||||
|
final String? orderId;
|
||||||
|
|
||||||
|
/// Grand total amount
|
||||||
|
final double grandTotal;
|
||||||
|
|
||||||
|
// Detail-only fields (nullable for list view)
|
||||||
|
|
||||||
|
/// Customer name
|
||||||
|
final String? customerName;
|
||||||
|
|
||||||
|
/// Seller company information
|
||||||
|
final SellerInfo? sellerInfo;
|
||||||
|
|
||||||
|
/// Buyer information
|
||||||
|
final BuyerInfo? buyerInfo;
|
||||||
|
|
||||||
|
/// Invoice line items
|
||||||
|
final List<InvoiceItem>? items;
|
||||||
|
|
||||||
|
/// Subtotal before discount
|
||||||
|
final double? total;
|
||||||
|
|
||||||
|
/// Discount amount
|
||||||
|
final double? discountAmount;
|
||||||
|
|
||||||
|
const Invoice({
|
||||||
|
required this.name,
|
||||||
|
required this.postingDate,
|
||||||
|
required this.status,
|
||||||
|
required this.statusColor,
|
||||||
|
this.orderId,
|
||||||
|
required this.grandTotal,
|
||||||
|
this.customerName,
|
||||||
|
this.sellerInfo,
|
||||||
|
this.buyerInfo,
|
||||||
|
this.items,
|
||||||
|
this.total,
|
||||||
|
this.discountAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Check if this is a detail invoice (has all detail fields)
|
||||||
|
bool get isDetail => sellerInfo != null && buyerInfo != null && items != null;
|
||||||
|
|
||||||
|
/// Get formatted posting date
|
||||||
|
String get formattedDate {
|
||||||
|
return '${postingDate.day.toString().padLeft(2, '0')}/${postingDate.month.toString().padLeft(2, '0')}/${postingDate.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy with method for immutability
|
||||||
|
Invoice copyWith({
|
||||||
|
String? name,
|
||||||
|
DateTime? postingDate,
|
||||||
|
String? status,
|
||||||
|
String? statusColor,
|
||||||
|
String? orderId,
|
||||||
|
double? grandTotal,
|
||||||
|
String? customerName,
|
||||||
|
SellerInfo? sellerInfo,
|
||||||
|
BuyerInfo? buyerInfo,
|
||||||
|
List<InvoiceItem>? items,
|
||||||
|
double? total,
|
||||||
|
double? discountAmount,
|
||||||
|
}) {
|
||||||
|
return Invoice(
|
||||||
|
name: name ?? this.name,
|
||||||
|
postingDate: postingDate ?? this.postingDate,
|
||||||
|
status: status ?? this.status,
|
||||||
|
statusColor: statusColor ?? this.statusColor,
|
||||||
|
orderId: orderId ?? this.orderId,
|
||||||
|
grandTotal: grandTotal ?? this.grandTotal,
|
||||||
|
customerName: customerName ?? this.customerName,
|
||||||
|
sellerInfo: sellerInfo ?? this.sellerInfo,
|
||||||
|
buyerInfo: buyerInfo ?? this.buyerInfo,
|
||||||
|
items: items ?? this.items,
|
||||||
|
total: total ?? this.total,
|
||||||
|
discountAmount: discountAmount ?? this.discountAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
name,
|
||||||
|
postingDate,
|
||||||
|
status,
|
||||||
|
statusColor,
|
||||||
|
orderId,
|
||||||
|
grandTotal,
|
||||||
|
customerName,
|
||||||
|
sellerInfo,
|
||||||
|
buyerInfo,
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
discountAmount,
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'Invoice(name: $name, status: $status, grandTotal: $grandTotal)';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/// Invoice Repository Interface
|
||||||
|
///
|
||||||
|
/// Defines the contract for invoice-related data operations.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:worker/features/invoices/domain/entities/invoice.dart';
|
||||||
|
|
||||||
|
/// Invoice Repository Interface
|
||||||
|
abstract class InvoiceRepository {
|
||||||
|
/// Get list of invoices
|
||||||
|
Future<List<Invoice>> getInvoicesList({
|
||||||
|
int limitStart = 0,
|
||||||
|
int limitPageLength = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Get invoice detail by ID
|
||||||
|
Future<Invoice> getInvoiceDetail(String name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
/// Page: Invoice Detail Page
|
||||||
|
///
|
||||||
|
/// Displays invoice detail following html/invoice-detail.html design.
|
||||||
|
library;
|
||||||
|
|
||||||
|
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:share_plus/share_plus.dart';
|
||||||
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
|
import 'package:worker/core/utils/extensions.dart';
|
||||||
|
import 'package:worker/features/invoices/domain/entities/invoice.dart';
|
||||||
|
import 'package:worker/features/invoices/presentation/providers/invoices_provider.dart';
|
||||||
|
|
||||||
|
/// Invoice Detail Page
|
||||||
|
///
|
||||||
|
/// Features:
|
||||||
|
/// - Invoice header with status
|
||||||
|
/// - Seller and buyer information (2-column grid)
|
||||||
|
/// - Product list table with unit price
|
||||||
|
/// - Invoice summary (total, discount, grand total)
|
||||||
|
/// - Share and contact support actions
|
||||||
|
class InvoiceDetailPage extends ConsumerWidget {
|
||||||
|
const InvoiceDetailPage({
|
||||||
|
super.key,
|
||||||
|
required this.invoiceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String invoiceId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final invoiceAsync = ref.watch(invoiceDetailProvider(invoiceId));
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'Chi tiết Hóa đơn',
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevation: AppBarSpecs.elevation,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
centerTitle: false,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: FaIcon(
|
||||||
|
FontAwesomeIcons.shareNodes,
|
||||||
|
size: 20,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
onPressed: () => _shareInvoice(context),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.sm),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: invoiceAsync.when(
|
||||||
|
data: (invoice) => _buildContent(context, invoice),
|
||||||
|
loading: () => const CustomLoadingIndicator(),
|
||||||
|
error: (error, stack) => _buildErrorState(context, ref, error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context, Invoice invoice) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Invoice Header Card
|
||||||
|
_buildHeaderCard(context, invoice),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Products Section
|
||||||
|
_buildProductsCard(context, invoice),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Action Button
|
||||||
|
_buildActionButton(context),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build invoice header card
|
||||||
|
Widget _buildHeaderCard(BuildContext context, Invoice invoice) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outline.withValues(alpha: 0.1),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow.withValues(alpha: 0.08),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Invoice Header Section (centered)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Invoice Icon
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colorScheme.primary,
|
||||||
|
colorScheme.primary.withValues(alpha: 0.8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: FaIcon(
|
||||||
|
FontAwesomeIcons.fileInvoiceDollar,
|
||||||
|
size: 32,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
'HÓA ĐƠN GTGT',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Invoice Number
|
||||||
|
Text(
|
||||||
|
'#${invoice.name}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Status Badge
|
||||||
|
_StatusBadge(
|
||||||
|
status: invoice.status,
|
||||||
|
statusColor: invoice.statusColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Invoice Meta Info
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_MetaItem(label: 'Ngày xuất:', value: invoice.formattedDate),
|
||||||
|
if (invoice.orderId != null) ...[
|
||||||
|
const SizedBox(width: 32),
|
||||||
|
_MetaItem(label: 'Đơn hàng:', value: '#${invoice.orderId}'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Company Information Section (2-column grid)
|
||||||
|
if (invoice.sellerInfo != null || invoice.buyerInfo != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildCompanyInfoSection(context, invoice),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build company info section (seller and buyer) - 2 column grid
|
||||||
|
Widget _buildCompanyInfoSection(BuildContext context, Invoice invoice) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final isWideScreen = screenWidth > 600;
|
||||||
|
|
||||||
|
if (isWideScreen) {
|
||||||
|
// 2-column grid for wide screens
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Seller Info
|
||||||
|
if (invoice.sellerInfo != null)
|
||||||
|
Expanded(
|
||||||
|
child: _CompanyInfoBlock(
|
||||||
|
icon: FontAwesomeIcons.building,
|
||||||
|
iconColor: colorScheme.primary,
|
||||||
|
title: 'Đơn vị bán hàng',
|
||||||
|
lines: _buildSellerInfoLines(invoice),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (invoice.sellerInfo != null && invoice.buyerInfo != null)
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
|
||||||
|
// Buyer Info
|
||||||
|
if (invoice.buyerInfo != null)
|
||||||
|
Expanded(
|
||||||
|
child: _CompanyInfoBlock(
|
||||||
|
icon: FontAwesomeIcons.userTie,
|
||||||
|
iconColor: Colors.green.shade600,
|
||||||
|
title: 'Đơn vị mua hàng',
|
||||||
|
lines: _buildBuyerInfoLines(invoice),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Single column for narrow screens
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Seller Info
|
||||||
|
if (invoice.sellerInfo != null)
|
||||||
|
_CompanyInfoBlock(
|
||||||
|
icon: FontAwesomeIcons.building,
|
||||||
|
iconColor: colorScheme.primary,
|
||||||
|
title: 'Đơn vị bán hàng',
|
||||||
|
lines: _buildSellerInfoLines(invoice),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (invoice.sellerInfo != null && invoice.buyerInfo != null)
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Buyer Info
|
||||||
|
if (invoice.buyerInfo != null)
|
||||||
|
_CompanyInfoBlock(
|
||||||
|
icon: FontAwesomeIcons.userTie,
|
||||||
|
iconColor: Colors.green.shade600,
|
||||||
|
title: 'Đơn vị mua hàng',
|
||||||
|
lines: _buildBuyerInfoLines(invoice),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_InfoLine> _buildSellerInfoLines(Invoice invoice) {
|
||||||
|
return [
|
||||||
|
if (invoice.sellerInfo!.companyName != null)
|
||||||
|
_InfoLine(label: 'Công ty', value: invoice.sellerInfo!.companyName!),
|
||||||
|
if (invoice.sellerInfo!.taxCode != null)
|
||||||
|
_InfoLine(label: 'Mã số thuế', value: invoice.sellerInfo!.taxCode!),
|
||||||
|
if (invoice.sellerInfo!.fullAddress.isNotEmpty)
|
||||||
|
_InfoLine(label: 'Địa chỉ', value: invoice.sellerInfo!.fullAddress),
|
||||||
|
if (invoice.sellerInfo!.phone != null)
|
||||||
|
_InfoLine(label: 'Điện thoại', value: invoice.sellerInfo!.phone!),
|
||||||
|
if (invoice.sellerInfo!.email != null)
|
||||||
|
_InfoLine(label: 'Email', value: invoice.sellerInfo!.email!),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_InfoLine> _buildBuyerInfoLines(Invoice invoice) {
|
||||||
|
return [
|
||||||
|
if (invoice.buyerInfo!.name != null)
|
||||||
|
_InfoLine(label: 'Người mua hàng', value: invoice.buyerInfo!.name!),
|
||||||
|
if (invoice.customerName != null)
|
||||||
|
_InfoLine(label: 'Tên đơn vị', value: invoice.customerName!),
|
||||||
|
if (invoice.buyerInfo!.taxCode != null)
|
||||||
|
_InfoLine(label: 'Mã số thuế', value: invoice.buyerInfo!.taxCode!),
|
||||||
|
if (invoice.buyerInfo!.fullAddress.isNotEmpty)
|
||||||
|
_InfoLine(label: 'Địa chỉ', value: invoice.buyerInfo!.fullAddress),
|
||||||
|
if (invoice.buyerInfo!.phone != null)
|
||||||
|
_InfoLine(label: 'Điện thoại', value: invoice.buyerInfo!.phone!),
|
||||||
|
if (invoice.buyerInfo!.email != null)
|
||||||
|
_InfoLine(label: 'Email', value: invoice.buyerInfo!.email!),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build products card
|
||||||
|
Widget _buildProductsCard(BuildContext context, Invoice invoice) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outline.withValues(alpha: 0.1),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colorScheme.shadow.withValues(alpha: 0.08),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Section Title
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
FaIcon(
|
||||||
|
FontAwesomeIcons.boxOpen,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Chi tiết hàng hóa',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Products Table
|
||||||
|
if (invoice.items != null && invoice.items!.isNotEmpty)
|
||||||
|
_buildProductsTable(context, invoice)
|
||||||
|
else
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Text(
|
||||||
|
'Không có thông tin sản phẩm',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Invoice Summary
|
||||||
|
_buildInvoiceSummary(context, invoice),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build products table - with Đơn giá column
|
||||||
|
Widget _buildProductsTable(BuildContext context, Invoice invoice) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// Table Header
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(8)),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// # column
|
||||||
|
SizedBox(
|
||||||
|
width: 32,
|
||||||
|
child: Text(
|
||||||
|
'#',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Tên hàng hóa column
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Text(
|
||||||
|
'Tên hàng hóa',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Số lượng column
|
||||||
|
SizedBox(
|
||||||
|
width: 55,
|
||||||
|
child: Text(
|
||||||
|
'SL',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Đơn giá column
|
||||||
|
SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: Text(
|
||||||
|
'Đơn giá',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Thành tiền column
|
||||||
|
SizedBox(
|
||||||
|
width: 90,
|
||||||
|
child: Text(
|
||||||
|
'Thành tiền',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Table Body
|
||||||
|
...invoice.items!.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final item = entry.value;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// # column
|
||||||
|
SizedBox(
|
||||||
|
width: 32,
|
||||||
|
child: Text(
|
||||||
|
'${index + 1}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Tên hàng hóa column
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.itemName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'SKU: ${item.itemCode}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Số lượng column
|
||||||
|
SizedBox(
|
||||||
|
width: 55,
|
||||||
|
child: Text(
|
||||||
|
item.qty.toStringAsFixed(item.qty.truncateToDouble() == item.qty ? 0 : 2),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Đơn giá column
|
||||||
|
SizedBox(
|
||||||
|
width: 80,
|
||||||
|
child: Text(
|
||||||
|
item.rate.toVNCurrency,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Thành tiền column
|
||||||
|
SizedBox(
|
||||||
|
width: 90,
|
||||||
|
child: Text(
|
||||||
|
item.amount.toVNCurrency,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build invoice summary
|
||||||
|
Widget _buildInvoiceSummary(BuildContext context, Invoice invoice) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerLowest,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Subtotal
|
||||||
|
if (invoice.total != null)
|
||||||
|
_SummaryRow(
|
||||||
|
label: 'Tổng tiền hàng:',
|
||||||
|
value: invoice.total!.toVNCurrency,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Discount
|
||||||
|
if (invoice.discountAmount != null && invoice.discountAmount! > 0)
|
||||||
|
_SummaryRow(
|
||||||
|
label: 'Chiết khấu:',
|
||||||
|
value: '-${invoice.discountAmount!.toVNCurrency}',
|
||||||
|
valueColor: const Color(0xFF059669),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Grand Total
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
margin: const EdgeInsets.only(top: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: colorScheme.outlineVariant, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'TỔNG THANH TOÁN:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
invoice.grandTotal.toVNCurrency,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFFDC2626),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build action button
|
||||||
|
Widget _buildActionButton(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _contactSupport(context),
|
||||||
|
icon: const FaIcon(FontAwesomeIcons.comments, size: 18),
|
||||||
|
label: const Text('Liên hệ hỗ trợ'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.onSurface,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 20),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
side: BorderSide(color: colorScheme.outlineVariant, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build error state
|
||||||
|
Widget _buildErrorState(BuildContext context, WidgetRef ref, Object error) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
FaIcon(
|
||||||
|
FontAwesomeIcons.circleExclamation,
|
||||||
|
size: 64,
|
||||||
|
color: colorScheme.error.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Có lỗi xảy ra',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Text(
|
||||||
|
error.toString(),
|
||||||
|
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => ref.invalidate(invoiceDetailProvider(invoiceId)),
|
||||||
|
child: const Text('Thử lại'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Share invoice
|
||||||
|
void _shareInvoice(BuildContext context) {
|
||||||
|
SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
text: 'Chi tiết hóa đơn #$invoiceId - EuroTile Worker',
|
||||||
|
subject: 'Hóa đơn #$invoiceId',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contact support
|
||||||
|
void _contactSupport(BuildContext context) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Hotline hỗ trợ: 1900 1234'),
|
||||||
|
duration: Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status Badge Widget - with uppercase text
|
||||||
|
class _StatusBadge extends StatelessWidget {
|
||||||
|
const _StatusBadge({
|
||||||
|
required this.status,
|
||||||
|
required this.statusColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String status;
|
||||||
|
final String statusColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
Color backgroundColor;
|
||||||
|
Color textColor;
|
||||||
|
|
||||||
|
switch (statusColor.toLowerCase()) {
|
||||||
|
case 'success':
|
||||||
|
backgroundColor = const Color(0xFFD1FAE5);
|
||||||
|
textColor = const Color(0xFF065F46);
|
||||||
|
case 'danger':
|
||||||
|
backgroundColor = const Color(0xFFFEF3C7);
|
||||||
|
textColor = const Color(0xFFD97706);
|
||||||
|
case 'warning':
|
||||||
|
backgroundColor = const Color(0xFFFEF3C7);
|
||||||
|
textColor = const Color(0xFFD97706);
|
||||||
|
case 'info':
|
||||||
|
backgroundColor = const Color(0xFFE0E7FF);
|
||||||
|
textColor = const Color(0xFF3730A3);
|
||||||
|
default:
|
||||||
|
backgroundColor = const Color(0xFFF3F4F6);
|
||||||
|
textColor = const Color(0xFF6B7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
status.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: textColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Meta Item Widget
|
||||||
|
class _MetaItem extends StatelessWidget {
|
||||||
|
const _MetaItem({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Company Info Block Widget
|
||||||
|
class _CompanyInfoBlock extends StatelessWidget {
|
||||||
|
const _CompanyInfoBlock({
|
||||||
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.title,
|
||||||
|
required this.lines,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color iconColor;
|
||||||
|
final String title;
|
||||||
|
final List<_InfoLine> lines;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
FaIcon(icon, size: 16, color: iconColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...lines,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Info Line Widget
|
||||||
|
class _InfoLine extends StatelessWidget {
|
||||||
|
const _InfoLine({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
|
child: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '$label: ',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary Row Widget
|
||||||
|
class _SummaryRow extends StatelessWidget {
|
||||||
|
const _SummaryRow({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.valueColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color? valueColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: valueColor ?? colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user