Compare commits
19 Commits
359c31a4d4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4546e7d8e8 | ||
|
|
fc6a4f038e | ||
|
|
e3632d4445 | ||
|
|
f130820131 | ||
|
|
4cfe000172 | ||
|
|
597c6a0e57 | ||
|
|
e0a9b3b9f4 | ||
|
|
b9b6d91a87 | ||
|
|
d4de557662 | ||
|
|
8ff7b3b505 | ||
|
|
2a14f82b72 | ||
|
|
2dadcc5ce1 | ||
|
|
27798cc234 | ||
|
|
e1c9f818d2 | ||
|
|
cae04b3ae7 | ||
|
|
9fb4ba621b | ||
|
|
19d9a3dc2d | ||
|
|
fc9b5e967f | ||
|
|
211ebdf1d8 |
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}'
|
}'
|
||||||
|
|||||||
@@ -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 */,
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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"}}}}}}
|
||||||
@@ -8,78 +8,6 @@
|
|||||||
<link rel="stylesheet" href="assets/css/style.css">
|
<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">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
</head>
|
</head>
|
||||||
<style>
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
animation: slideUp 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from { transform: translateY(20px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
padding: 20px;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: 20px;
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
color: #6b7280;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.document-card {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.download-btn {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-item.active {
|
|
||||||
background: var(--primary-blue);
|
|
||||||
color: var(--white);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<body>
|
<body>
|
||||||
<div class="page-wrapper">
|
<div class="page-wrapper">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
@@ -88,15 +16,12 @@
|
|||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="header-title">Lịch sử điểm</h1>
|
<h1 class="header-title">Lịch sử điểm</h1>
|
||||||
<!--<div style="width: 32px;"></div>-->
|
<div style="width: 32px;"></div>
|
||||||
<button class="back-button" onclick="openInfoModal()">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Filter Section -->
|
<!-- Filter Section -->
|
||||||
<!--<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="d-flex justify-between align-center">
|
<div class="d-flex justify-between align-center">
|
||||||
<h3 class="card-title">Bộ lọc</h3>
|
<h3 class="card-title">Bộ lọc</h3>
|
||||||
<i class="fas fa-filter" style="color: var(--primary-blue);"></i>
|
<i class="fas fa-filter" style="color: var(--primary-blue);"></i>
|
||||||
@@ -104,185 +29,222 @@
|
|||||||
<p class="text-muted" style="font-size: 12px; margin-top: 8px;">
|
<p class="text-muted" style="font-size: 12px; margin-top: 8px;">
|
||||||
Thời gian hiệu lực: 01/01/2023 - 31/12/2023
|
Thời gian hiệu lực: 01/01/2023 - 31/12/2023
|
||||||
</p>
|
</p>
|
||||||
</div>-->
|
</div>
|
||||||
|
|
||||||
<!-- Points History List -->
|
<!-- Points History List -->
|
||||||
<div class="points-history-list">
|
<div class="points-history-list">
|
||||||
<!-- Transaction Item 1 -->
|
<!-- Transaction Card 1 -->
|
||||||
<div class="card mb-3">
|
<div class="transaction-card">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="card-header">
|
||||||
<div style="flex: 1;">
|
<span class="transaction-code">GD-00083</span>
|
||||||
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;">
|
<span class="transaction-date">28/09/2023</span>
|
||||||
Giao dịch mua hàng 00083
|
|
||||||
</h4>
|
|
||||||
<p class="text-muted" style="font-size: 12px;">
|
|
||||||
Thời gian: 28/09/2023 17:23:18
|
|
||||||
</p>
|
|
||||||
<p class="text-muted" style="font-size: 12px;">
|
|
||||||
Giao dịch: 100.000.000 VND
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn-complaint" onclick="openComplaint(this)">
|
|
||||||
Khiếu nại
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
<div class="d-flex justify-end align-center" style="margin-top: 12px;">
|
<div class="transaction-desc">
|
||||||
<div style="text-align: right;">
|
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Giao dịch mua hàng</div>
|
||||||
<div style="color: var(--success-color); font-weight: 500;">+3</div>
|
<div style="font-size: 13px; color: #999;">Mã tham chiếu: #HD-2023-00083</div>
|
||||||
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="transaction-points positive">
|
||||||
|
+3 điểm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="balance-after">Số dư sau: <strong>604 điểm</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Transaction Item 2 -->
|
<!-- Transaction Card 2 -->
|
||||||
<div class="card mb-3">
|
<div class="transaction-card">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="card-header">
|
||||||
<div style="flex: 1;">
|
<span class="transaction-code">GD-00081</span>
|
||||||
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;">
|
<span class="transaction-date">27/09/2023</span>
|
||||||
Giao dịch mua hàng 00081
|
|
||||||
</h4>
|
|
||||||
<p class="text-muted" style="font-size: 12px;">
|
|
||||||
Thời gian: 27/09/2023 17:23:18
|
|
||||||
</p>
|
|
||||||
<p class="text-muted" style="font-size: 12px;">
|
|
||||||
Giao dịch: 200.000.000 VND
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn-complaint" onclick="openComplaint(this)">
|
|
||||||
Khiếu nại
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
<div class="d-flex justify-end align-center" style="margin-top: 12px;">
|
<div class="transaction-desc">
|
||||||
<div style="text-align: right;">
|
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Giao dịch mua hàng</div>
|
||||||
<div style="color: var(--text-dark); font-weight: 500;">0</div>
|
<div style="font-size: 13px; color: #999;">Mã tham chiếu: #HD-2023-00081</div>
|
||||||
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="transaction-points neutral">
|
||||||
|
0 điểm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="balance-after">Số dư sau: <strong>601 điểm</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Transaction Item 3 -->
|
<!-- Transaction Card 3 -->
|
||||||
<div class="card mb-3">
|
<div class="transaction-card">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="card-header">
|
||||||
<div style="flex: 1;">
|
<span class="transaction-code">EXP-2023-001</span>
|
||||||
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;">
|
<span class="transaction-date">20/09/2023</span>
|
||||||
Điểm thưởng hết hạn
|
|
||||||
</h4>
|
|
||||||
<p class="text-muted" style="font-size: 12px;">
|
|
||||||
Thời gian: 20/09/2023 17:23:18
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn-complaint" onclick="openComplaint(this)">
|
|
||||||
Khiếu nại
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
<div class="d-flex justify-end align-center" style="margin-top: 12px;">
|
<div class="transaction-desc">
|
||||||
<div style="text-align: right;">
|
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Điểm thưởng hết hạn</div>
|
||||||
<div style="color: var(--danger-color); font-weight: 500;">-5</div>
|
<div style="font-size: 13px; color: #999;">Hết hạn sử dụng</div>
|
||||||
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="transaction-points negative">
|
||||||
|
-5 điểm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="balance-after">Số dư sau: <strong>601 điểm</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Transaction Item 4 -->
|
<!-- Transaction Card 4 -->
|
||||||
<div class="card mb-3">
|
<div class="transaction-card">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="card-header">
|
||||||
<div style="flex: 1;">
|
<span class="transaction-code">RDM-2023-042</span>
|
||||||
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;">
|
<span class="transaction-date">19/09/2023</span>
|
||||||
Đổi Voucher HSG
|
|
||||||
</h4>
|
|
||||||
<p class="text-muted" style="font-size: 12px;">
|
|
||||||
Thời gian: 19/09/2023 17:23:18
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn-complaint" onclick="openComplaint(this)">
|
|
||||||
Khiếu nại
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
<div class="d-flex justify-end align-center" style="margin-top: 12px;">
|
<div class="transaction-desc">
|
||||||
<div style="text-align: right;">
|
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Đổi Voucher giảm giá</div>
|
||||||
<div style="color: var(--danger-color); font-weight: 500;">-500</div>
|
<div style="font-size: 13px; color: #999;">Voucher 5.000.000đ - HSG</div>
|
||||||
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="transaction-points negative">
|
||||||
|
-500 điểm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="balance-after">Số dư sau: <strong>606 điểm</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Transaction Item 5 -->
|
<!-- Transaction Card 5 -->
|
||||||
<div class="card mb-3">
|
<div class="transaction-card">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="card-header">
|
||||||
<div style="flex: 1;">
|
<span class="transaction-code">REF-2023-128</span>
|
||||||
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;">
|
<span class="transaction-date">10/09/2023</span>
|
||||||
Giới thiệu người dùng
|
|
||||||
</h4>
|
|
||||||
<p class="text-muted" style="font-size: 12px;">
|
|
||||||
Thời gian: 10/09/2023 17:23:18
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn-complaint" onclick="openComplaint(this)">
|
|
||||||
Khiếu nại
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
<div class="d-flex justify-end align-center" style="margin-top: 12px;">
|
<div class="transaction-desc">
|
||||||
<div style="text-align: right;">
|
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Giới thiệu thành công</div>
|
||||||
<div style="color: var(--success-color); font-weight: 500;">+5</div>
|
<div style="font-size: 13px; color: #999;">Giới thiệu: Nguyễn Văn A</div>
|
||||||
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="transaction-points positive">
|
||||||
|
+5 điểm
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="balance-after">Số dư sau: <strong>1.106 điểm</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Transaction Item 6 -->
|
<!-- Transaction Card 6 -->
|
||||||
<div class="card mb-3">
|
<div class="transaction-card">
|
||||||
<div class="d-flex justify-between align-start mb-2">
|
<div class="card-header">
|
||||||
<div style="flex: 1;">
|
<span class="transaction-code">RDM-2023-038</span>
|
||||||
<h4 style="color: var(--primary-blue); font-weight: 500; margin-bottom: 4px;">
|
<span class="transaction-date">05/09/2023</span>
|
||||||
Đổi quà
|
|
||||||
</h4>
|
|
||||||
<p class="text-muted" style="font-size: 12px;">
|
|
||||||
Thời gian: 19/09/2023 17:23:18
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button class="btn-complaint" onclick="openComplaint(this)">
|
|
||||||
Khiếu nại
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
<div class="d-flex justify-end align-center" style="margin-top: 12px;">
|
<div class="transaction-desc">
|
||||||
<div style="text-align: right;">
|
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">Đổi quà tặng</div>
|
||||||
<div style="color: var(--danger-color); font-weight: 500;">-200</div>
|
<div style="font-size: 13px; color: #999;">Tai nghe Bluetooth Sony WH-1000XM4</div>
|
||||||
<div style="color: var(--primary-blue); font-size: 12px;">Điểm mới: 604</div>
|
</div>
|
||||||
|
<div class="transaction-points negative">
|
||||||
|
-200 điểm
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="card-footer">
|
||||||
</div>
|
<span class="balance-after">Số dư sau: <strong>1.101 điểm</strong></span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Info Modal -->
|
|
||||||
<div id="infoModal" class="modal-overlay" style="display: none;">
|
|
||||||
<div class="modal-content info-modal">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h3 class="modal-title" style="font-weight: bold;">Hướng dẫn sử dụng</h3>
|
|
||||||
<button class="modal-close" onclick="closeInfoModal()">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>Đây là nội dung hướng dẫn sử dụng cho tính năng Lịch sử điểm:</p>
|
|
||||||
<ul class="list-disc ml-6 mt-3">
|
|
||||||
<li>Đây là sao kê chi tiết tất cả các giao dịch cộng/trừ điểm của bạn.</li>
|
|
||||||
<li>Bạn có thể kiểm tra điểm được cộng từ đơn hàng, từ việc đăng ký công trình, hoặc điểm bị trừ khi đổi quà.</li>
|
|
||||||
<li>Nếu phát hiện giao dịch bị sai sót, hãy bấm nút "Khiếu nại" trên dòng giao dịch đó để gửi yêu cầu hỗ trợ.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-primary" onclick="closeInfoModal()">Đóng</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Modern Transaction Card Styles */
|
||||||
|
.transaction-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-card .card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-code {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #005B9A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-card .card-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-desc {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-points {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-points.positive {
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-points.negative {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-points.neutral {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-card .card-footer {
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-after {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-after strong {
|
||||||
|
color: #005B9A;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.transaction-points {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function openComplaint(buttonElement) {
|
function openComplaint(buttonElement) {
|
||||||
// Get transaction info from the card
|
// Get transaction info from the card
|
||||||
@@ -302,21 +264,6 @@
|
|||||||
|
|
||||||
window.location.href = `point-complaint.html?${params.toString()}`;
|
window.location.href = `point-complaint.html?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openInfoModal() {
|
|
||||||
document.getElementById('infoModal').style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeInfoModal() {
|
|
||||||
document.getElementById('infoModal').style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (e.target.classList.contains('modal-overlay')) {
|
|
||||||
e.target.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -319,7 +319,124 @@
|
|||||||
color: #0369a1;
|
color: #0369a1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-row {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-row-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px dashed #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-row-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-product {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove-product:hover {
|
||||||
|
background: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-input-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-input-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-label.required::after {
|
||||||
|
content: " *";
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-subtotal {
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 2px dashed #005B9A;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-subtotal-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-subtotal-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #005B9A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-product {
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
border: 2px dashed var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-product:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
background: linear-gradient(135deg, #eff6ff, #dbeafe);
|
||||||
|
border: 2px solid #005B9A;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
.product-input-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.content {
|
.content {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -403,44 +520,9 @@
|
|||||||
placeholder="Nhập số hóa đơn (nếu có)"
|
placeholder="Nhập số hóa đơn (nếu có)"
|
||||||
onchange="validateForm()">
|
onchange="validateForm()">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Total Amount -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label required">Tổng giá trị đơn hàng (VNĐ)</label>
|
|
||||||
<input type="number"
|
|
||||||
class="form-input"
|
|
||||||
id="totalAmount"
|
|
||||||
placeholder="0"
|
|
||||||
min="0"
|
|
||||||
step="1000"
|
|
||||||
required
|
|
||||||
oninput="calculatePoints(); validateForm()">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Points Estimate -->
|
|
||||||
<!--<div class="points-estimate" id="pointsEstimate">
|
|
||||||
<div class="estimate-title">Điểm dự kiến nhận được</div>
|
|
||||||
<div class="estimate-text" id="estimateText">0 điểm</div>
|
|
||||||
</div>-->
|
|
||||||
|
|
||||||
<!-- Products Purchased -->
|
|
||||||
<!--<div class="form-group">
|
|
||||||
<label class="form-label">Sản phẩm đã mua</label>
|
|
||||||
<textarea class="form-input form-textarea"
|
|
||||||
id="products"
|
|
||||||
placeholder="Mô tả các sản phẩm đã mua (tùy chọn)"
|
|
||||||
rows="3"></textarea>
|
|
||||||
</div>-->
|
|
||||||
|
|
||||||
<!-- Points Estimate -->
|
|
||||||
<div class="points-estimate" id="pointsEstimate">
|
|
||||||
<div class="estimate-title">Điểm dự kiến nhận được</div>
|
|
||||||
<div class="estimate-text" id="estimateText">0 điểm</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Company Information -->
|
<!-- Company Information -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Tên công ty</label>
|
<label class="form-label">Tên đơn vị mua hàng</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
id="companyName"
|
id="companyName"
|
||||||
@@ -454,26 +536,41 @@
|
|||||||
id="taxCode"
|
id="taxCode"
|
||||||
placeholder="Nhập mã số thuế (nếu có)">
|
placeholder="Nhập mã số thuế (nếu có)">
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Dynamic Product List -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Số lượng (m²) đã mua</label>
|
<label class="form-label required">Danh sách sản phẩm</label>
|
||||||
<input type="number"
|
<div id="productsList">
|
||||||
class="form-input"
|
<!-- Product rows will be added here dynamically -->
|
||||||
id="squareMeters"
|
</div>
|
||||||
placeholder="0"
|
<button type="button" onclick="addProductRow()" class="btn-add-product">
|
||||||
min="0"
|
<i class="fas fa-plus-circle"></i>
|
||||||
step="0.01">
|
Thêm sản phẩm
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Products Purchased -->
|
<!-- Order Summary (Auto-calculated) -->
|
||||||
<div class="form-group">
|
<div class="summary-card">
|
||||||
<label class="form-label">Sản phẩm đã mua</label>
|
<h3 style="font-size: 16px; font-weight: 600; color: #333; margin-bottom: 16px;">
|
||||||
<textarea class="form-input form-textarea"
|
<i class="fas fa-calculator" style="color: #005B9A; margin-right: 8px;"></i>
|
||||||
id="products"
|
Tổng kết đơn hàng
|
||||||
placeholder="Mô tả các sản phẩm đã mua (tùy chọn)"
|
</h3>
|
||||||
rows="3"></textarea>
|
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; padding-bottom: 12px; border-bottom: 1px dashed #e5e7eb;">
|
||||||
|
<span style="color: #666; font-size: 15px;">Tổng số lượng:</span>
|
||||||
|
<span id="totalSquareMeters" style="color: #333; font-weight: 600; font-size: 15px;">0 m²</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between;">
|
||||||
|
<span style="color: #666; font-size: 15px;">Tổng giá trị đơn hàng:</span>
|
||||||
|
<span id="totalAmount" style="color: #005B9A; font-weight: 700; font-size: 18px;">0 đ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Points Estimate -->
|
||||||
|
<div class="points-estimate" id="pointsEstimate">
|
||||||
|
<div class="estimate-title">Điểm dự kiến nhận được</div>
|
||||||
|
<div class="estimate-text" id="estimateText">0 điểm</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Invoice Images -->
|
<!-- Invoice Images -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -504,13 +601,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Notes -->
|
<!-- Additional Notes -->
|
||||||
<div class="form-group">
|
<!--<div class="form-group">
|
||||||
<label class="form-label">Ghi chú thêm</label>
|
<label class="form-label">Ghi chú thêm</label>
|
||||||
<textarea class="form-input form-textarea"
|
<textarea class="form-input form-textarea"
|
||||||
id="notes"
|
id="notes"
|
||||||
placeholder="Ghi chú thêm về đơn hàng (tùy chọn)"
|
placeholder="Ghi chú thêm về đơn hàng (tùy chọn)"
|
||||||
rows="3"></textarea>
|
rows="3"></textarea>
|
||||||
</div>
|
</div>-->
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<button type="submit" class="submit-button" id="submitButton" disabled>
|
<button type="submit" class="submit-button" id="submitButton" disabled>
|
||||||
@@ -523,26 +620,153 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
let selectedFiles = [];
|
let selectedFiles = [];
|
||||||
|
let productCounter = 0;
|
||||||
|
let products = [];
|
||||||
|
|
||||||
// Set max date to today
|
// Set max date to today
|
||||||
document.getElementById('purchaseDate').max = new Date().toISOString().split('T')[0];
|
document.getElementById('purchaseDate').max = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Add first product row on page load
|
||||||
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
|
addProductRow();
|
||||||
|
});
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
window.history.back();
|
window.history.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addProductRow() {
|
||||||
|
productCounter++;
|
||||||
|
const productsList = document.getElementById('productsList');
|
||||||
|
|
||||||
|
const productRow = document.createElement('div');
|
||||||
|
productRow.className = 'product-row';
|
||||||
|
productRow.id = `product-${productCounter}`;
|
||||||
|
productRow.innerHTML = `
|
||||||
|
<div class="product-row-header">
|
||||||
|
<span class="product-row-title">
|
||||||
|
<i class="fas fa-box"></i> Sản phẩm #${productCounter}
|
||||||
|
</span>
|
||||||
|
<button type="button" onclick="removeProductRow(${productCounter})" class="btn-remove-product">
|
||||||
|
<i class="fas fa-trash"></i> Xóa
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="product-input-full">
|
||||||
|
<label class="product-label required">Tên sản phẩm</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-input"
|
||||||
|
id="productName-${productCounter}"
|
||||||
|
placeholder="Ví dụ: Gạch Granite 60x60"
|
||||||
|
required
|
||||||
|
onchange="updateCalculations()">
|
||||||
|
</div>
|
||||||
|
<div class="product-input-group">
|
||||||
|
<div>
|
||||||
|
<label class="product-label required">Số lượng (m²)</label>
|
||||||
|
<input type="number"
|
||||||
|
class="form-input"
|
||||||
|
id="productQty-${productCounter}"
|
||||||
|
placeholder="0"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
oninput="updateCalculations()">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="product-label required">Đơn giá (VNĐ/m²)</label>
|
||||||
|
<input type="number"
|
||||||
|
class="form-input"
|
||||||
|
id="productPrice-${productCounter}"
|
||||||
|
placeholder="0"
|
||||||
|
min="0"
|
||||||
|
step="1000"
|
||||||
|
required
|
||||||
|
oninput="updateCalculations()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="product-subtotal">
|
||||||
|
<div class="product-subtotal-label">Thành tiền</div>
|
||||||
|
<div class="product-subtotal-value" id="productSubtotal-${productCounter}">0 đ</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
productsList.appendChild(productRow);
|
||||||
|
products.push(productCounter);
|
||||||
|
updateCalculations();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProductRow(id) {
|
||||||
|
if (products.length <= 1) {
|
||||||
|
alert('Cần ít nhất một sản phẩm!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = document.getElementById(`product-${id}`);
|
||||||
|
if (row) {
|
||||||
|
row.remove();
|
||||||
|
products = products.filter(p => p !== id);
|
||||||
|
updateCalculations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCalculations() {
|
||||||
|
let totalQty = 0;
|
||||||
|
let totalValue = 0;
|
||||||
|
|
||||||
|
products.forEach(id => {
|
||||||
|
const qtyInput = document.getElementById(`productQty-${id}`);
|
||||||
|
const priceInput = document.getElementById(`productPrice-${id}`);
|
||||||
|
const subtotalEl = document.getElementById(`productSubtotal-${id}`);
|
||||||
|
|
||||||
|
if (qtyInput && priceInput && subtotalEl) {
|
||||||
|
const qty = parseFloat(qtyInput.value) || 0;
|
||||||
|
const price = parseFloat(priceInput.value) || 0;
|
||||||
|
const subtotal = qty * price;
|
||||||
|
|
||||||
|
totalQty += qty;
|
||||||
|
totalValue += subtotal;
|
||||||
|
|
||||||
|
subtotalEl.textContent = formatCurrency(subtotal);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update summary
|
||||||
|
document.getElementById('totalSquareMeters').textContent = totalQty.toFixed(2) + ' m²';
|
||||||
|
document.getElementById('totalAmount').textContent = formatCurrency(totalValue);
|
||||||
|
|
||||||
|
// Calculate points
|
||||||
|
calculatePoints(totalValue);
|
||||||
|
validateForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value) {
|
||||||
|
return new Intl.NumberFormat('vi-VN', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'VND',
|
||||||
|
minimumFractionDigits: 0
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
function validateForm() {
|
function validateForm() {
|
||||||
const purchaseDate = document.getElementById('purchaseDate').value;
|
const purchaseDate = document.getElementById('purchaseDate').value;
|
||||||
const storeLocation = document.getElementById('storeLocation').value;
|
|
||||||
const totalAmount = document.getElementById('totalAmount').value;
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|
||||||
const isValid = purchaseDate && storeLocation && totalAmount && hasFiles;
|
// Check if at least one product is filled
|
||||||
|
let hasValidProduct = false;
|
||||||
|
products.forEach(id => {
|
||||||
|
const name = document.getElementById(`productName-${id}`).value;
|
||||||
|
const qty = document.getElementById(`productQty-${id}`).value;
|
||||||
|
const price = document.getElementById(`productPrice-${id}`).value;
|
||||||
|
if (name && qty && price) {
|
||||||
|
hasValidProduct = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValid = purchaseDate && hasValidProduct && hasFiles;
|
||||||
document.getElementById('submitButton').disabled = !isValid;
|
document.getElementById('submitButton').disabled = !isValid;
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculatePoints() {
|
function calculatePoints(totalAmount) {
|
||||||
const totalAmount = document.getElementById('totalAmount').value;
|
|
||||||
const pointsEstimate = document.getElementById('pointsEstimate');
|
const pointsEstimate = document.getElementById('pointsEstimate');
|
||||||
const estimateText = document.getElementById('estimateText');
|
const estimateText = document.getElementById('estimateText');
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
248
ios/Podfile.lock
248
ios/Podfile.lock
@@ -35,65 +35,134 @@ 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):
|
- Flutter
|
||||||
- MLKitCommon (~> 11.0)
|
- FlutterMacOS
|
||||||
- MLKitVision (~> 7.0)
|
- nanopb (3.30910.0):
|
||||||
- MLKitCommon (11.0.0):
|
- nanopb/decode (= 3.30910.0)
|
||||||
- GoogleDataTransport (< 10.0, >= 9.4.1)
|
- nanopb/encode (= 3.30910.0)
|
||||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
- nanopb/decode (3.30910.0)
|
||||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
- nanopb/encode (3.30910.0)
|
||||||
- GoogleUtilities/UserDefaults (< 8.0, >= 7.13.0)
|
- objective_c (0.0.1):
|
||||||
- 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)
|
|
||||||
- nanopb (2.30910.0):
|
|
||||||
- nanopb/decode (= 2.30910.0)
|
|
||||||
- nanopb/encode (= 2.30910.0)
|
|
||||||
- nanopb/decode (2.30910.0)
|
|
||||||
- nanopb/encode (2.30910.0)
|
|
||||||
- onesignal_flutter (5.3.4):
|
- onesignal_flutter (5.3.4):
|
||||||
- Flutter
|
- Flutter
|
||||||
- OneSignalXCFramework (= 5.2.14)
|
- OneSignalXCFramework (= 5.2.14)
|
||||||
@@ -145,13 +214,20 @@ PODS:
|
|||||||
- OneSignalXCFramework/OneSignalOutcomes
|
- OneSignalXCFramework/OneSignalOutcomes
|
||||||
- open_file_ios (0.0.1):
|
- open_file_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- package_info_plus (0.4.5):
|
||||||
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
- 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)
|
||||||
|
- Sentry/HybridSDK (8.56.2)
|
||||||
|
- sentry_flutter (9.8.0):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- Sentry/HybridSDK (= 8.56.2)
|
||||||
- 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,15 +243,21 @@ 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`)
|
||||||
|
- objective_c (from `.symlinks/plugins/objective_c/ios`)
|
||||||
- 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`)
|
||||||
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
|
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
@@ -185,20 +267,21 @@ 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
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
|
- Sentry
|
||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
@@ -206,6 +289,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,13 +304,19 @@ 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"
|
||||||
|
objective_c:
|
||||||
|
:path: ".symlinks/plugins/objective_c/ios"
|
||||||
onesignal_flutter:
|
onesignal_flutter:
|
||||||
:path: ".symlinks/plugins/onesignal_flutter/ios"
|
:path: ".symlinks/plugins/onesignal_flutter/ios"
|
||||||
open_file_ios:
|
open_file_ios:
|
||||||
:path: ".symlinks/plugins/open_file_ios/ios"
|
:path: ".symlinks/plugins/open_file_ios/ios"
|
||||||
|
package_info_plus:
|
||||||
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
|
sentry_flutter:
|
||||||
|
:path: ".symlinks/plugins/sentry_flutter/ios"
|
||||||
share_plus:
|
share_plus:
|
||||||
:path: ".symlinks/plugins/share_plus/ios"
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
@@ -236,34 +331,41 @@ 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
|
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||||
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
|
||||||
|
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
SDWebImage: d0184764be51240d49c761c37f53dd017e1ccaaf
|
||||||
|
Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7
|
||||||
|
sentry_flutter: f074f75557daea0e1dd9607381a05cc0e3e456fe
|
||||||
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;
|
||||||
};
|
};
|
||||||
@@ -628,6 +632,7 @@
|
|||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner";
|
INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -930,6 +935,7 @@
|
|||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner";
|
INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -955,6 +961,7 @@
|
|||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner";
|
INFOPLIST_KEY_CFBundleDisplayName = "DBIZ Partner";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -55,21 +56,10 @@ class AuthInterceptor extends Interceptor {
|
|||||||
// Get session data from secure storage
|
// Get session data from secure storage
|
||||||
final sid = await _secureStorage.read(key: 'frappe_sid');
|
final sid = await _secureStorage.read(key: 'frappe_sid');
|
||||||
final csrfToken = await _secureStorage.read(key: 'frappe_csrf_token');
|
final csrfToken = await _secureStorage.read(key: 'frappe_csrf_token');
|
||||||
final fullName = await _secureStorage.read(key: 'frappe_full_name');
|
|
||||||
final userId = await _secureStorage.read(key: 'frappe_user_id');
|
|
||||||
|
|
||||||
if (sid != null && csrfToken != null) {
|
if (sid != null && csrfToken != null) {
|
||||||
// Build cookie header with all required fields
|
// Only send sid in Cookie header - other fields are not needed
|
||||||
final cookieHeader = [
|
options.headers['Cookie'] = 'sid=$sid';
|
||||||
'sid=$sid',
|
|
||||||
'full_name=${fullName ?? "User"}',
|
|
||||||
'system_user=no',
|
|
||||||
'user_id=${userId != null ? Uri.encodeComponent(userId) : ApiConstants.frappePublicUserId}',
|
|
||||||
'user_image=',
|
|
||||||
].join('; ');
|
|
||||||
|
|
||||||
// Add Frappe session headers
|
|
||||||
options.headers['Cookie'] = cookieHeader;
|
|
||||||
options.headers['X-Frappe-CSRF-Token'] = csrfToken;
|
options.headers['X-Frappe-CSRF-Token'] = csrfToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,10 +559,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';
|
||||||
|
|
||||||
@@ -446,6 +447,7 @@ Future<CacheOptions> cacheOptions(Ref ref) async {
|
|||||||
Future<Dio> dio(Ref ref) async {
|
Future<Dio> dio(Ref ref) async {
|
||||||
final dio = Dio();
|
final dio = Dio();
|
||||||
|
|
||||||
|
|
||||||
// Base configuration
|
// Base configuration
|
||||||
dio
|
dio
|
||||||
..options = BaseOptions(
|
..options = BaseOptions(
|
||||||
@@ -465,7 +467,7 @@ Future<Dio> dio(Ref ref) async {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
// Add interceptors in order
|
// Add interceptors in order
|
||||||
// 1. Custom Curl interceptor (first to log cURL commands)
|
// 1. Custom Curl interceptor (logs cURL commands)
|
||||||
// Uses debugPrint and developer.log for better visibility
|
// Uses debugPrint and developer.log for better visibility
|
||||||
..interceptors.add(CustomCurlLoggerInterceptor())
|
..interceptors.add(CustomCurlLoggerInterceptor())
|
||||||
// 2. Logging interceptor
|
// 2. Logging interceptor
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -27,6 +28,7 @@ import 'package:worker/features/chat/presentation/pages/chat_list_page.dart';
|
|||||||
import 'package:worker/features/favorites/presentation/pages/favorites_page.dart';
|
import 'package:worker/features/favorites/presentation/pages/favorites_page.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart';
|
import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/pages/points_history_page.dart';
|
import 'package:worker/features/loyalty/presentation/pages/points_history_page.dart';
|
||||||
|
import 'package:worker/features/loyalty/presentation/pages/points_record_create_page.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/pages/points_records_page.dart';
|
import 'package:worker/features/loyalty/presentation/pages/points_records_page.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/pages/rewards_page.dart';
|
import 'package:worker/features/loyalty/presentation/pages/rewards_page.dart';
|
||||||
import 'package:worker/features/main/presentation/pages/main_scaffold.dart';
|
import 'package:worker/features/main/presentation/pages/main_scaffold.dart';
|
||||||
@@ -64,7 +66,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;
|
||||||
@@ -131,16 +133,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,
|
||||||
@@ -192,16 +200,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
|
||||||
@@ -212,6 +226,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 ?? ''),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -224,6 +239,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 ?? ''),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -239,6 +255,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),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -248,8 +265,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
|
||||||
@@ -304,6 +324,14 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
MaterialPage(key: state.pageKey, child: const PointsRecordsPage()),
|
MaterialPage(key: state.pageKey, child: const PointsRecordsPage()),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Points Record Create Route
|
||||||
|
GoRoute(
|
||||||
|
path: RouteNames.pointsRecordCreate,
|
||||||
|
name: 'loyalty_points_record_create',
|
||||||
|
pageBuilder: (context, state) =>
|
||||||
|
MaterialPage(key: state.pageKey, child: const PointsRecordCreatePage()),
|
||||||
|
),
|
||||||
|
|
||||||
// Orders Route
|
// Orders Route
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteNames.orders,
|
path: RouteNames.orders,
|
||||||
@@ -632,6 +660,7 @@ class RouteNames {
|
|||||||
static const String rewards = '$loyalty/rewards';
|
static const String rewards = '$loyalty/rewards';
|
||||||
static const String pointsHistory = '$loyalty/points-history';
|
static const String pointsHistory = '$loyalty/points-history';
|
||||||
static const String pointsRecords = '$loyalty/points-records';
|
static const String pointsRecords = '$loyalty/points-records';
|
||||||
|
static const String pointsRecordCreate = '$loyalty/points-records/create';
|
||||||
static const String myGifts = '$loyalty/gifts';
|
static const String myGifts = '$loyalty/gifts';
|
||||||
static const String referral = '$loyalty/referral';
|
static const String referral = '$loyalty/referral';
|
||||||
|
|
||||||
|
|||||||
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,15 +87,10 @@ class FrappeAuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeLogin}';
|
const url = '${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeLogin}';
|
||||||
|
|
||||||
// Build cookie header
|
// Get stored session - only need sid and csrf_token
|
||||||
final storedSession = await getStoredSession();
|
final storedSession = await getStoredSession();
|
||||||
final cookieHeader = _buildCookieHeader(
|
|
||||||
sid: storedSession!['sid']!,
|
|
||||||
fullName: storedSession['fullName']!,
|
|
||||||
userId: storedSession['userId']!,
|
|
||||||
);
|
|
||||||
|
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
final response = await _dio.post<Map<String, dynamic>>(
|
||||||
url,
|
url,
|
||||||
@@ -109,7 +104,7 @@ class FrappeAuthService {
|
|||||||
},
|
},
|
||||||
options: Options(
|
options: Options(
|
||||||
headers: {
|
headers: {
|
||||||
'Cookie': cookieHeader,
|
'Cookie': 'sid=${storedSession!['sid']!}',
|
||||||
'X-Frappe-Csrf-Token': storedSession['csrfToken']!,
|
'X-Frappe-Csrf-Token': storedSession['csrfToken']!,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -203,31 +198,13 @@ class FrappeAuthService {
|
|||||||
return sid != null && csrfToken != null;
|
return sid != null && csrfToken != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build cookie header string
|
|
||||||
String _buildCookieHeader({
|
|
||||||
required String sid,
|
|
||||||
required String fullName,
|
|
||||||
required String userId,
|
|
||||||
}) {
|
|
||||||
return [
|
|
||||||
'sid=$sid',
|
|
||||||
'full_name=$fullName',
|
|
||||||
'system_user=no',
|
|
||||||
'user_id=${Uri.encodeComponent(userId)}',
|
|
||||||
'user_image=',
|
|
||||||
].join('; ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get headers for Frappe API requests
|
/// Get headers for Frappe API requests
|
||||||
|
/// Only sends sid in Cookie - other fields are not needed
|
||||||
Future<Map<String, String>> getHeaders() async {
|
Future<Map<String, String>> getHeaders() async {
|
||||||
final session = await ensureSession();
|
final session = await ensureSession();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'Cookie': _buildCookieHeader(
|
'Cookie': 'sid=${session['sid']!}',
|
||||||
sid: session['sid']!,
|
|
||||||
fullName: session['fullName']!,
|
|
||||||
userId: session['userId']!,
|
|
||||||
),
|
|
||||||
'X-Frappe-Csrf-Token': session['csrfToken']!,
|
'X-Frappe-Csrf-Token': session['csrfToken']!,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|||||||
168
lib/core/services/onesignal_service.dart
Normal file
168
lib/core/services/onesignal_service.dart
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:onesignal_flutter/onesignal_flutter.dart';
|
||||||
|
|
||||||
|
/// OneSignal service for managing push notifications and external user ID.
|
||||||
|
///
|
||||||
|
/// This service handles:
|
||||||
|
/// - Initializing OneSignal SDK
|
||||||
|
/// - Setting external user ID after login (using phone number)
|
||||||
|
/// - Restoring external user ID on app startup
|
||||||
|
/// - Clearing external user ID on logout
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// // Initialize in main.dart
|
||||||
|
/// await OneSignalService.init(appId: 'your-app-id');
|
||||||
|
///
|
||||||
|
/// // After successful login
|
||||||
|
/// await OneSignalService.login(phoneNumber);
|
||||||
|
///
|
||||||
|
/// // On logout
|
||||||
|
/// await OneSignalService.logout();
|
||||||
|
/// ```
|
||||||
|
class OneSignalService {
|
||||||
|
OneSignalService._();
|
||||||
|
|
||||||
|
/// OneSignal App ID - Replace with your actual App ID from OneSignal dashboard
|
||||||
|
static const String _defaultAppId = '778ca22d-c719-4ec8-86cb-a6b911166066';
|
||||||
|
|
||||||
|
/// Initialize OneSignal SDK
|
||||||
|
///
|
||||||
|
/// Must be called before using any other OneSignal methods.
|
||||||
|
/// Sets up push subscription observers and requests notification permission.
|
||||||
|
///
|
||||||
|
/// [appId] - Optional App ID override (uses default if not provided)
|
||||||
|
/// [requestPermission] - Whether to request notification permission (default: true)
|
||||||
|
static Future<void> init({
|
||||||
|
String? appId,
|
||||||
|
bool requestPermission = true,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// Set debug log level (verbose in debug, none in release)
|
||||||
|
OneSignal.Debug.setLogLevel(kDebugMode ? OSLogLevel.verbose : OSLogLevel.none);
|
||||||
|
|
||||||
|
// Initialize with App ID
|
||||||
|
OneSignal.initialize(appId ?? _defaultAppId);
|
||||||
|
debugPrint('🔔 OneSignal initialized');
|
||||||
|
|
||||||
|
// Add push subscription observer to track subscription state changes
|
||||||
|
OneSignal.User.pushSubscription.addObserver((state) {
|
||||||
|
debugPrint('🔔 Push subscription state changed:');
|
||||||
|
debugPrint(' Previous - optedIn: ${state.previous.optedIn}, id: ${state.previous.id}');
|
||||||
|
debugPrint(' Current - optedIn: ${state.current.optedIn}, id: ${state.current.id}');
|
||||||
|
debugPrint(' Subscription ID: ${state.current.id}');
|
||||||
|
debugPrint(' Push Token: ${state.current.token}');
|
||||||
|
|
||||||
|
if (state.current.id != null) {
|
||||||
|
debugPrint('🔔 ✅ Device successfully subscribed!');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add notification permission observer
|
||||||
|
OneSignal.Notifications.addPermissionObserver((isGranted) {
|
||||||
|
debugPrint('🔔 Notification permission changed: $isGranted');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request permission if enabled
|
||||||
|
if (requestPermission) {
|
||||||
|
final accepted = await OneSignal.Notifications.requestPermission(true);
|
||||||
|
debugPrint('🔔 Permission request result: $accepted');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give OneSignal SDK time to complete initialization and server registration
|
||||||
|
await Future<void>.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// Log current subscription status
|
||||||
|
_logSubscriptionStatus();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to initialize - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log current subscription status for debugging
|
||||||
|
static void _logSubscriptionStatus() {
|
||||||
|
final optedIn = OneSignal.User.pushSubscription.optedIn;
|
||||||
|
final id = OneSignal.User.pushSubscription.id;
|
||||||
|
final token = OneSignal.User.pushSubscription.token;
|
||||||
|
|
||||||
|
debugPrint('🔔 Current subscription status:');
|
||||||
|
debugPrint(' Opted In: $optedIn');
|
||||||
|
debugPrint(' Subscription ID: $id');
|
||||||
|
debugPrint(' Push Token: $token');
|
||||||
|
|
||||||
|
if (id == null) {
|
||||||
|
debugPrint('🔔 ⚠️ Subscription ID is null - check device connectivity and OneSignal app ID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Login user to OneSignal by setting external user ID.
|
||||||
|
///
|
||||||
|
/// This associates the device with the user's phone number,
|
||||||
|
/// allowing targeted push notifications to specific users.
|
||||||
|
///
|
||||||
|
/// [phoneNumber] - The user's phone number (used as external ID)
|
||||||
|
static Future<void> login(String phoneNumber) async {
|
||||||
|
try {
|
||||||
|
// Set external user ID for targeting
|
||||||
|
await OneSignal.login(phoneNumber);
|
||||||
|
debugPrint('🔔 OneSignal: login - external_id set to $phoneNumber');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to set external user ID - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout user from OneSignal by removing external user ID.
|
||||||
|
///
|
||||||
|
/// This disassociates the device from the user,
|
||||||
|
/// so notifications won't be sent to this specific user anymore.
|
||||||
|
static Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
await OneSignal.logout();
|
||||||
|
debugPrint('🔔 OneSignal: logout - external_id cleared');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to clear external user ID - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a tag to the user for segmentation.
|
||||||
|
///
|
||||||
|
/// Tags can be used to segment users for targeted notifications.
|
||||||
|
/// Example: tier = "diamond", role = "contractor"
|
||||||
|
static Future<void> setTag(String key, String value) async {
|
||||||
|
try {
|
||||||
|
await OneSignal.User.addTagWithKey(key, value);
|
||||||
|
debugPrint('🔔 OneSignal: tag set - $key: $value');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to set tag - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add multiple tags at once.
|
||||||
|
static Future<void> setTags(Map<String, String> tags) async {
|
||||||
|
try {
|
||||||
|
await OneSignal.User.addTags(tags);
|
||||||
|
debugPrint('🔔 OneSignal: tags set - $tags');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to set tags - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a tag from the user.
|
||||||
|
static Future<void> removeTag(String key) async {
|
||||||
|
try {
|
||||||
|
await OneSignal.User.removeTag(key);
|
||||||
|
debugPrint('🔔 OneSignal: tag removed - $key');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔔 OneSignal error: Failed to remove tag - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the OneSignal subscription ID (player ID).
|
||||||
|
///
|
||||||
|
/// This is the device-specific ID used by OneSignal.
|
||||||
|
static String? get subscriptionId => OneSignal.User.pushSubscription.id;
|
||||||
|
|
||||||
|
/// Check if push notifications are enabled.
|
||||||
|
static bool get isPushEnabled =>
|
||||||
|
OneSignal.User.pushSubscription.optedIn ?? false;
|
||||||
|
}
|
||||||
251
lib/core/services/sentry_service.dart
Normal file
251
lib/core/services/sentry_service.dart
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
|
/// Sentry service for error tracking and performance monitoring.
|
||||||
|
///
|
||||||
|
/// This service handles:
|
||||||
|
/// - Initializing Sentry SDK
|
||||||
|
/// - Capturing exceptions and errors
|
||||||
|
/// - Capturing custom messages
|
||||||
|
/// - Setting user context after login
|
||||||
|
/// - Performance monitoring
|
||||||
|
///
|
||||||
|
/// Usage:
|
||||||
|
/// ```dart
|
||||||
|
/// // Initialize in main.dart
|
||||||
|
/// await SentryService.init(
|
||||||
|
/// dsn: 'your-sentry-dsn',
|
||||||
|
/// appRunner: () => runApp(MyApp()),
|
||||||
|
/// );
|
||||||
|
///
|
||||||
|
/// // Capture exception
|
||||||
|
/// SentryService.captureException(error, stackTrace: stackTrace);
|
||||||
|
///
|
||||||
|
/// // Capture message
|
||||||
|
/// SentryService.captureMessage('User performed action X');
|
||||||
|
///
|
||||||
|
/// // Set user context after login
|
||||||
|
/// SentryService.setUser(userId: '123', email: 'user@example.com');
|
||||||
|
/// ```
|
||||||
|
class SentryService {
|
||||||
|
SentryService._();
|
||||||
|
|
||||||
|
/// Sentry DSN - Replace with your actual DSN from Sentry dashboard
|
||||||
|
static const String _dsn = 'https://2c5893508a29e5ea750b64d5ee31aeef@o4509632266436608.ingest.us.sentry.io/4510464530972672';
|
||||||
|
|
||||||
|
/// Initialize Sentry SDK
|
||||||
|
///
|
||||||
|
/// Must be called before runApp() in main.dart.
|
||||||
|
/// Wraps the app with Sentry error boundary.
|
||||||
|
///
|
||||||
|
/// [dsn] - Optional DSN override (uses default if not provided)
|
||||||
|
/// [appRunner] - The function that runs the app (typically runApp(MyApp()))
|
||||||
|
/// [environment] - Environment name (e.g., 'development', 'production')
|
||||||
|
static Future<void> init({
|
||||||
|
String? dsn,
|
||||||
|
required Future<void> Function() appRunner,
|
||||||
|
String? environment,
|
||||||
|
}) async {
|
||||||
|
// Get package info for release version
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
final release = 'partner@${packageInfo.version}+${packageInfo.buildNumber}';
|
||||||
|
|
||||||
|
await SentryFlutter.init(
|
||||||
|
(options) {
|
||||||
|
options
|
||||||
|
..dsn = dsn ?? _dsn
|
||||||
|
|
||||||
|
// Release version: worker@1.0.1+29
|
||||||
|
..release = release
|
||||||
|
|
||||||
|
// Environment configuration
|
||||||
|
..environment = environment ?? (kReleaseMode ? 'production' : 'development')
|
||||||
|
|
||||||
|
// Performance monitoring
|
||||||
|
..tracesSampleRate = kReleaseMode ? 0.2 : 1.0 // 20% in prod, 100% in dev
|
||||||
|
..profilesSampleRate = kReleaseMode ? 0.2 : 1.0
|
||||||
|
|
||||||
|
// Enable automatic instrumentation
|
||||||
|
..enableAutoPerformanceTracing = true
|
||||||
|
|
||||||
|
// Capture failed requests
|
||||||
|
..captureFailedRequests = true
|
||||||
|
|
||||||
|
// Debug mode settings
|
||||||
|
..debug = kDebugMode
|
||||||
|
|
||||||
|
// Add app-specific tags
|
||||||
|
..beforeSend = (event, hint) {
|
||||||
|
// Filter out certain errors if needed
|
||||||
|
// Return null to drop the event
|
||||||
|
// return event;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
appRunner: appRunner,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('🔴 Sentry initialized (release: $release, enabled: ${!kDebugMode})');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture an exception with optional stack trace
|
||||||
|
///
|
||||||
|
/// [exception] - The exception to capture
|
||||||
|
/// [stackTrace] - Optional stack trace
|
||||||
|
/// [hint] - Optional hint with additional context
|
||||||
|
static Future<void> captureException(
|
||||||
|
dynamic exception, {
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
Hint? hint,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await Sentry.captureException(
|
||||||
|
exception,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
hint: hint,
|
||||||
|
);
|
||||||
|
debugPrint('🔴 Sentry: Exception captured - ${exception.runtimeType}');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to capture exception - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a custom message
|
||||||
|
///
|
||||||
|
/// [message] - The message to capture
|
||||||
|
/// [level] - Severity level (default: info)
|
||||||
|
/// [params] - Optional parameters to include
|
||||||
|
static Future<void> captureMessage(
|
||||||
|
String message, {
|
||||||
|
SentryLevel level = SentryLevel.info,
|
||||||
|
Map<String, dynamic>? params,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await Sentry.captureMessage(
|
||||||
|
message,
|
||||||
|
level: level,
|
||||||
|
withScope: params != null
|
||||||
|
? (scope) {
|
||||||
|
params.forEach((key, value) {
|
||||||
|
scope.setExtra(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
debugPrint('🔴 Sentry: Message captured - $message');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to capture message - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set user context for error tracking
|
||||||
|
///
|
||||||
|
/// Call this after successful login to associate errors with users.
|
||||||
|
///
|
||||||
|
/// [userId] - User's unique identifier
|
||||||
|
/// [email] - User's email (optional)
|
||||||
|
/// [username] - User's display name (optional)
|
||||||
|
/// [extras] - Additional user data (optional)
|
||||||
|
static Future<void> setUser({
|
||||||
|
required String userId,
|
||||||
|
String? email,
|
||||||
|
String? username,
|
||||||
|
Map<String, dynamic>? extras,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await Sentry.configureScope((scope) {
|
||||||
|
scope.setUser(SentryUser(
|
||||||
|
id: userId,
|
||||||
|
email: email,
|
||||||
|
username: username,
|
||||||
|
data: extras,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
debugPrint('🔴 Sentry: User set - $userId');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to set user - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear user context on logout
|
||||||
|
static Future<void> clearUser() async {
|
||||||
|
try {
|
||||||
|
await Sentry.configureScope((scope) {
|
||||||
|
scope.setUser(null);
|
||||||
|
});
|
||||||
|
debugPrint('🔴 Sentry: User cleared');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to clear user - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a breadcrumb for tracking user actions
|
||||||
|
///
|
||||||
|
/// Breadcrumbs are used to track the sequence of events leading to an error.
|
||||||
|
///
|
||||||
|
/// [message] - Description of the action
|
||||||
|
/// [category] - Category of the breadcrumb (e.g., 'navigation', 'ui.click')
|
||||||
|
/// [data] - Additional data (optional)
|
||||||
|
static Future<void> addBreadcrumb({
|
||||||
|
required String message,
|
||||||
|
String? category,
|
||||||
|
Map<String, dynamic>? data,
|
||||||
|
SentryLevel level = SentryLevel.info,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await Sentry.addBreadcrumb(Breadcrumb(
|
||||||
|
message: message,
|
||||||
|
category: category,
|
||||||
|
data: data,
|
||||||
|
level: level,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to add breadcrumb - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a tag for filtering in Sentry dashboard
|
||||||
|
///
|
||||||
|
/// [key] - Tag name
|
||||||
|
/// [value] - Tag value
|
||||||
|
static Future<void> setTag(String key, String value) async {
|
||||||
|
try {
|
||||||
|
await Sentry.configureScope((scope) {
|
||||||
|
scope.setTag(key, value);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to set tag - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set extra context data
|
||||||
|
///
|
||||||
|
/// [key] - Context key
|
||||||
|
/// [value] - Context value (will be serialized)
|
||||||
|
static Future<void> setExtra(String key, dynamic value) async {
|
||||||
|
try {
|
||||||
|
await Sentry.configureScope((scope) {
|
||||||
|
scope.setExtra(key, value);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to set extra - $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a performance transaction
|
||||||
|
///
|
||||||
|
/// [name] - Transaction name
|
||||||
|
/// [operation] - Operation type (e.g., 'http.client', 'ui.load')
|
||||||
|
///
|
||||||
|
/// Returns the transaction to be finished later.
|
||||||
|
static ISentrySpan? startTransaction(String name, String operation) {
|
||||||
|
try {
|
||||||
|
return Sentry.startTransaction(name, operation);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('🔴 Sentry error: Failed to start transaction - $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,15 @@ extension StringExtensions on String {
|
|||||||
if (cleaned.length < 10) return this;
|
if (cleaned.length < 10) return this;
|
||||||
return '${cleaned.substring(0, 4)} ${cleaned.substring(4, 7)} ***';
|
return '${cleaned.substring(0, 4)} ${cleaned.substring(4, 7)} ***';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove leading "#" from string (e.g., "#SO-001" -> "SO-001")
|
||||||
|
/// Useful for backend IDs that start with "#"
|
||||||
|
String get withoutHash {
|
||||||
|
if (startsWith('#')) {
|
||||||
|
return substring(1);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -360,12 +361,7 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: const CustomLoadingIndicator(),
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: colorScheme.primary,
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.md),
|
const SizedBox(width: AppSpacing.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -492,11 +488,8 @@ class _ProfileCardSection extends ConsumerWidget {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: colorScheme.primaryContainer,
|
color: colorScheme.primaryContainer,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: CustomLoadingIndicator(
|
||||||
child: CircularProgressIndicator(
|
color: colorScheme.onPrimaryContainer,
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
@@ -676,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';
|
||||||
@@ -450,13 +451,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
isSaving,
|
isSaving,
|
||||||
),
|
),
|
||||||
icon: isSaving.value
|
icon: isSaving.value
|
||||||
? SizedBox(
|
? CustomLoadingIndicator(
|
||||||
width: 18,
|
color: colorScheme.onPrimary,
|
||||||
height: 18,
|
size: 18,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 18),
|
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 18),
|
||||||
label: Text(
|
label: Text(
|
||||||
@@ -800,13 +797,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (isLoading) ...[
|
if (isLoading) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
CustomLoadingIndicator(
|
||||||
width: 12,
|
color: colorScheme.primary,
|
||||||
height: 12,
|
size: 12,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -856,13 +849,9 @@ class AddressFormPage extends HookConsumerWidget {
|
|||||||
suffixIcon: isLoading
|
suffixIcon: isLoading
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const 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: colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -253,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,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -61,21 +62,8 @@ class ProfileEditPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
),
|
),
|
||||||
body: Center(
|
body: const CustomLoadingIndicator(
|
||||||
child: Column(
|
message: 'Đang tải thông tin...',
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(color: colorScheme.primary),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
Text(
|
|
||||||
'Đang tải thông tin...',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (error, stack) => Scaffold(
|
error: (error, stack) => Scaffold(
|
||||||
|
|||||||
@@ -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),
|
||||||
/// );
|
/// );
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -294,15 +295,9 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? SizedBox(
|
? CustomLoadingIndicator(
|
||||||
height: 20.0,
|
color: colorScheme.onPrimary,
|
||||||
width: 20.0,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.0,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'Gửi mã OTP',
|
'Gửi mã OTP',
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -86,6 +87,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
..when(
|
..when(
|
||||||
data: (user) {
|
data: (user) {
|
||||||
if (user != null && mounted) {
|
if (user != null && mounted) {
|
||||||
|
// Analytics (logLogin & setUserId) are handled in auth_provider
|
||||||
// Navigate to home on success
|
// Navigate to home on success
|
||||||
context.goHome();
|
context.goHome();
|
||||||
}
|
}
|
||||||
@@ -483,15 +485,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: isLoading
|
child: isLoading
|
||||||
? SizedBox(
|
? CustomLoadingIndicator(
|
||||||
height: 20.0,
|
color: colorScheme.onPrimary,
|
||||||
width: 20.0,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.0,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'Đăng nhập',
|
'Đăng nhập',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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';
|
||||||
@@ -377,15 +378,9 @@ class _OtpVerificationPageState extends ConsumerState<OtpVerificationPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? SizedBox(
|
? CustomLoadingIndicator(
|
||||||
height: 20,
|
color: colorScheme.onPrimary,
|
||||||
width: 20,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'Xác nhận',
|
'Xác nhận',
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -25,6 +26,7 @@ import 'package:worker/features/auth/presentation/providers/customer_groups_prov
|
|||||||
import 'package:worker/features/auth/presentation/providers/session_provider.dart';
|
import 'package:worker/features/auth/presentation/providers/session_provider.dart';
|
||||||
import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart';
|
import 'package:worker/features/auth/presentation/widgets/phone_input_field.dart';
|
||||||
import 'package:worker/features/auth/presentation/widgets/file_upload_card.dart';
|
import 'package:worker/features/auth/presentation/widgets/file_upload_card.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
|
||||||
/// Registration Page
|
/// Registration Page
|
||||||
///
|
///
|
||||||
@@ -344,6 +346,9 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
// Log sign up analytics event
|
||||||
|
AnalyticsService.logSignUp(method: 'phone');
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -410,18 +415,8 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
),
|
),
|
||||||
body: _isLoadingData
|
body: _isLoadingData
|
||||||
? Center(
|
? const CustomLoadingIndicator(
|
||||||
child: Column(
|
message: 'Đang tải dữ liệu...',
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const CircularProgressIndicator(),
|
|
||||||
const SizedBox(height: AppSpacing.md),
|
|
||||||
Text(
|
|
||||||
'Đang tải dữ liệu...',
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: SafeArea(
|
: SafeArea(
|
||||||
child: Form(
|
child: Form(
|
||||||
@@ -646,15 +641,9 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? SizedBox(
|
? CustomLoadingIndicator(
|
||||||
height: 20,
|
color: colorScheme.onPrimary,
|
||||||
width: 20,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'Đăng ký',
|
'Đăng ký',
|
||||||
@@ -801,9 +790,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,
|
||||||
@@ -867,9 +859,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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -61,10 +62,7 @@ class SplashPage extends StatelessWidget {
|
|||||||
const SizedBox(height: 48.0),
|
const SizedBox(height: 48.0),
|
||||||
|
|
||||||
// Loading Indicator
|
// Loading Indicator
|
||||||
CircularProgressIndicator(
|
const CustomLoadingIndicator(),
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
|
||||||
strokeWidth: 3.0,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16.0),
|
const SizedBox(height: 16.0),
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
/// 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';
|
||||||
import 'package:worker/core/network/dio_client.dart';
|
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/core/services/onesignal_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';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
|
||||||
part 'auth_provider.g.dart';
|
part 'auth_provider.g.dart';
|
||||||
|
|
||||||
@@ -80,10 +80,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 {
|
||||||
@@ -105,7 +101,11 @@ class Auth extends _$Auth {
|
|||||||
final fullName = await secureStorage.read(key: 'frappe_full_name');
|
final fullName = await secureStorage.read(key: 'frappe_full_name');
|
||||||
|
|
||||||
if (sid != null && userId != null && userId != ApiConstants.frappePublicUserId) {
|
if (sid != null && userId != null && userId != ApiConstants.frappePublicUserId) {
|
||||||
// User is logged in and wants to be remembered, create User entity
|
// User is logged in and wants to be remembered
|
||||||
|
// Restore OneSignal external user ID for targeted notifications
|
||||||
|
await OneSignalService.login(userId);
|
||||||
|
|
||||||
|
// Create User entity
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
return User(
|
return User(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@@ -170,7 +170,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,26 +182,20 @@ 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);
|
||||||
|
|
||||||
|
// Set user ID for analytics tracking
|
||||||
|
await AnalyticsService.setUserId(phoneNumber);
|
||||||
|
// Log login event
|
||||||
|
await AnalyticsService.logLogin(method: 'phone');
|
||||||
|
|
||||||
|
// Set OneSignal external user ID for targeted notifications
|
||||||
|
await OneSignalService.login(phoneNumber);
|
||||||
|
|
||||||
// Create and return User entity
|
// Create and return User entity
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
return User(
|
return User(
|
||||||
@@ -239,6 +232,12 @@ class Auth extends _$Auth {
|
|||||||
state = await AsyncValue.guard(() async {
|
state = await AsyncValue.guard(() async {
|
||||||
final frappeService = await _frappeAuthService;
|
final frappeService = await _frappeAuthService;
|
||||||
|
|
||||||
|
// Clear user ID from analytics
|
||||||
|
await AnalyticsService.setUserId(null);
|
||||||
|
|
||||||
|
// Clear OneSignal external user ID
|
||||||
|
await OneSignalService.logout();
|
||||||
|
|
||||||
// Clear saved session
|
// Clear saved session
|
||||||
await _localDataSource.clearSession();
|
await _localDataSource.clearSession();
|
||||||
await frappeService.clearSession();
|
await frappeService.clearSession();
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ final class AuthProvider extends $AsyncNotifierProvider<Auth, User?> {
|
|||||||
Auth create() => Auth();
|
Auth create() => Auth();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$authHash() => r'f0438cf6eb9eb17c0afc6b23055acd09926b21ae';
|
String _$authHash() => r'2aaad43ba390e824b5aa8d95bc14e514c421c8ef';
|
||||||
|
|
||||||
/// Authentication Provider
|
/// Authentication Provider
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -16,6 +17,8 @@ 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';
|
||||||
import 'package:worker/features/cart/presentation/widgets/cart_item_widget.dart';
|
import 'package:worker/features/cart/presentation/widgets/cart_item_widget.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
|
|
||||||
/// Cart Page
|
/// Cart Page
|
||||||
///
|
///
|
||||||
@@ -33,30 +36,37 @@ class CartPage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _CartPageState extends ConsumerState<CartPage> {
|
class _CartPageState extends ConsumerState<CartPage> {
|
||||||
bool _isSyncing = false;
|
bool _isSyncing = false;
|
||||||
|
bool _hasLoggedViewCart = 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.
|
||||||
// No dispose() method needed - using ref.read() in dispose() is unsafe.
|
// No dispose() method needed - using ref.read() in dispose() is unsafe.
|
||||||
|
|
||||||
|
void _logViewCartOnce(CartState cartState) {
|
||||||
|
if (_hasLoggedViewCart || cartState.isEmpty) return;
|
||||||
|
_hasLoggedViewCart = true;
|
||||||
|
|
||||||
|
AnalyticsService.logViewCart(
|
||||||
|
cartValue: cartState.selectedTotal,
|
||||||
|
items: cartState.items.map((item) => AnalyticsEventItem(
|
||||||
|
itemId: item.product.productId,
|
||||||
|
itemName: item.product.name,
|
||||||
|
price: item.product.basePrice,
|
||||||
|
quantity: item.quantity.toInt(),
|
||||||
|
)).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final cartState = ref.watch(cartProvider);
|
final cartState = ref.watch(cartProvider);
|
||||||
|
|
||||||
final currencyFormatter = NumberFormat.currency(
|
// Log view cart analytics event only once when page opens
|
||||||
locale: 'vi_VN',
|
_logViewCartOnce(cartState);
|
||||||
symbol: 'đ',
|
|
||||||
decimalDigits: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
final itemCount = cartState.itemCount;
|
final itemCount = cartState.itemCount;
|
||||||
final hasSelection = cartState.selectedCount > 0;
|
final hasSelection = cartState.selectedCount > 0;
|
||||||
@@ -102,7 +112,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
|
||||||
@@ -132,9 +142,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
if (cartState.isLoading)
|
if (cartState.isLoading)
|
||||||
Container(
|
Container(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.1),
|
color: colorScheme.onSurface.withValues(alpha: 0.1),
|
||||||
child: const Center(
|
child: const CustomLoadingIndicator(),
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -145,7 +153,11 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
context,
|
context,
|
||||||
cartState,
|
cartState,
|
||||||
ref,
|
ref,
|
||||||
currencyFormatter,
|
NumberFormat.currency(
|
||||||
|
locale: 'vi_VN',
|
||||||
|
symbol: 'đ',
|
||||||
|
decimalDigits: 0,
|
||||||
|
),
|
||||||
hasSelection,
|
hasSelection,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -315,14 +327,9 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
child: _isSyncing
|
child: _isSyncing
|
||||||
? SizedBox(
|
? CustomLoadingIndicator(
|
||||||
width: 20,
|
color: colorScheme.surface,
|
||||||
height: 20,
|
size: 20,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor:
|
|
||||||
AlwaysStoppedAnimation<Color>(colorScheme.surface),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
'Tiến hành đặt hàng',
|
'Tiến hành đặt hàng',
|
||||||
@@ -343,6 +350,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
|
|
||||||
/// Build error banner (shown at top when there's an error but cart has items)
|
/// Build error banner (shown at top when there's an error but cart has items)
|
||||||
Widget _buildErrorBanner(String errorMessage) {
|
Widget _buildErrorBanner(String errorMessage) {
|
||||||
|
print(errorMessage);
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
@@ -425,7 +433,7 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
),
|
),
|
||||||
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'),
|
||||||
),
|
),
|
||||||
@@ -454,6 +462,17 @@ class _CartPageState extends ConsumerState<CartPage> {
|
|||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
// Log remove from cart analytics for selected items
|
||||||
|
for (final item in cartState.items) {
|
||||||
|
if (cartState.selectedItems[item.product.productId] == true) {
|
||||||
|
AnalyticsService.logRemoveFromCart(
|
||||||
|
productId: item.product.productId,
|
||||||
|
productName: item.product.name,
|
||||||
|
price: item.product.basePrice,
|
||||||
|
quantity: item.quantity.toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
ref.read(cartProvider.notifier).deleteSelected();
|
ref.read(cartProvider.notifier).deleteSelected();
|
||||||
context.pop();
|
context.pop();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -28,6 +29,8 @@ import 'package:worker/features/cart/presentation/widgets/payment_method_section
|
|||||||
import 'package:worker/features/cart/presentation/widgets/price_negotiation_section.dart';
|
import 'package:worker/features/cart/presentation/widgets/price_negotiation_section.dart';
|
||||||
import 'package:worker/features/orders/presentation/providers/order_status_provider.dart';
|
import 'package:worker/features/orders/presentation/providers/order_status_provider.dart';
|
||||||
import 'package:worker/features/orders/presentation/providers/payment_terms_provider.dart';
|
import 'package:worker/features/orders/presentation/providers/payment_terms_provider.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
|
|
||||||
/// Checkout Page
|
/// Checkout Page
|
||||||
///
|
///
|
||||||
@@ -103,6 +106,22 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
final total = subtotal - memberDiscount + shipping;
|
final total = subtotal - memberDiscount + shipping;
|
||||||
|
|
||||||
|
// Log begin checkout analytics event
|
||||||
|
if (cartItemsData.isNotEmpty) {
|
||||||
|
AnalyticsService.logBeginCheckout(
|
||||||
|
value: total,
|
||||||
|
items: cartItemsData.map((itemData) {
|
||||||
|
final cartItem = itemData as CartItemData;
|
||||||
|
return AnalyticsEventItem(
|
||||||
|
itemId: cartItem.product.productId,
|
||||||
|
itemName: cartItem.product.name,
|
||||||
|
price: cartItem.product.basePrice,
|
||||||
|
quantity: cartItem.quantity.toInt(),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.surfaceContainerLowest,
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -177,9 +196,7 @@ class CheckoutPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
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),
|
||||||
|
|||||||
@@ -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
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import 'package:cached_network_image/cached_network_image.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:intl/intl.dart';
|
import 'package:go_router/go_router.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';
|
||||||
@@ -78,11 +80,6 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
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),
|
||||||
@@ -116,30 +113,40 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Product Image (bigger: 100x100)
|
// Product Image (bigger: 100x100) - tap to navigate to product detail
|
||||||
ClipRRect(
|
GestureDetector(
|
||||||
borderRadius: BorderRadius.circular(8),
|
onTap: () {
|
||||||
child: CachedNetworkImage(
|
// Navigate to product detail with product ID in path
|
||||||
imageUrl: widget.item.product.thumbnail,
|
context.push('/products/${widget.item.product.productId}');
|
||||||
width: 100,
|
},
|
||||||
height: 100,
|
child: ClipRRect(
|
||||||
fit: BoxFit.cover,
|
borderRadius: BorderRadius.circular(8),
|
||||||
placeholder: (context, url) => Container(
|
child: CachedNetworkImage(
|
||||||
|
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,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
fit: BoxFit.cover,
|
||||||
child: const Center(
|
placeholder: (context, url) => Container(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Center(
|
||||||
|
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: colorScheme.surfaceContainerHighest,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
child: FaIcon(
|
||||||
child: FaIcon(
|
FontAwesomeIcons.image,
|
||||||
FontAwesomeIcons.image,
|
color: colorScheme.onSurfaceVariant,
|
||||||
color: colorScheme.onSurfaceVariant,
|
size: 32,
|
||||||
size: 32,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -167,7 +174,7 @@ 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: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -192,14 +199,15 @@ class _CartItemWidgetState extends ConsumerState<CartItemWidget> {
|
|||||||
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// Quantity TextField
|
// Quantity TextField - uses text keyboard for Done button on iOS/Android
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 32,
|
height: 32,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _quantityController,
|
controller: _quantityController,
|
||||||
focusNode: _quantityFocusNode,
|
focusNode: _quantityFocusNode,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.text,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: AppTypography.titleMedium.copyWith(
|
style: AppTypography.titleMedium.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -93,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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -302,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,
|
||||||
),
|
),
|
||||||
@@ -319,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...'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -129,14 +131,12 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
promotions: promotions,
|
promotions: promotions,
|
||||||
onPromotionTap: (promotion) {
|
onPromotionTap: (promotion) {
|
||||||
// Navigate to promotion details
|
// Navigate to promotion details
|
||||||
context.push('/promotions/${promotion.id}');
|
context.push('/news/${promotion.id}');
|
||||||
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: 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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -198,7 +198,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
actions: [
|
actions: [
|
||||||
QuickAction(
|
QuickAction(
|
||||||
icon: FontAwesomeIcons.circlePlus,
|
icon: FontAwesomeIcons.circlePlus,
|
||||||
label: 'Ghi nhận điểm',
|
label: 'Tham gia sự kiện',
|
||||||
onTap: () => context.push(RouteNames.pointsRecords),
|
onTap: () => context.push(RouteNames.pointsRecords),
|
||||||
),
|
),
|
||||||
QuickAction(
|
QuickAction(
|
||||||
@@ -216,7 +216,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
|
|
||||||
// Sample Houses & News Section
|
// Sample Houses & News Section
|
||||||
QuickActionSection(
|
QuickActionSection(
|
||||||
title: 'Nhà mẫu, dự án & tin tức',
|
title: 'Nhà mẫu & Dự án',
|
||||||
actions: [
|
actions: [
|
||||||
QuickAction(
|
QuickAction(
|
||||||
icon: FontAwesomeIcons.houseCircleCheck,
|
icon: FontAwesomeIcons.houseCircleCheck,
|
||||||
@@ -241,4 +241,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,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class BuyerInfoModel {
|
|||||||
final String? wardCode;
|
final String? wardCode;
|
||||||
final String? cityName;
|
final String? cityName;
|
||||||
final String? wardName;
|
final String? wardName;
|
||||||
|
final String? customerName;
|
||||||
|
|
||||||
const BuyerInfoModel({
|
const BuyerInfoModel({
|
||||||
this.name,
|
this.name,
|
||||||
@@ -100,6 +101,7 @@ class BuyerInfoModel {
|
|||||||
this.wardCode,
|
this.wardCode,
|
||||||
this.cityName,
|
this.cityName,
|
||||||
this.wardName,
|
this.wardName,
|
||||||
|
this.customerName,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory BuyerInfoModel.fromJson(Map<String, dynamic> json) {
|
factory BuyerInfoModel.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -115,6 +117,7 @@ class BuyerInfoModel {
|
|||||||
wardCode: json['ward_code'] as String?,
|
wardCode: json['ward_code'] as String?,
|
||||||
cityName: json['city_name'] as String?,
|
cityName: json['city_name'] as String?,
|
||||||
wardName: json['ward_name'] as String?,
|
wardName: json['ward_name'] as String?,
|
||||||
|
customerName: json['customer_name'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +133,7 @@ class BuyerInfoModel {
|
|||||||
'ward_code': wardCode,
|
'ward_code': wardCode,
|
||||||
'city_name': cityName,
|
'city_name': cityName,
|
||||||
'ward_name': wardName,
|
'ward_name': wardName,
|
||||||
|
'customer_name': customerName,
|
||||||
};
|
};
|
||||||
|
|
||||||
BuyerInfo toEntity() => BuyerInfo(
|
BuyerInfo toEntity() => BuyerInfo(
|
||||||
@@ -144,6 +148,7 @@ class BuyerInfoModel {
|
|||||||
wardCode: wardCode,
|
wardCode: wardCode,
|
||||||
cityName: cityName,
|
cityName: cityName,
|
||||||
wardName: wardName,
|
wardName: wardName,
|
||||||
|
customerName: customerName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class BuyerInfo extends Equatable {
|
|||||||
final String? wardCode;
|
final String? wardCode;
|
||||||
final String? cityName;
|
final String? cityName;
|
||||||
final String? wardName;
|
final String? wardName;
|
||||||
|
final String? customerName;
|
||||||
|
|
||||||
const BuyerInfo({
|
const BuyerInfo({
|
||||||
this.name,
|
this.name,
|
||||||
@@ -88,6 +89,7 @@ class BuyerInfo extends Equatable {
|
|||||||
this.wardCode,
|
this.wardCode,
|
||||||
this.cityName,
|
this.cityName,
|
||||||
this.wardName,
|
this.wardName,
|
||||||
|
this.customerName
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Get formatted full address
|
/// Get formatted full address
|
||||||
@@ -118,6 +120,7 @@ class BuyerInfo extends Equatable {
|
|||||||
wardCode,
|
wardCode,
|
||||||
cityName,
|
cityName,
|
||||||
wardName,
|
wardName,
|
||||||
|
customerName
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,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:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/utils/extensions.dart';
|
import 'package:worker/core/utils/extensions.dart';
|
||||||
@@ -66,7 +67,7 @@ class InvoiceDetailPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
body: invoiceAsync.when(
|
body: invoiceAsync.when(
|
||||||
data: (invoice) => _buildContent(context, invoice),
|
data: (invoice) => _buildContent(context, invoice),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => _buildErrorState(context, ref, error),
|
error: (error, stack) => _buildErrorState(context, ref, error),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -168,7 +169,7 @@ class InvoiceDetailPage extends ConsumerWidget {
|
|||||||
|
|
||||||
// Invoice Number
|
// Invoice Number
|
||||||
Text(
|
Text(
|
||||||
'#${invoice.name}',
|
invoice.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -191,7 +192,7 @@ class InvoiceDetailPage extends ConsumerWidget {
|
|||||||
_MetaItem(label: 'Ngày xuất:', value: invoice.formattedDate),
|
_MetaItem(label: 'Ngày xuất:', value: invoice.formattedDate),
|
||||||
if (invoice.orderId != null) ...[
|
if (invoice.orderId != null) ...[
|
||||||
const SizedBox(width: 32),
|
const SizedBox(width: 32),
|
||||||
_MetaItem(label: 'Đơn hàng:', value: '#${invoice.orderId}'),
|
_MetaItem(label: 'Đơn hàng:', value: '${invoice.orderId}'),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -293,10 +294,10 @@ class InvoiceDetailPage extends ConsumerWidget {
|
|||||||
|
|
||||||
List<_InfoLine> _buildBuyerInfoLines(Invoice invoice) {
|
List<_InfoLine> _buildBuyerInfoLines(Invoice invoice) {
|
||||||
return [
|
return [
|
||||||
if (invoice.buyerInfo!.name != null)
|
if (invoice.buyerInfo!.customerName != null)
|
||||||
_InfoLine(label: 'Người mua hàng', value: invoice.buyerInfo!.name!),
|
_InfoLine(label: 'Người mua hàng', value: invoice.buyerInfo!.customerName!),
|
||||||
if (invoice.customerName != null)
|
if (invoice.buyerInfo!.addressTitle != null)
|
||||||
_InfoLine(label: 'Tên đơn vị', value: invoice.customerName!),
|
_InfoLine(label: 'Tên đơn vị', value: invoice.buyerInfo!.addressTitle!),
|
||||||
if (invoice.buyerInfo!.taxCode != null)
|
if (invoice.buyerInfo!.taxCode != null)
|
||||||
_InfoLine(label: 'Mã số thuế', value: invoice.buyerInfo!.taxCode!),
|
_InfoLine(label: 'Mã số thuế', value: invoice.buyerInfo!.taxCode!),
|
||||||
if (invoice.buyerInfo!.fullAddress.isNotEmpty)
|
if (invoice.buyerInfo!.fullAddress.isNotEmpty)
|
||||||
|
|||||||
@@ -7,6 +7,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/utils/extensions.dart';
|
import 'package:worker/core/utils/extensions.dart';
|
||||||
@@ -74,7 +75,7 @@ class InvoicesPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => _buildErrorState(context, ref, error),
|
error: (error, stack) => _buildErrorState(context, ref, error),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -208,7 +209,7 @@ class _InvoiceCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'#${invoice.name}',
|
invoice.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -251,7 +252,7 @@ class _InvoiceCard extends StatelessWidget {
|
|||||||
if (invoice.orderId != null)
|
if (invoice.orderId != null)
|
||||||
_DetailRow(
|
_DetailRow(
|
||||||
label: 'Đơn hàng:',
|
label: 'Đơn hàng:',
|
||||||
value: '#${invoice.orderId}',
|
value: '${invoice.orderId}',
|
||||||
),
|
),
|
||||||
_DetailRow(
|
_DetailRow(
|
||||||
label: 'Tổng tiền:',
|
label: 'Tổng tiền:',
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class PointsHistoryLocalDataSource {
|
|||||||
complaintStatus: ComplaintStatus.none,
|
complaintStatus: ComplaintStatus.none,
|
||||||
balanceAfter: 604,
|
balanceAfter: 604,
|
||||||
expiryDate: now.add(const Duration(days: 365)),
|
expiryDate: now.add(const Duration(days: 365)),
|
||||||
timestamp: DateTime(2023, 9, 28, 17, 23, 18),
|
timestamp: DateTime(2025, 9, 28, 17, 23, 18),
|
||||||
),
|
),
|
||||||
LoyaltyPointEntryModel(
|
LoyaltyPointEntryModel(
|
||||||
entryId: 'entry_002',
|
entryId: 'entry_002',
|
||||||
@@ -108,7 +108,7 @@ class PointsHistoryLocalDataSource {
|
|||||||
complaintStatus: ComplaintStatus.none,
|
complaintStatus: ComplaintStatus.none,
|
||||||
balanceAfter: 604,
|
balanceAfter: 604,
|
||||||
expiryDate: now.add(const Duration(days: 365)),
|
expiryDate: now.add(const Duration(days: 365)),
|
||||||
timestamp: DateTime(2023, 9, 27, 17, 23, 18),
|
timestamp: DateTime(2025, 9, 27, 17, 23, 18),
|
||||||
),
|
),
|
||||||
LoyaltyPointEntryModel(
|
LoyaltyPointEntryModel(
|
||||||
entryId: 'entry_003',
|
entryId: 'entry_003',
|
||||||
@@ -123,7 +123,7 @@ class PointsHistoryLocalDataSource {
|
|||||||
complaintStatus: ComplaintStatus.none,
|
complaintStatus: ComplaintStatus.none,
|
||||||
balanceAfter: 604,
|
balanceAfter: 604,
|
||||||
expiryDate: null,
|
expiryDate: null,
|
||||||
timestamp: DateTime(2023, 9, 20, 17, 23, 18),
|
timestamp: DateTime(2025, 9, 20, 17, 23, 18),
|
||||||
),
|
),
|
||||||
LoyaltyPointEntryModel(
|
LoyaltyPointEntryModel(
|
||||||
entryId: 'entry_004',
|
entryId: 'entry_004',
|
||||||
@@ -138,7 +138,7 @@ class PointsHistoryLocalDataSource {
|
|||||||
complaintStatus: ComplaintStatus.none,
|
complaintStatus: ComplaintStatus.none,
|
||||||
balanceAfter: 604,
|
balanceAfter: 604,
|
||||||
expiryDate: null,
|
expiryDate: null,
|
||||||
timestamp: DateTime(2023, 9, 19, 17, 23, 18),
|
timestamp: DateTime(2025, 9, 19, 17, 23, 18),
|
||||||
),
|
),
|
||||||
LoyaltyPointEntryModel(
|
LoyaltyPointEntryModel(
|
||||||
entryId: 'entry_005',
|
entryId: 'entry_005',
|
||||||
@@ -153,7 +153,7 @@ class PointsHistoryLocalDataSource {
|
|||||||
complaintStatus: ComplaintStatus.none,
|
complaintStatus: ComplaintStatus.none,
|
||||||
balanceAfter: 604,
|
balanceAfter: 604,
|
||||||
expiryDate: now.add(const Duration(days: 365)),
|
expiryDate: now.add(const Duration(days: 365)),
|
||||||
timestamp: DateTime(2023, 9, 10, 17, 23, 18),
|
timestamp: DateTime(2025, 9, 10, 17, 23, 18),
|
||||||
),
|
),
|
||||||
LoyaltyPointEntryModel(
|
LoyaltyPointEntryModel(
|
||||||
entryId: 'entry_006',
|
entryId: 'entry_006',
|
||||||
@@ -168,7 +168,7 @@ class PointsHistoryLocalDataSource {
|
|||||||
complaintStatus: ComplaintStatus.none,
|
complaintStatus: ComplaintStatus.none,
|
||||||
balanceAfter: 604,
|
balanceAfter: 604,
|
||||||
expiryDate: null,
|
expiryDate: null,
|
||||||
timestamp: DateTime(2023, 9, 5, 17, 23, 18),
|
timestamp: DateTime(2025, 9, 5, 17, 23, 18),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class PointsRecordRemoteDataSourceImpl
|
|||||||
userId: 'user123',
|
userId: 'user123',
|
||||||
invoiceNumber: 'INV-VG-001',
|
invoiceNumber: 'INV-VG-001',
|
||||||
storeName: 'Công ty TNHH Vingroup',
|
storeName: 'Công ty TNHH Vingroup',
|
||||||
transactionDate: DateTime(2023, 11, 15),
|
transactionDate: DateTime(2025, 11, 15),
|
||||||
invoiceAmount: 2500000,
|
invoiceAmount: 2500000,
|
||||||
notes: 'Gạch granite cao cấp cho khu vực lobby và hành lang',
|
notes: 'Gạch granite cao cấp cho khu vực lobby và hành lang',
|
||||||
attachments: const [
|
attachments: const [
|
||||||
@@ -39,8 +39,8 @@ class PointsRecordRemoteDataSourceImpl
|
|||||||
],
|
],
|
||||||
status: PointsStatus.approved,
|
status: PointsStatus.approved,
|
||||||
pointsEarned: 250,
|
pointsEarned: 250,
|
||||||
submittedAt: DateTime(2023, 11, 15, 10, 0),
|
submittedAt: DateTime(2025, 11, 15, 10, 0),
|
||||||
processedAt: DateTime(2023, 11, 20, 14, 30),
|
processedAt: DateTime(2025, 11, 20, 14, 30),
|
||||||
processedBy: 'admin001',
|
processedBy: 'admin001',
|
||||||
),
|
),
|
||||||
PointsRecord(
|
PointsRecord(
|
||||||
@@ -48,21 +48,21 @@ class PointsRecordRemoteDataSourceImpl
|
|||||||
userId: 'user123',
|
userId: 'user123',
|
||||||
invoiceNumber: 'INV-BTX-002',
|
invoiceNumber: 'INV-BTX-002',
|
||||||
storeName: 'Tập đoàn Bitexco',
|
storeName: 'Tập đoàn Bitexco',
|
||||||
transactionDate: DateTime(2023, 11, 25),
|
transactionDate: DateTime(2025, 11, 25),
|
||||||
invoiceAmount: 1250000,
|
invoiceAmount: 1250000,
|
||||||
notes: 'Gạch porcelain 80x80 cho sảnh chính và khu mua sắm',
|
notes: 'Gạch porcelain 80x80 cho sảnh chính và khu mua sắm',
|
||||||
attachments: const [
|
attachments: const [
|
||||||
'https://example.com/invoice3.jpg',
|
'https://example.com/invoice3.jpg',
|
||||||
],
|
],
|
||||||
status: PointsStatus.pending,
|
status: PointsStatus.pending,
|
||||||
submittedAt: DateTime(2023, 11, 25, 9, 15),
|
submittedAt: DateTime(2025, 11, 25, 9, 15),
|
||||||
),
|
),
|
||||||
PointsRecord(
|
PointsRecord(
|
||||||
recordId: 'PRR003',
|
recordId: 'PRR003',
|
||||||
userId: 'user123',
|
userId: 'user123',
|
||||||
invoiceNumber: 'INV-ABC-003',
|
invoiceNumber: 'INV-ABC-003',
|
||||||
storeName: 'Công ty TNHH ABC Manufacturing',
|
storeName: 'Công ty TNHH ABC Manufacturing',
|
||||||
transactionDate: DateTime(2023, 11, 20),
|
transactionDate: DateTime(2025, 11, 20),
|
||||||
invoiceAmount: 4200000,
|
invoiceAmount: 4200000,
|
||||||
notes: 'Gạch chống trơn cho khu vực sản xuất và kho bãi',
|
notes: 'Gạch chống trơn cho khu vực sản xuất và kho bãi',
|
||||||
attachments: const [
|
attachments: const [
|
||||||
@@ -71,8 +71,8 @@ class PointsRecordRemoteDataSourceImpl
|
|||||||
],
|
],
|
||||||
status: PointsStatus.rejected,
|
status: PointsStatus.rejected,
|
||||||
rejectReason: 'Hình ảnh minh chứng không hợp lệ',
|
rejectReason: 'Hình ảnh minh chứng không hợp lệ',
|
||||||
submittedAt: DateTime(2023, 11, 20, 11, 0),
|
submittedAt: DateTime(2025, 11, 20, 11, 0),
|
||||||
processedAt: DateTime(2023, 11, 28, 16, 45),
|
processedAt: DateTime(2025, 11, 28, 16, 45),
|
||||||
processedBy: 'admin002',
|
processedBy: 'admin002',
|
||||||
),
|
),
|
||||||
PointsRecord(
|
PointsRecord(
|
||||||
@@ -80,7 +80,7 @@ class PointsRecordRemoteDataSourceImpl
|
|||||||
userId: 'user123',
|
userId: 'user123',
|
||||||
invoiceNumber: 'INV-ECO-004',
|
invoiceNumber: 'INV-ECO-004',
|
||||||
storeName: 'Ecopark Group',
|
storeName: 'Ecopark Group',
|
||||||
transactionDate: DateTime(2023, 10, 10),
|
transactionDate: DateTime(2025, 10, 10),
|
||||||
invoiceAmount: 3700000,
|
invoiceAmount: 3700000,
|
||||||
notes: 'Gạch ceramic vân gỗ cho khu vực phòng khách và sân vườn',
|
notes: 'Gạch ceramic vân gỗ cho khu vực phòng khách và sân vườn',
|
||||||
attachments: const [
|
attachments: const [
|
||||||
@@ -88,8 +88,8 @@ class PointsRecordRemoteDataSourceImpl
|
|||||||
],
|
],
|
||||||
status: PointsStatus.approved,
|
status: PointsStatus.approved,
|
||||||
pointsEarned: 370,
|
pointsEarned: 370,
|
||||||
submittedAt: DateTime(2023, 10, 10, 8, 30),
|
submittedAt: DateTime(2025, 10, 10, 8, 30),
|
||||||
processedAt: DateTime(2023, 10, 15, 10, 20),
|
processedAt: DateTime(2025, 10, 15, 10, 20),
|
||||||
processedBy: 'admin001',
|
processedBy: 'admin001',
|
||||||
),
|
),
|
||||||
PointsRecord(
|
PointsRecord(
|
||||||
@@ -97,7 +97,7 @@ class PointsRecordRemoteDataSourceImpl
|
|||||||
userId: 'user123',
|
userId: 'user123',
|
||||||
invoiceNumber: 'INV-DMD-005',
|
invoiceNumber: 'INV-DMD-005',
|
||||||
storeName: 'Diamond Hospitality Group',
|
storeName: 'Diamond Hospitality Group',
|
||||||
transactionDate: DateTime(2023, 12, 1),
|
transactionDate: DateTime(2025, 12, 1),
|
||||||
invoiceAmount: 8600000,
|
invoiceAmount: 8600000,
|
||||||
notes: 'Gạch marble tự nhiên cho lobby và phòng suite',
|
notes: 'Gạch marble tự nhiên cho lobby và phòng suite',
|
||||||
attachments: const [
|
attachments: const [
|
||||||
@@ -106,7 +106,7 @@ class PointsRecordRemoteDataSourceImpl
|
|||||||
'https://example.com/invoice9.jpg',
|
'https://example.com/invoice9.jpg',
|
||||||
],
|
],
|
||||||
status: PointsStatus.pending,
|
status: PointsStatus.pending,
|
||||||
submittedAt: DateTime(2023, 12, 1, 13, 0),
|
submittedAt: DateTime(2025, 12, 1, 13, 0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,26 +9,28 @@ 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/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
import 'package:worker/core/database/models/enums.dart';
|
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
import 'package:worker/features/loyalty/data/datasources/points_history_local_datasource.dart';
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.dart';
|
import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/providers/points_history_provider.dart';
|
import 'package:worker/features/loyalty/presentation/providers/points_history_provider.dart';
|
||||||
|
|
||||||
/// Points History Page
|
/// Points History Page
|
||||||
///
|
///
|
||||||
/// Features:
|
/// Features:
|
||||||
/// - Filter section with date range
|
/// - Filter section with date range picker
|
||||||
/// - List of transaction cards
|
/// - List of transaction cards with new design
|
||||||
/// - Each card shows: description, date, amount, points change, new balance
|
/// - Each card shows: code, date, description, reference, points change, balance after
|
||||||
/// - Complaint button for each transaction
|
|
||||||
class PointsHistoryPage extends ConsumerWidget {
|
class PointsHistoryPage extends ConsumerWidget {
|
||||||
const PointsHistoryPage({super.key});
|
const PointsHistoryPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
// Use unfiltered data for now (mock data)
|
||||||
final historyAsync = ref.watch(pointsHistoryProvider);
|
final historyAsync = ref.watch(pointsHistoryProvider);
|
||||||
|
final filter = ref.watch(pointsHistoryFilterProvider);
|
||||||
|
//todo: implement filtering logic in provider later
|
||||||
|
//:note: final historyAsync = ref.watch(filteredPointsHistoryProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.surfaceContainerLowest,
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
@@ -52,43 +54,46 @@ class PointsHistoryPage extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
child: historyAsync.when(
|
child: historyAsync.when(
|
||||||
data: (entries) {
|
data: (entries) {
|
||||||
if (entries.isEmpty) {
|
return ListView(
|
||||||
return _buildEmptyState(colorScheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
// Filter Section
|
||||||
children: [
|
_buildFilterSection(context, ref, colorScheme, filter),
|
||||||
// Filter Section
|
|
||||||
_buildFilterSection(colorScheme),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Transaction List
|
// Empty state or Transaction List
|
||||||
|
if (entries.isEmpty)
|
||||||
|
_buildEmptyStateInline(colorScheme)
|
||||||
|
else
|
||||||
...entries.map(
|
...entries.map(
|
||||||
(entry) => _buildTransactionCard(context, ref, entry, colorScheme),
|
(entry) => _buildTransactionCard(context, entry, colorScheme),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => _buildErrorState(error, colorScheme),
|
error: (error, stack) => _buildErrorState(error, colorScheme),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build filter section
|
/// Build filter section with date pickers
|
||||||
Widget _buildFilterSection(ColorScheme colorScheme) {
|
Widget _buildFilterSection(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
PointsHistoryFilter filter,
|
||||||
|
) {
|
||||||
|
final dateFormatter = DateFormat('dd/MM/yyyy');
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -103,156 +108,130 @@ class PointsHistoryPage extends ConsumerWidget {
|
|||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FaIcon(FontAwesomeIcons.sliders, color: colorScheme.primary, size: 18),
|
FaIcon(FontAwesomeIcons.filter, color: colorScheme.primary, size: 18),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 16),
|
||||||
Text(
|
|
||||||
'Thời gian hiệu lực: 01/01/2023 - 31/12/2023',
|
|
||||||
style: TextStyle(fontSize: 12, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build transaction card
|
// Date range row
|
||||||
Widget _buildTransactionCard(
|
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
LoyaltyPointEntryModel entry,
|
|
||||||
ColorScheme colorScheme,
|
|
||||||
) {
|
|
||||||
final dateFormatter = DateFormat('dd/MM/yyyy HH:mm:ss');
|
|
||||||
final currencyFormatter = NumberFormat.currency(
|
|
||||||
locale: 'vi_VN',
|
|
||||||
symbol: 'VND',
|
|
||||||
decimalDigits: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get transaction amount if it's a purchase
|
|
||||||
final datasource = ref.read(pointsHistoryLocalDataSourceProvider);
|
|
||||||
final transactionAmount = datasource.getTransactionAmount(
|
|
||||||
entry.description,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
elevation: 1,
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Top row: Description and Complaint button
|
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
|
// Start date
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: _buildDateField(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
context: context,
|
||||||
children: [
|
colorScheme: colorScheme,
|
||||||
// Description
|
label: 'Từ ngày',
|
||||||
Text(
|
value: filter.startDate,
|
||||||
entry.description,
|
dateFormatter: dateFormatter,
|
||||||
style: TextStyle(
|
onTap: () async {
|
||||||
fontSize: 15,
|
final date = await showDatePicker(
|
||||||
fontWeight: FontWeight.w500,
|
context: context,
|
||||||
color: colorScheme.primary,
|
initialDate: filter.startDate ?? DateTime.now(),
|
||||||
),
|
firstDate: DateTime(2020),
|
||||||
),
|
lastDate: filter.endDate ?? DateTime.now(),
|
||||||
const SizedBox(height: 4),
|
);
|
||||||
|
if (date != null) {
|
||||||
// Timestamp
|
ref.read(pointsHistoryFilterProvider.notifier).setStartDate(date);
|
||||||
Text(
|
}
|
||||||
'Thời gian: ${dateFormatter.format(entry.timestamp)}',
|
},
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Transaction amount (if purchase)
|
|
||||||
if (transactionAmount != null) ...[
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'Giao dịch: ${currencyFormatter.format(transactionAmount)}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
// End date
|
||||||
// Complaint button
|
Expanded(
|
||||||
OutlinedButton(
|
child: _buildDateField(
|
||||||
onPressed: () {
|
context: context,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
colorScheme: colorScheme,
|
||||||
const SnackBar(
|
label: 'Đến ngày',
|
||||||
content: Text('Chức năng khiếu nại đang phát triển'),
|
value: filter.endDate,
|
||||||
),
|
dateFormatter: dateFormatter,
|
||||||
);
|
onTap: () async {
|
||||||
},
|
final date = await showDatePicker(
|
||||||
style: OutlinedButton.styleFrom(
|
context: context,
|
||||||
padding: const EdgeInsets.symmetric(
|
initialDate: filter.endDate ?? DateTime.now(),
|
||||||
horizontal: 12,
|
firstDate: filter.startDate ?? DateTime(2020),
|
||||||
vertical: 6,
|
lastDate: DateTime.now(),
|
||||||
),
|
);
|
||||||
side: BorderSide(color: colorScheme.onSurfaceVariant),
|
if (date != null) {
|
||||||
foregroundColor: colorScheme.onSurface,
|
ref.read(pointsHistoryFilterProvider.notifier).setEndDate(date);
|
||||||
textStyle: const TextStyle(fontSize: 12),
|
}
|
||||||
minimumSize: const Size(0, 32),
|
},
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: const Text('Khiếu nại'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Bottom row: Points change and new balance
|
// Quick filter chips
|
||||||
Row(
|
SingleChildScrollView(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
scrollDirection: Axis.horizontal,
|
||||||
children: [
|
child: Row(
|
||||||
Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
_buildQuickFilterChip(
|
||||||
children: [
|
context: context,
|
||||||
// Points change
|
ref: ref,
|
||||||
Text(
|
colorScheme: colorScheme,
|
||||||
entry.points > 0 ? '+${entry.points}' : '${entry.points}',
|
label: 'Hôm nay',
|
||||||
style: TextStyle(
|
onTap: () {
|
||||||
fontSize: 16,
|
final now = DateTime.now();
|
||||||
fontWeight: FontWeight.w500,
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
color: entry.points > 0
|
ref.read(pointsHistoryFilterProvider.notifier).setDateRange(today, today);
|
||||||
? AppColors.success
|
},
|
||||||
: entry.points < 0
|
),
|
||||||
? AppColors.danger
|
const SizedBox(width: 8),
|
||||||
: colorScheme.onSurface,
|
_buildQuickFilterChip(
|
||||||
),
|
context: context,
|
||||||
),
|
ref: ref,
|
||||||
const SizedBox(height: 2),
|
colorScheme: colorScheme,
|
||||||
|
label: '7 ngày',
|
||||||
// New balance
|
onTap: () {
|
||||||
Text(
|
final now = DateTime.now();
|
||||||
'Điểm mới: ${entry.balanceAfter}',
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
style: TextStyle(
|
final weekAgo = today.subtract(const Duration(days: 7));
|
||||||
fontSize: 12,
|
ref.read(pointsHistoryFilterProvider.notifier).setDateRange(weekAgo, today);
|
||||||
color: colorScheme.primary,
|
},
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
],
|
_buildQuickFilterChip(
|
||||||
),
|
context: context,
|
||||||
],
|
ref: ref,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
label: '30 ngày',
|
||||||
|
onTap: () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final monthAgo = today.subtract(const Duration(days: 30));
|
||||||
|
ref.read(pointsHistoryFilterProvider.notifier).setDateRange(monthAgo, today);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildQuickFilterChip(
|
||||||
|
context: context,
|
||||||
|
ref: ref,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
label: 'Năm nay',
|
||||||
|
onTap: () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
ref.read(pointsHistoryFilterProvider.notifier).setDateRange(
|
||||||
|
DateTime(now.year, 1, 1),
|
||||||
|
DateTime(now.year, 12, 31),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildQuickFilterChip(
|
||||||
|
context: context,
|
||||||
|
ref: ref,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
label: 'Tất cả',
|
||||||
|
onTap: () {
|
||||||
|
ref.read(pointsHistoryFilterProvider.notifier).clearFilter();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -260,30 +239,257 @@ class PointsHistoryPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build empty state
|
Widget _buildDateField({
|
||||||
Widget _buildEmptyState(ColorScheme colorScheme) {
|
required BuildContext context,
|
||||||
return Center(
|
required ColorScheme colorScheme,
|
||||||
|
required String label,
|
||||||
|
required DateTime? value,
|
||||||
|
required DateFormat dateFormatter,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value != null ? dateFormatter.format(value) : 'Chọn',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: value != null ? colorScheme.onSurface : colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FaIcon(
|
||||||
|
FontAwesomeIcons.calendar,
|
||||||
|
size: 14,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuickFilterChip({
|
||||||
|
required BuildContext context,
|
||||||
|
required WidgetRef ref,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
required String label,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build transaction card with new design
|
||||||
|
Widget _buildTransactionCard(
|
||||||
|
BuildContext context,
|
||||||
|
LoyaltyPointEntryModel entry,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
) {
|
||||||
|
final dateFormatter = DateFormat('dd/MM/yyyy');
|
||||||
|
|
||||||
|
// Determine points color
|
||||||
|
Color pointsColor;
|
||||||
|
String pointsPrefix;
|
||||||
|
if (entry.points > 0) {
|
||||||
|
pointsColor = AppColors.success;
|
||||||
|
pointsPrefix = '+';
|
||||||
|
} else if (entry.points < 0) {
|
||||||
|
pointsColor = AppColors.danger;
|
||||||
|
pointsPrefix = '';
|
||||||
|
} else {
|
||||||
|
pointsColor = colorScheme.onSurfaceVariant;
|
||||||
|
pointsPrefix = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 1,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header: Code and Date
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
entry.entryId,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
dateFormatter.format(entry.timestamp),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content: Description and Points
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Description
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
entry.description,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Mã tham chiếu: ${entry.entryId}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// Points
|
||||||
|
Text(
|
||||||
|
'$pointsPrefix${entry.points} điểm',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: pointsColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Footer: Balance After
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerLowest,
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Số dư sau: ',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${entry.balanceAfter} điểm',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build inline empty state (inside scrollable list)
|
||||||
|
Widget _buildEmptyStateInline(ColorScheme colorScheme) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 60),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
FaIcon(
|
FaIcon(
|
||||||
FontAwesomeIcons.clockRotateLeft,
|
FontAwesomeIcons.clockRotateLeft,
|
||||||
size: 80,
|
size: 64,
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Chưa có lịch sử điểm',
|
'Không có giao dịch',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Kéo xuống để làm mới',
|
'Không tìm thấy giao dịch trong khoảng thời gian này',
|
||||||
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
|
style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -292,7 +498,6 @@ class PointsHistoryPage extends ConsumerWidget {
|
|||||||
|
|
||||||
/// Build error state
|
/// Build error state
|
||||||
Widget _buildErrorState(Object error, ColorScheme colorScheme) {
|
Widget _buildErrorState(Object error, ColorScheme colorScheme) {
|
||||||
print(error.toString());
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,9 @@ 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/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
|
import 'package:worker/core/router/app_router.dart';
|
||||||
import 'package:worker/core/theme/colors.dart';
|
import 'package:worker/core/theme/colors.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:worker/features/loyalty/domain/entities/points_record.dart';
|
import 'package:worker/features/loyalty/domain/entities/points_record.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/providers/points_records_provider.dart';
|
import 'package:worker/features/loyalty/presentation/providers/points_records_provider.dart';
|
||||||
|
|
||||||
@@ -46,13 +48,12 @@ class PointsRecordsPage extends ConsumerWidget {
|
|||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
// TODO: Navigate to points record create page
|
final result = await context.push<bool>(RouteNames.pointsRecordCreate);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (result == true) {
|
||||||
const SnackBar(
|
// Refresh list after successful creation
|
||||||
content: Text('Tính năng tạo ghi nhận điểm sẽ được cập nhật'),
|
ref.invalidate(allPointsRecordsProvider);
|
||||||
),
|
}
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: AppSpacing.sm),
|
const SizedBox(width: AppSpacing.sm),
|
||||||
@@ -183,9 +184,7 @@ class PointsRecordsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(
|
loading: () => const CustomLoadingIndicator(),
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
error: (error, stack) => RefreshIndicator(
|
error: (error, stack) => RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await ref.read(allPointsRecordsProvider.notifier).refresh();
|
await ref.read(allPointsRecordsProvider.notifier).refresh();
|
||||||
@@ -296,7 +295,7 @@ class PointsRecordsPage extends ConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'#${record.recordId}',
|
record.recordId,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:worker/features/loyalty/presentation/providers/gifts_provider.da
|
|||||||
import 'package:worker/features/loyalty/presentation/providers/loyalty_points_provider.dart';
|
import 'package:worker/features/loyalty/presentation/providers/loyalty_points_provider.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/widgets/points_balance_card.dart';
|
import 'package:worker/features/loyalty/presentation/widgets/points_balance_card.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/widgets/reward_card.dart';
|
import 'package:worker/features/loyalty/presentation/widgets/reward_card.dart';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
|
||||||
/// Rewards Page
|
/// Rewards Page
|
||||||
///
|
///
|
||||||
@@ -441,6 +442,12 @@ class RewardsPage extends ConsumerWidget {
|
|||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
GiftCatalog gift,
|
GiftCatalog gift,
|
||||||
) {
|
) {
|
||||||
|
// Log spend points analytics event
|
||||||
|
AnalyticsService.logSpendPoints(
|
||||||
|
points: gift.pointsCost,
|
||||||
|
itemName: gift.name,
|
||||||
|
);
|
||||||
|
|
||||||
// Deduct points
|
// Deduct points
|
||||||
ref.read(loyaltyPointsProvider.notifier).deductPoints(gift.pointsCost);
|
ref.read(loyaltyPointsProvider.notifier).deductPoints(gift.pointsCost);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,59 @@ import 'package:worker/features/loyalty/data/models/loyalty_point_entry_model.da
|
|||||||
|
|
||||||
part 'points_history_provider.g.dart';
|
part 'points_history_provider.g.dart';
|
||||||
|
|
||||||
|
/// Points History Filter State
|
||||||
|
class PointsHistoryFilter {
|
||||||
|
const PointsHistoryFilter({
|
||||||
|
this.startDate,
|
||||||
|
this.endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DateTime? startDate;
|
||||||
|
final DateTime? endDate;
|
||||||
|
|
||||||
|
PointsHistoryFilter copyWith({
|
||||||
|
DateTime? startDate,
|
||||||
|
DateTime? endDate,
|
||||||
|
bool clearStartDate = false,
|
||||||
|
bool clearEndDate = false,
|
||||||
|
}) {
|
||||||
|
return PointsHistoryFilter(
|
||||||
|
startDate: clearStartDate ? null : (startDate ?? this.startDate),
|
||||||
|
endDate: clearEndDate ? null : (endDate ?? this.endDate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Points History Filter Provider
|
||||||
|
@riverpod
|
||||||
|
class PointsHistoryFilterNotifier extends _$PointsHistoryFilterNotifier {
|
||||||
|
@override
|
||||||
|
PointsHistoryFilter build() {
|
||||||
|
// Default: current year
|
||||||
|
final now = DateTime.now();
|
||||||
|
return PointsHistoryFilter(
|
||||||
|
startDate: DateTime(now.year, 1, 1),
|
||||||
|
endDate: DateTime(now.year, 12, 31),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDateRange(DateTime? start, DateTime? end) {
|
||||||
|
state = PointsHistoryFilter(startDate: start, endDate: end);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setStartDate(DateTime? date) {
|
||||||
|
state = state.copyWith(startDate: date);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setEndDate(DateTime? date) {
|
||||||
|
state = state.copyWith(endDate: date);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearFilter() {
|
||||||
|
state = const PointsHistoryFilter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Points History Local Data Source Provider
|
/// Points History Local Data Source Provider
|
||||||
@riverpod
|
@riverpod
|
||||||
PointsHistoryLocalDataSource pointsHistoryLocalDataSource(Ref ref) {
|
PointsHistoryLocalDataSource pointsHistoryLocalDataSource(Ref ref) {
|
||||||
@@ -44,3 +97,41 @@ class PointsHistory extends _$PointsHistory {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filtered Points History Provider
|
||||||
|
@riverpod
|
||||||
|
Future<List<LoyaltyPointEntryModel>> filteredPointsHistory(Ref ref) async {
|
||||||
|
final entries = await ref.watch(pointsHistoryProvider.future);
|
||||||
|
final filter = ref.watch(pointsHistoryFilterProvider);
|
||||||
|
|
||||||
|
return entries.where((entry) {
|
||||||
|
// Filter by start date
|
||||||
|
if (filter.startDate != null) {
|
||||||
|
final startOfDay = DateTime(
|
||||||
|
filter.startDate!.year,
|
||||||
|
filter.startDate!.month,
|
||||||
|
filter.startDate!.day,
|
||||||
|
);
|
||||||
|
if (entry.timestamp.isBefore(startOfDay)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by end date
|
||||||
|
if (filter.endDate != null) {
|
||||||
|
final endOfDay = DateTime(
|
||||||
|
filter.endDate!.year,
|
||||||
|
filter.endDate!.month,
|
||||||
|
filter.endDate!.day,
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
);
|
||||||
|
if (entry.timestamp.isAfter(endOfDay)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,68 @@ part of 'points_history_provider.dart';
|
|||||||
|
|
||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
// ignore_for_file: type=lint, type=warning
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Points History Filter Provider
|
||||||
|
|
||||||
|
@ProviderFor(PointsHistoryFilterNotifier)
|
||||||
|
const pointsHistoryFilterProvider = PointsHistoryFilterNotifierProvider._();
|
||||||
|
|
||||||
|
/// Points History Filter Provider
|
||||||
|
final class PointsHistoryFilterNotifierProvider
|
||||||
|
extends
|
||||||
|
$NotifierProvider<PointsHistoryFilterNotifier, PointsHistoryFilter> {
|
||||||
|
/// Points History Filter Provider
|
||||||
|
const PointsHistoryFilterNotifierProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'pointsHistoryFilterProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$pointsHistoryFilterNotifierHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
PointsHistoryFilterNotifier create() => PointsHistoryFilterNotifier();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(PointsHistoryFilter value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<PointsHistoryFilter>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$pointsHistoryFilterNotifierHash() =>
|
||||||
|
r'ef2587f4461c9488d9b15ed033e1d362042795f8';
|
||||||
|
|
||||||
|
/// Points History Filter Provider
|
||||||
|
|
||||||
|
abstract class _$PointsHistoryFilterNotifier
|
||||||
|
extends $Notifier<PointsHistoryFilter> {
|
||||||
|
PointsHistoryFilter build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final created = build();
|
||||||
|
final ref = this.ref as $Ref<PointsHistoryFilter, PointsHistoryFilter>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<PointsHistoryFilter, PointsHistoryFilter>,
|
||||||
|
PointsHistoryFilter,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleValue(ref, created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Points History Local Data Source Provider
|
/// Points History Local Data Source Provider
|
||||||
|
|
||||||
@ProviderFor(pointsHistoryLocalDataSource)
|
@ProviderFor(pointsHistoryLocalDataSource)
|
||||||
@@ -130,3 +192,50 @@ abstract class _$PointsHistory
|
|||||||
element.handleValue(ref, created);
|
element.handleValue(ref, created);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filtered Points History Provider
|
||||||
|
|
||||||
|
@ProviderFor(filteredPointsHistory)
|
||||||
|
const filteredPointsHistoryProvider = FilteredPointsHistoryProvider._();
|
||||||
|
|
||||||
|
/// Filtered Points History Provider
|
||||||
|
|
||||||
|
final class FilteredPointsHistoryProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AsyncValue<List<LoyaltyPointEntryModel>>,
|
||||||
|
List<LoyaltyPointEntryModel>,
|
||||||
|
FutureOr<List<LoyaltyPointEntryModel>>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<List<LoyaltyPointEntryModel>>,
|
||||||
|
$FutureProvider<List<LoyaltyPointEntryModel>> {
|
||||||
|
/// Filtered Points History Provider
|
||||||
|
const FilteredPointsHistoryProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'filteredPointsHistoryProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$filteredPointsHistoryHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<List<LoyaltyPointEntryModel>> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<LoyaltyPointEntryModel>> create(Ref ref) {
|
||||||
|
return filteredPointsHistory(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$filteredPointsHistoryHash() =>
|
||||||
|
r'989e2bf824eeb161b44b67d9ee81b713444a6e87';
|
||||||
|
|||||||
@@ -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:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:worker/features/loyalty/domain/entities/gift_catalog.dart';
|
import 'package:worker/features/loyalty/domain/entities/gift_catalog.dart';
|
||||||
import 'package:worker/features/loyalty/presentation/providers/loyalty_points_provider.dart';
|
import 'package:worker/features/loyalty/presentation/providers/loyalty_points_provider.dart';
|
||||||
|
|
||||||
@@ -150,8 +151,8 @@ class RewardCard extends ConsumerWidget {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CustomLoadingIndicator(color: Theme.of(context).colorScheme.primary, size: 40),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_html/flutter_html.dart';
|
import 'package:flutter_html/flutter_html.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -60,7 +61,7 @@ class _NewsDetailPageState extends ConsumerState<NewsDetailPage> {
|
|||||||
}
|
}
|
||||||
return _buildContent(context, article);
|
return _buildContent(context, article);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
error: (error, stack) => _buildErrorState(context, error.toString()),
|
error: (error, stack) => _buildErrorState(context, error.toString()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -126,7 +127,7 @@ class _NewsDetailPageState extends ConsumerState<NewsDetailPage> {
|
|||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
height: 250,
|
height: 250,
|
||||||
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: 250,
|
height: 250,
|
||||||
|
|||||||
@@ -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_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';
|
||||||
@@ -98,7 +99,7 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
|
|||||||
loading: () => const SliverToBoxAdapter(
|
loading: () => const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(AppSpacing.md),
|
padding: EdgeInsets.all(AppSpacing.md),
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: const CustomLoadingIndicator(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (error, stack) =>
|
error: (error, stack) =>
|
||||||
@@ -158,7 +159,7 @@ class _NewsListPageState extends ConsumerState<NewsListPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const SliverFillRemaining(
|
loading: () => const SliverFillRemaining(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: const CustomLoadingIndicator(),
|
||||||
),
|
),
|
||||||
error: (error, stack) => SliverFillRemaining(
|
error: (error, stack) => SliverFillRemaining(
|
||||||
child: _buildErrorState(error.toString()),
|
child: _buildErrorState(error.toString()),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.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:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
@@ -65,7 +66,7 @@ class FeaturedNewsCard extends StatelessWidget {
|
|||||||
placeholder: (context, url) => Container(
|
placeholder: (context, url) => Container(
|
||||||
height: 200,
|
height: 200,
|
||||||
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: 200,
|
height: 200,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.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:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
@@ -57,11 +58,11 @@ class NewsCard extends StatelessWidget {
|
|||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CustomLoadingIndicator(color: colorScheme.primary, size: 20),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.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:worker/core/constants/ui_constants.dart';
|
import 'package:worker/core/constants/ui_constants.dart';
|
||||||
@@ -56,11 +57,11 @@ class RelatedArticleCard extends StatelessWidget {
|
|||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CustomLoadingIndicator(color: colorScheme.primary, size: 20),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart' hide Notification;
|
import 'package:flutter/material.dart' hide Notification;
|
||||||
|
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:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -26,6 +27,8 @@ class NotificationsPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Use Flutter hooks for local state management
|
// Use Flutter hooks for local state management
|
||||||
final selectedCategory = useState<String>('general');
|
final selectedCategory = useState<String>('general');
|
||||||
final notificationsAsync = ref.watch(
|
final notificationsAsync = ref.watch(
|
||||||
@@ -33,59 +36,38 @@ class NotificationsPage extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF4F6F8),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
body: SafeArea(
|
appBar: AppBar(
|
||||||
child: Column(
|
title: Text(
|
||||||
children: [
|
'Thông báo',
|
||||||
// Header
|
style: TextStyle(color: colorScheme.onSurface),
|
||||||
_buildHeader(),
|
|
||||||
|
|
||||||
// Tabs
|
|
||||||
_buildTabs(context, selectedCategory),
|
|
||||||
|
|
||||||
// Notifications List
|
|
||||||
Expanded(
|
|
||||||
child: notificationsAsync.when(
|
|
||||||
data: (notifications) => _buildNotificationsList(
|
|
||||||
context,
|
|
||||||
ref,
|
|
||||||
notifications,
|
|
||||||
selectedCategory,
|
|
||||||
),
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (error, stack) =>
|
|
||||||
_buildErrorState(ref, selectedCategory),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
elevation: AppBarSpecs.elevation,
|
||||||
|
backgroundColor: colorScheme.surface,
|
||||||
|
foregroundColor: colorScheme.onSurface,
|
||||||
|
centerTitle: false,
|
||||||
),
|
),
|
||||||
);
|
body: Column(
|
||||||
}
|
children: [
|
||||||
|
// Tabs
|
||||||
|
_buildTabs(context, selectedCategory),
|
||||||
|
|
||||||
/// Build header
|
// Notifications List
|
||||||
Widget _buildHeader() {
|
Expanded(
|
||||||
return Container(
|
child: notificationsAsync.when(
|
||||||
width: double.infinity,
|
data: (notifications) => _buildNotificationsList(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
context,
|
||||||
decoration: BoxDecoration(
|
ref,
|
||||||
color: Colors.white,
|
notifications,
|
||||||
boxShadow: [
|
selectedCategory,
|
||||||
BoxShadow(
|
),
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
loading: () => const CustomLoadingIndicator(),
|
||||||
blurRadius: 8,
|
error: (error, stack) =>
|
||||||
offset: const Offset(0, 2),
|
_buildErrorState(ref, selectedCategory),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Text(
|
|
||||||
'Thông báo',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Color(0xFF212121),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:worker/core/enums/status_color.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';
|
||||||
import 'package:worker/core/utils/extensions.dart';
|
import 'package:worker/core/utils/extensions.dart';
|
||||||
|
import 'package:worker/core/widgets/loading_indicator.dart';
|
||||||
import 'package:worker/features/account/domain/entities/address.dart';
|
import 'package:worker/features/account/domain/entities/address.dart';
|
||||||
import 'package:worker/features/orders/domain/entities/order_detail.dart';
|
import 'package:worker/features/orders/domain/entities/order_detail.dart';
|
||||||
import 'package:worker/features/orders/presentation/providers/orders_provider.dart';
|
import 'package:worker/features/orders/presentation/providers/orders_provider.dart';
|
||||||
@@ -135,7 +136,7 @@ class OrderDetailPage extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
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,
|
||||||
@@ -204,7 +205,7 @@ class OrderDetailPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Order Number and Status Badge
|
// Order Number and Status Badge
|
||||||
Text(
|
Text(
|
||||||
'#${order.name}',
|
order.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@@ -1001,7 +1002,7 @@ class OrderDetailPage extends ConsumerWidget {
|
|||||||
height: 60,
|
height: 60,
|
||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CustomLoadingIndicator(size: 20),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) => Container(
|
errorWidget: (context, url, error) => Container(
|
||||||
@@ -1524,13 +1525,9 @@ class OrderDetailPage extends ConsumerWidget {
|
|||||||
SnackBar(
|
SnackBar(
|
||||||
content: Row(
|
content: Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
CustomLoadingIndicator(
|
||||||
width: 20,
|
size: 20,
|
||||||
height: 20,
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).colorScheme.onPrimary),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Text('Đang hủy đơn hàng...'),
|
const Text('Đang hủy đơn hàng...'),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:intl/intl.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';
|
||||||
|
import 'package:worker/core/services/analytics_service.dart';
|
||||||
|
|
||||||
/// Order Success Page
|
/// Order Success Page
|
||||||
class OrderSuccessPage extends StatelessWidget {
|
class OrderSuccessPage extends StatelessWidget {
|
||||||
@@ -37,6 +38,15 @@ class OrderSuccessPage extends StatelessWidget {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
|
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
|
||||||
|
|
||||||
|
// Log purchase analytics event (only for actual purchases, not negotiations)
|
||||||
|
if (!isNegotiation && total != null) {
|
||||||
|
AnalyticsService.logPurchase(
|
||||||
|
orderId: orderNumber,
|
||||||
|
value: total!,
|
||||||
|
items: [], // Items not available in this page
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
|||||||
@@ -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: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';
|
||||||
@@ -351,7 +352,7 @@ class _OrdersPageState extends ConsumerState<OrdersPage> {
|
|||||||
/// Build loading state
|
/// Build loading state
|
||||||
Widget _buildLoadingState() {
|
Widget _buildLoadingState() {
|
||||||
return const SliverFillRemaining(
|
return const SliverFillRemaining(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: const CustomLoadingIndicator(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.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';
|
||||||
@@ -192,7 +193,7 @@ class PaymentDetailPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
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,
|
||||||
@@ -247,7 +248,7 @@ class PaymentDetailPage extends ConsumerWidget {
|
|||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'#$invoiceNumber',
|
'$invoiceNumber',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user