Compare commits
5 Commits
453984cd57
...
2f296ad8d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f296ad8d3 | ||
|
|
b5afeed534 | ||
|
|
47cdf71968 | ||
|
|
4e40a52b84 | ||
|
|
b367d405c4 |
@@ -6,7 +6,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.worker"
|
||||
namespace = "com.dbiz.partner"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@@ -21,7 +21,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.example.worker"
|
||||
applicationId = "com.dbiz.partner"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.example.worker
|
||||
package com.dbiz.partner
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
53
docs/products.sh
Normal file
53
docs/products.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
get product list
|
||||
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.item.get_list' \
|
||||
--header 'X-Frappe-Csrf-Token: 2080d7c5952833b5080de1f93012ae019731aa00e79f93ae787869f3' \
|
||||
--header 'Cookie: sid=f5fa31ebf6901e99fc7fda974a3c6e524949bc38e551a39544d7d0e2; full_name=Ha%20Duy%20Lam; sid=f5fa31ebf6901e99fc7fda974a3c6e524949bc38e551a39544d7d0e2; system_user=yes; user_id=lamhd%40gmail.com; user_image=' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"limit_start" : 0,
|
||||
"limit_page_length": 0
|
||||
}'
|
||||
|
||||
get product attribute list
|
||||
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.item_attribute.get_list' \
|
||||
--header 'X-Frappe-Csrf-Token: 13c271e0e58dcad9bcc0053cad0057540eb0675bb7052c2cc1a815b2' \
|
||||
--header 'Cookie: sid=d9ddd3862832f12901ef4c0d77d6891cd08ef851a254b7d56c857724; full_name=Ha%20Duy%20Lam; sid=d9ddd3862832f12901ef4c0d77d6891cd08ef851a254b7d56c857724; system_user=yes; user_id=lamhd%40gmail.com; user_image=' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"filters": {"is_group": 0},
|
||||
"limit_page_length": 0
|
||||
}'
|
||||
|
||||
get product brand
|
||||
curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
|
||||
--header 'X-Frappe-Csrf-Token: 52e3deff2accdc4d990312508dff6be0ecae61e01da837f00b2bfae9' \
|
||||
--header 'Cookie: sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; full_name=PublicAPI; sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; system_user=no; user_id=public_api%40dbiz.com; user_image=' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"doctype": "Brand",
|
||||
"fields": ["name"],
|
||||
"limit_page_length": 0
|
||||
}'
|
||||
|
||||
get product group
|
||||
curl --location 'https://land.dbiz.com//api/method/frappe.client.get_list' \
|
||||
--header 'X-Frappe-Csrf-Token: 52e3deff2accdc4d990312508dff6be0ecae61e01da837f00b2bfae9' \
|
||||
--header 'Cookie: sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; full_name=PublicAPI; sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; system_user=no; user_id=public_api%40dbiz.com; user_image=' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"doctype": "Item Group",
|
||||
"fields": ["item_group_name","name"],
|
||||
"filters": {"is_group": 0},
|
||||
"limit_page_length": 0
|
||||
}'
|
||||
|
||||
get product detail
|
||||
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.item.get_detail' \
|
||||
--header 'X-Frappe-Csrf-Token: 4989ff095956a891bbae0944a1483097b6eb06f1080961f7164a7e17' \
|
||||
--header 'Cookie: sid=42ab54811fb7eadc8c67a6651c68519c8655e9b3e7b797628dcd0b88; full_name=PublicAPI; sid=723d7a4c28209a1c5451d2dce1f7232c04addb2e040a273f3a56ea77; system_user=no; user_id=public_api%40dbiz.com; user_image=' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"name" : "GIB20 G02"
|
||||
}'
|
||||
|
||||
|
||||
@@ -152,7 +152,6 @@
|
||||
01651DC8E3A322D39483596C /* Pods-RunnerTests.release.xcconfig */,
|
||||
18121E1016DEC4038E74F1F0 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -489,14 +488,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = W759YCT9DM;
|
||||
DEVELOPMENT_TEAM = 9R5X2DM2C8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.worker;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.dbiz.partner;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -513,7 +512,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.worker.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.dbiz.partner.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -531,7 +530,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.worker.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.dbiz.partner.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -547,7 +546,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.worker.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.dbiz.partner.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -672,14 +671,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = W759YCT9DM;
|
||||
DEVELOPMENT_TEAM = 9R5X2DM2C8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.worker;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.dbiz.partner;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -695,14 +694,14 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = W759YCT9DM;
|
||||
DEVELOPMENT_TEAM = 9R5X2DM2C8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.worker;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.dbiz.partner;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -25,7 +25,7 @@ class WorkerApp extends ConsumerWidget {
|
||||
return MaterialApp.router(
|
||||
// ==================== App Configuration ====================
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Worker App',
|
||||
title: 'DBIZ Partner',
|
||||
|
||||
// ==================== Router Configuration ====================
|
||||
// Using go_router for declarative routing with deep linking support
|
||||
|
||||
@@ -368,6 +368,25 @@ class ApiConstants {
|
||||
/// Frappe public API user ID
|
||||
static const String frappePublicUserId = 'public_api@dbiz.com';
|
||||
|
||||
// ============================================================================
|
||||
// Product/Item Endpoints (Frappe ERPNext)
|
||||
// ============================================================================
|
||||
|
||||
/// Get product/item list (requires sid and csrf_token)
|
||||
/// POST /api/method/building_material.building_material.api.item.get_list
|
||||
/// Body: { "limit_start": 0, "limit_page_length": 0 }
|
||||
static const String frappeGetItems = '/building_material.building_material.api.item.get_list';
|
||||
|
||||
/// Get product/item detail (requires sid and csrf_token)
|
||||
/// POST /api/method/building_material.building_material.api.item.get_detail
|
||||
/// Body: { "name": "item_code" }
|
||||
static const String frappeGetItemDetail = '/building_material.building_material.api.item.get_detail';
|
||||
|
||||
/// Get item attributes list (requires sid and csrf_token)
|
||||
/// POST /api/method/building_material.building_material.api.item_attribute.get_list
|
||||
/// Body: { "filters": {"is_group": 0}, "limit_page_length": 0 }
|
||||
static const String frappeGetItemAttributes = '/building_material.building_material.api.item_attribute.get_list';
|
||||
|
||||
// ============================================================================
|
||||
// Notification Endpoints
|
||||
// ============================================================================
|
||||
|
||||
@@ -6,8 +6,8 @@ library;
|
||||
import 'package:curl_logger_dio_interceptor/curl_logger_dio_interceptor.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/core/constants/api_constants.dart';
|
||||
import 'package:worker/features/auth/data/datasources/auth_remote_datasource.dart';
|
||||
import 'package:worker/features/auth/data/models/auth_session_model.dart';
|
||||
|
||||
part 'session_provider.g.dart';
|
||||
|
||||
@@ -16,7 +16,7 @@ part 'session_provider.g.dart';
|
||||
Dio dio(Ref ref) {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: 'https://land.dbiz.com',
|
||||
baseUrl: ApiConstants.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
),
|
||||
|
||||
@@ -51,7 +51,7 @@ final class DioProvider extends $FunctionalProvider<Dio, Dio, Dio>
|
||||
}
|
||||
}
|
||||
|
||||
String _$dioHash() => r'2bc10725a1b646cfaabd88c722e5101c06837c75';
|
||||
String _$dioHash() => r'3c682dd2b6f7ca8e39e1c26713a9160c2e69d894';
|
||||
|
||||
/// Provider for AuthRemoteDataSource
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/favorites/data/datasources/favorites_local_datasource.dart';
|
||||
import 'package:worker/features/favorites/data/models/favorite_model.dart';
|
||||
import 'package:worker/features/products/data/datasources/products_local_datasource.dart';
|
||||
import 'package:worker/features/products/data/repositories/products_repository_impl.dart';
|
||||
import 'package:worker/features/products/domain/entities/product.dart';
|
||||
import 'package:worker/features/products/domain/usecases/get_products.dart';
|
||||
import 'package:worker/features/products/presentation/providers/products_provider.dart';
|
||||
|
||||
part 'favorites_provider.g.dart';
|
||||
|
||||
@@ -251,12 +250,9 @@ Future<List<Product>> favoriteProducts(Ref ref) async {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Import products provider to get all products
|
||||
const productsRepository = ProductsRepositoryImpl(
|
||||
localDataSource: ProductsLocalDataSourceImpl(),
|
||||
);
|
||||
|
||||
const getProductsUseCase = GetProducts(productsRepository);
|
||||
// Get products repository with injected dependencies
|
||||
final productsRepository = await ref.watch(productsRepositoryProvider.future);
|
||||
final getProductsUseCase = GetProducts(productsRepository);
|
||||
final allProducts = await getProductsUseCase();
|
||||
|
||||
// Filter to only include favorited products
|
||||
|
||||
@@ -440,4 +440,4 @@ final class FavoriteProductsProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$favoriteProductsHash() => r'6f48aa57781b0276ad72928e6b54b04fc53b0d7e';
|
||||
String _$favoriteProductsHash() => r'630acfbc403cc4deb486c7b0199f128252a8990b';
|
||||
|
||||
@@ -147,7 +147,6 @@ class NewsRemoteDataSource {
|
||||
'published_on',
|
||||
'blogger',
|
||||
'blog_intro',
|
||||
'content',
|
||||
'meta_image',
|
||||
'meta_description',
|
||||
'blog_category',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
library;
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:worker/core/constants/api_constants.dart';
|
||||
import 'package:worker/features/news/domain/entities/news_article.dart';
|
||||
|
||||
part 'blog_post_model.g.dart';
|
||||
@@ -86,22 +87,22 @@ class BlogPostModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract excerpt from blogIntro or metaDescription
|
||||
final excerpt = blogIntro ?? metaDescription ?? '';
|
||||
|
||||
// Use content_html preferentially, fall back to content
|
||||
final htmlContent = contentHtml ?? content;
|
||||
|
||||
// Excerpt is ONLY from blog_intro (plain text)
|
||||
final excerpt = blogIntro ?? '';
|
||||
|
||||
// Use meta image with full URL path
|
||||
String imageUrl;
|
||||
if (metaImage != null && metaImage!.isNotEmpty) {
|
||||
// If meta_image starts with /, prepend the base URL
|
||||
if (metaImage!.startsWith('/')) {
|
||||
imageUrl = 'https://land.dbiz.com$metaImage';
|
||||
imageUrl = '${ApiConstants.baseUrl}$metaImage';
|
||||
} else if (metaImage!.startsWith('http')) {
|
||||
imageUrl = metaImage!;
|
||||
} else {
|
||||
imageUrl = 'https://land.dbiz.com/$metaImage';
|
||||
imageUrl = '${ApiConstants.baseUrl}/$metaImage';
|
||||
}
|
||||
} else {
|
||||
imageUrl = 'https://via.placeholder.com/400x300?text=${Uri.encodeComponent(title)}';
|
||||
@@ -117,7 +118,9 @@ class BlogPostModel {
|
||||
return NewsArticle(
|
||||
id: name,
|
||||
title: title,
|
||||
excerpt: excerpt.length > 200 ? '${excerpt.substring(0, 200)}...' : excerpt,
|
||||
excerpt: excerpt.isNotEmpty
|
||||
? (excerpt.length > 300 ? '${excerpt.substring(0, 300)}...' : excerpt)
|
||||
: 'Không có mô tả',
|
||||
content: htmlContent,
|
||||
imageUrl: imageUrl,
|
||||
category: category,
|
||||
|
||||
@@ -7,6 +7,7 @@ library;
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
@@ -153,37 +154,82 @@ class _NewsDetailPageState extends ConsumerState<NewsDetailPage> {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Excerpt
|
||||
|
||||
// Article Body - Render HTML content
|
||||
if (article.content != null && article.content!.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
// Wrap Html in Container to prevent rendering issues
|
||||
child: Html(
|
||||
data: article.content,
|
||||
style: {
|
||||
"body": Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
fontSize: FontSize(16),
|
||||
lineHeight: const LineHeight(1.7),
|
||||
color: const Color(0xFF1E293B),
|
||||
),
|
||||
"h2": Style(
|
||||
fontSize: FontSize(20),
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1E293B),
|
||||
margin: Margins.only(top: 32, bottom: 16),
|
||||
),
|
||||
"h3": Style(
|
||||
fontSize: FontSize(18),
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1E293B),
|
||||
margin: Margins.only(top: 24, bottom: 12),
|
||||
),
|
||||
"p": Style(
|
||||
fontSize: FontSize(16),
|
||||
color: const Color(0xFF1E293B),
|
||||
lineHeight: const LineHeight(1.7),
|
||||
margin: Margins.only(bottom: 16),
|
||||
),
|
||||
"strong": Style(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF1E293B),
|
||||
),
|
||||
"img": Style(
|
||||
margin: Margins.symmetric(vertical: 16),
|
||||
),
|
||||
"ul": Style(
|
||||
margin: Margins.only(left: 16, bottom: 16),
|
||||
),
|
||||
"ol": Style(
|
||||
margin: Margins.only(left: 16, bottom: 16),
|
||||
),
|
||||
"li": Style(
|
||||
fontSize: FontSize(16),
|
||||
color: const Color(0xFF1E293B),
|
||||
lineHeight: const LineHeight(1.5),
|
||||
margin: Margins.only(bottom: 8),
|
||||
),
|
||||
"blockquote": Style(
|
||||
backgroundColor: const Color(0xFFF0F9FF),
|
||||
border: const Border(
|
||||
left: BorderSide(color: AppColors.primaryBlue, width: 4),
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(8),
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
article.excerpt,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF64748B),
|
||||
padding: HtmlPaddings.all(16),
|
||||
margin: Margins.symmetric(vertical: 24),
|
||||
fontStyle: FontStyle.italic,
|
||||
height: 1.5,
|
||||
),
|
||||
"div": Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
),
|
||||
},
|
||||
onLinkTap: (url, attributes, element) {
|
||||
// Handle link taps if needed
|
||||
if (url != null) {
|
||||
debugPrint('Link tapped: $url');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Article Body
|
||||
if (article.content != null)
|
||||
_buildArticleBody(article.content!),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
@@ -261,192 +307,6 @@ class _NewsDetailPageState extends ConsumerState<NewsDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build article body with simple HTML parsing
|
||||
Widget _buildArticleBody(String content) {
|
||||
final elements = _parseHTMLContent(content);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: elements,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse HTML-like content into widgets
|
||||
List<Widget> _parseHTMLContent(String content) {
|
||||
final List<Widget> widgets = [];
|
||||
final lines = content.split('\n').where((line) => line.trim().isNotEmpty);
|
||||
|
||||
for (final line in lines) {
|
||||
final trimmed = line.trim();
|
||||
|
||||
// H2 heading
|
||||
if (trimmed.startsWith('<h2>') && trimmed.endsWith('</h2>')) {
|
||||
final text = trimmed.substring(4, trimmed.length - 5);
|
||||
widgets.add(_buildH2(text));
|
||||
}
|
||||
// H3 heading
|
||||
else if (trimmed.startsWith('<h3>') && trimmed.endsWith('</h3>')) {
|
||||
final text = trimmed.substring(4, trimmed.length - 5);
|
||||
widgets.add(_buildH3(text));
|
||||
}
|
||||
// Paragraph
|
||||
else if (trimmed.startsWith('<p>') && trimmed.endsWith('</p>')) {
|
||||
final text = trimmed.substring(3, trimmed.length - 4);
|
||||
widgets.add(_buildParagraph(text));
|
||||
}
|
||||
// Unordered list start
|
||||
else if (trimmed == '<ul>') {
|
||||
// Collect list items
|
||||
final listItems = <String>[];
|
||||
continue;
|
||||
}
|
||||
// List item
|
||||
else if (trimmed.startsWith('<li>') && trimmed.endsWith('</li>')) {
|
||||
final text = trimmed.substring(4, trimmed.length - 5);
|
||||
widgets.add(_buildListItem(text, false));
|
||||
}
|
||||
// Ordered list item (number prefix)
|
||||
else if (RegExp(r'^\d+\.').hasMatch(trimmed)) {
|
||||
widgets.add(_buildListItem(trimmed, true));
|
||||
}
|
||||
// Blockquote
|
||||
else if (trimmed.startsWith('<blockquote>') &&
|
||||
trimmed.endsWith('</blockquote>')) {
|
||||
final text = trimmed.substring(12, trimmed.length - 13);
|
||||
widgets.add(_buildBlockquote(text));
|
||||
}
|
||||
// Highlight box (custom tag)
|
||||
else if (trimmed.startsWith('<highlight type="')) {
|
||||
final typeMatch = RegExp(r'type="(\w+)"').firstMatch(trimmed);
|
||||
final contentMatch = RegExp(r'>(.*)</highlight>').firstMatch(trimmed);
|
||||
|
||||
if (typeMatch != null && contentMatch != null) {
|
||||
final type = typeMatch.group(1);
|
||||
final content = contentMatch.group(1);
|
||||
|
||||
widgets.add(
|
||||
HighlightBox(
|
||||
type: type == 'tip' ? HighlightType.tip : HighlightType.warning,
|
||||
title: type == 'tip' ? 'Mẹo từ chuyên gia' : 'Lưu ý khi sử dụng',
|
||||
content: content ?? '',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// Build H2 heading
|
||||
Widget _buildH2(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 32, bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(height: 2, width: 60, color: AppColors.primaryBlue),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build H3 heading
|
||||
Widget _buildH3(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 24, bottom: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build paragraph
|
||||
Widget _buildParagraph(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF1E293B),
|
||||
height: 1.7,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build list item
|
||||
Widget _buildListItem(String text, bool isOrdered) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16, bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isOrdered ? '' : '• ',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.primaryBlue,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF1E293B),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build blockquote
|
||||
Widget _buildBlockquote(String text) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 24),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F9FF),
|
||||
border: const Border(
|
||||
left: BorderSide(color: AppColors.primaryBlue, width: 4),
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(8),
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF1E293B),
|
||||
fontStyle: FontStyle.italic,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build tags section
|
||||
Widget _buildTagsSection(List<String> tags) {
|
||||
return Container(
|
||||
|
||||
@@ -116,10 +116,10 @@ class NewsCard extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
// Date
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 12,
|
||||
color: const Color(0xFF64748B),
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/// Data Source: Products Remote Data Source
|
||||
///
|
||||
/// Handles fetching product data from the Frappe ERPNext API.
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:worker/core/constants/api_constants.dart';
|
||||
import 'package:worker/core/network/dio_client.dart';
|
||||
import 'package:worker/core/services/frappe_auth_service.dart';
|
||||
import 'package:worker/features/products/data/models/product_model.dart';
|
||||
|
||||
/// Products Remote Data Source
|
||||
///
|
||||
/// Provides methods to fetch product data from the Frappe ERPNext API.
|
||||
/// Uses FrappeAuthService for session management.
|
||||
class ProductsRemoteDataSource {
|
||||
ProductsRemoteDataSource(this._dioClient, this._frappeAuthService);
|
||||
|
||||
final DioClient _dioClient;
|
||||
final FrappeAuthService _frappeAuthService;
|
||||
|
||||
/// Get all products
|
||||
///
|
||||
/// Fetches all products from Frappe ERPNext.
|
||||
/// Returns a list of [ProductModel].
|
||||
///
|
||||
/// API endpoint: POST https://land.dbiz.com/api/method/building_material.building_material.api.item.get_list
|
||||
/// Request body:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "limit_start": 0,
|
||||
/// "limit_page_length": 0
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Response format:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "message": [
|
||||
/// {
|
||||
/// "name": "GIB20 G02",
|
||||
/// "item_name": "Gạch Eurotile 1200x1200",
|
||||
/// "description": "Product description...",
|
||||
/// "standard_rate": 450000.0,
|
||||
/// "stock_uom": "m²",
|
||||
/// "image": "/files/image.jpg",
|
||||
/// "brand": "Eurotile",
|
||||
/// ...
|
||||
/// }
|
||||
/// ]
|
||||
/// }
|
||||
/// ```
|
||||
Future<List<ProductModel>> getAllProducts({
|
||||
int limitStart = 0,
|
||||
int limitPageLength = 0,
|
||||
}) async {
|
||||
try {
|
||||
// Get Frappe session headers
|
||||
final headers = await _frappeAuthService.getHeaders();
|
||||
|
||||
// Build full API URL
|
||||
const url =
|
||||
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetItems}';
|
||||
|
||||
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||
url,
|
||||
data: {
|
||||
'limit_start': limitStart,
|
||||
'limit_page_length': limitPageLength,
|
||||
},
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw Exception('Empty response from server');
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
final message = response.data!['message'];
|
||||
if (message == null) {
|
||||
throw Exception('No message field in response');
|
||||
}
|
||||
|
||||
final productsList = message as List;
|
||||
return productsList
|
||||
.map((item) => ProductModel.fromFrappeJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) {
|
||||
throw Exception('Products endpoint not found');
|
||||
} else if (e.response?.statusCode == 500) {
|
||||
throw Exception('Server error while fetching products');
|
||||
} else if (e.type == DioExceptionType.connectionTimeout) {
|
||||
throw Exception('Connection timeout while fetching products');
|
||||
} else if (e.type == DioExceptionType.receiveTimeout) {
|
||||
throw Exception('Response timeout while fetching products');
|
||||
} else {
|
||||
throw Exception('Failed to fetch products: ${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Unexpected error fetching products: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get product detail by name/code
|
||||
///
|
||||
/// Fetches a single product by its item code from Frappe ERPNext.
|
||||
/// Returns a [ProductModel].
|
||||
///
|
||||
/// API endpoint: POST https://land.dbiz.com/api/method/building_material.building_material.api.item.get_detail
|
||||
/// Request body:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "name": "GIB20 G02"
|
||||
/// }
|
||||
/// ```
|
||||
Future<ProductModel> getProductDetail(String itemCode) async {
|
||||
try {
|
||||
// Get Frappe session headers
|
||||
final headers = await _frappeAuthService.getHeaders();
|
||||
|
||||
// Build full API URL
|
||||
final url =
|
||||
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetItemDetail}';
|
||||
|
||||
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||
url,
|
||||
data: {
|
||||
'name': itemCode,
|
||||
},
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw Exception('Empty response from server');
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
final message = response.data!['message'];
|
||||
if (message == null) {
|
||||
throw Exception('Product not found: $itemCode');
|
||||
}
|
||||
|
||||
return ProductModel.fromFrappeJson(message as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) {
|
||||
throw Exception('Product not found: $itemCode');
|
||||
} else if (e.response?.statusCode == 500) {
|
||||
throw Exception('Server error while fetching product detail');
|
||||
} else if (e.type == DioExceptionType.connectionTimeout) {
|
||||
throw Exception('Connection timeout while fetching product detail');
|
||||
} else if (e.type == DioExceptionType.receiveTimeout) {
|
||||
throw Exception('Response timeout while fetching product detail');
|
||||
} else {
|
||||
throw Exception('Failed to fetch product detail: ${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Unexpected error fetching product detail: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Search products
|
||||
///
|
||||
/// Searches products by name or description.
|
||||
/// For now, we fetch all products and filter locally.
|
||||
/// In the future, the API might support server-side search.
|
||||
Future<List<ProductModel>> searchProducts(String query) async {
|
||||
// For now, fetch all products and filter locally
|
||||
// TODO: Implement server-side search if API supports it
|
||||
final allProducts = await getAllProducts();
|
||||
|
||||
final lowercaseQuery = query.toLowerCase();
|
||||
|
||||
return allProducts.where((product) {
|
||||
final name = product.name.toLowerCase();
|
||||
final description = (product.description ?? '').toLowerCase();
|
||||
final productId = product.productId.toLowerCase();
|
||||
|
||||
return name.contains(lowercaseQuery) ||
|
||||
description.contains(lowercaseQuery) ||
|
||||
productId.contains(lowercaseQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Get products by category
|
||||
///
|
||||
/// Filters products by category.
|
||||
/// For now, we fetch all products and filter locally.
|
||||
/// In the future, the API might support category filtering.
|
||||
Future<List<ProductModel>> getProductsByCategory(String categoryId) async {
|
||||
// For now, fetch all products and filter locally
|
||||
// TODO: Implement server-side category filtering if API supports it
|
||||
final allProducts = await getAllProducts();
|
||||
|
||||
if (categoryId == 'all') {
|
||||
return allProducts;
|
||||
}
|
||||
|
||||
return allProducts
|
||||
.where((product) => product.category == categoryId)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get product groups (Item Groups)
|
||||
///
|
||||
/// Fetches product groups from Frappe ERPNext.
|
||||
/// Returns a list of group objects with name and item_group_name.
|
||||
///
|
||||
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list
|
||||
/// Request body:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "doctype": "Item Group",
|
||||
/// "fields": ["item_group_name", "name"],
|
||||
/// "filters": {"is_group": 0},
|
||||
/// "limit_page_length": 0
|
||||
/// }
|
||||
/// ```
|
||||
Future<List<Map<String, dynamic>>> getProductGroups() async {
|
||||
try {
|
||||
final headers = await _frappeAuthService.getHeaders();
|
||||
final url =
|
||||
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}';
|
||||
|
||||
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||
url,
|
||||
data: {
|
||||
'doctype': 'Item Group',
|
||||
'fields': ['item_group_name', 'name'],
|
||||
'filters': {'is_group': 0},
|
||||
'limit_page_length': 0,
|
||||
},
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw Exception('Empty response from server');
|
||||
}
|
||||
|
||||
final message = response.data!['message'];
|
||||
if (message == null) {
|
||||
throw Exception('No message field in response');
|
||||
}
|
||||
|
||||
return (message as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) {
|
||||
throw Exception('Product groups endpoint not found');
|
||||
} else if (e.response?.statusCode == 500) {
|
||||
throw Exception('Server error while fetching product groups');
|
||||
} else {
|
||||
throw Exception('Failed to fetch product groups: ${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Unexpected error fetching product groups: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get product brands
|
||||
///
|
||||
/// Fetches brands from Frappe ERPNext.
|
||||
/// Returns a list of brand names.
|
||||
///
|
||||
/// API endpoint: POST https://land.dbiz.com/api/method/frappe.client.get_list
|
||||
/// Request body:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "doctype": "Brand",
|
||||
/// "fields": ["name"],
|
||||
/// "limit_page_length": 0
|
||||
/// }
|
||||
/// ```
|
||||
Future<List<String>> getProductBrands() async {
|
||||
try {
|
||||
final headers = await _frappeAuthService.getHeaders();
|
||||
final url =
|
||||
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetList}';
|
||||
|
||||
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||
url,
|
||||
data: {
|
||||
'doctype': 'Brand',
|
||||
'fields': ['name'],
|
||||
'limit_page_length': 0,
|
||||
},
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw Exception('Empty response from server');
|
||||
}
|
||||
|
||||
final message = response.data!['message'];
|
||||
if (message == null) {
|
||||
throw Exception('No message field in response');
|
||||
}
|
||||
|
||||
return (message as List)
|
||||
.map((item) => item['name'] as String)
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) {
|
||||
throw Exception('Product brands endpoint not found');
|
||||
} else if (e.response?.statusCode == 500) {
|
||||
throw Exception('Server error while fetching product brands');
|
||||
} else {
|
||||
throw Exception('Failed to fetch product brands: ${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Unexpected error fetching product brands: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get product attributes
|
||||
///
|
||||
/// Fetches product attributes from Frappe ERPNext.
|
||||
/// Returns a list of attribute objects.
|
||||
///
|
||||
/// API endpoint: POST https://land.dbiz.com/api/method/building_material.building_material.api.item_attribute.get_list
|
||||
/// Request body:
|
||||
/// ```json
|
||||
/// {
|
||||
/// "filters": {"is_group": 0},
|
||||
/// "limit_page_length": 0
|
||||
/// }
|
||||
/// ```
|
||||
Future<List<Map<String, dynamic>>> getProductAttributes() async {
|
||||
try {
|
||||
final headers = await _frappeAuthService.getHeaders();
|
||||
final url =
|
||||
'${ApiConstants.baseUrl}${ApiConstants.frappeApiMethod}${ApiConstants.frappeGetItemAttributes}';
|
||||
|
||||
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||
url,
|
||||
data: {
|
||||
'filters': {'is_group': 0},
|
||||
'limit_page_length': 0,
|
||||
},
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
throw Exception('Empty response from server');
|
||||
}
|
||||
|
||||
final message = response.data!['message'];
|
||||
if (message == null) {
|
||||
throw Exception('No message field in response');
|
||||
}
|
||||
|
||||
return (message as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) {
|
||||
throw Exception('Product attributes endpoint not found');
|
||||
} else if (e.response?.statusCode == 500) {
|
||||
throw Exception('Server error while fetching product attributes');
|
||||
} else {
|
||||
throw Exception('Failed to fetch product attributes: ${e.message}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Unexpected error fetching product attributes: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:hive_ce/hive.dart';
|
||||
|
||||
import 'package:worker/core/constants/api_constants.dart';
|
||||
import 'package:worker/core/constants/storage_constants.dart';
|
||||
import 'package:worker/features/products/domain/entities/product.dart';
|
||||
|
||||
@@ -21,8 +22,9 @@ class ProductModel extends HiveObject {
|
||||
this.description,
|
||||
required this.basePrice,
|
||||
this.images,
|
||||
this.thumbnail,
|
||||
this.imageCaptions,
|
||||
this.link360,
|
||||
this.customLink360,
|
||||
this.specifications,
|
||||
this.category,
|
||||
this.brand,
|
||||
@@ -54,49 +56,53 @@ class ProductModel extends HiveObject {
|
||||
@HiveField(4)
|
||||
final String? images;
|
||||
|
||||
/// Image captions (JSON encoded map of image_url -> caption)
|
||||
/// Thumbnail image URL
|
||||
@HiveField(5)
|
||||
final String? thumbnail;
|
||||
|
||||
/// Image captions (JSON encoded map of image_url -> caption)
|
||||
@HiveField(6)
|
||||
final String? imageCaptions;
|
||||
|
||||
/// 360-degree view link
|
||||
@HiveField(6)
|
||||
final String? link360;
|
||||
/// Custom 360-degree view link
|
||||
@HiveField(7)
|
||||
final String? customLink360;
|
||||
|
||||
/// Product specifications (JSON encoded)
|
||||
/// Contains: size, material, color, finish, etc.
|
||||
@HiveField(7)
|
||||
@HiveField(8)
|
||||
final String? specifications;
|
||||
|
||||
/// Product category
|
||||
@HiveField(8)
|
||||
@HiveField(9)
|
||||
final String? category;
|
||||
|
||||
/// Product brand
|
||||
@HiveField(9)
|
||||
@HiveField(10)
|
||||
final String? brand;
|
||||
|
||||
/// Unit of measurement (m2, box, piece, etc.)
|
||||
@HiveField(10)
|
||||
@HiveField(11)
|
||||
final String? unit;
|
||||
|
||||
/// Whether product is active
|
||||
@HiveField(11)
|
||||
@HiveField(12)
|
||||
final bool isActive;
|
||||
|
||||
/// Whether product is featured
|
||||
@HiveField(12)
|
||||
@HiveField(13)
|
||||
final bool isFeatured;
|
||||
|
||||
/// ERPNext item code for integration
|
||||
@HiveField(13)
|
||||
@HiveField(14)
|
||||
final String? erpnextItemCode;
|
||||
|
||||
/// Product creation timestamp
|
||||
@HiveField(14)
|
||||
@HiveField(15)
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Last update timestamp
|
||||
@HiveField(15)
|
||||
@HiveField(16)
|
||||
final DateTime? updatedAt;
|
||||
|
||||
// =========================================================================
|
||||
@@ -111,10 +117,11 @@ class ProductModel extends HiveObject {
|
||||
description: json['description'] as String?,
|
||||
basePrice: (json['base_price'] as num).toDouble(),
|
||||
images: json['images'] != null ? jsonEncode(json['images']) : null,
|
||||
thumbnail: json['thumbnail'] as String?,
|
||||
imageCaptions: json['image_captions'] != null
|
||||
? jsonEncode(json['image_captions'])
|
||||
: null,
|
||||
link360: json['link_360'] as String?,
|
||||
customLink360: json['custom_link_360'] as String?,
|
||||
specifications: json['specifications'] != null
|
||||
? jsonEncode(json['specifications'])
|
||||
: null,
|
||||
@@ -131,6 +138,119 @@ class ProductModel extends HiveObject {
|
||||
);
|
||||
}
|
||||
|
||||
/// Create ProductModel from Frappe ERPNext API JSON
|
||||
///
|
||||
/// Maps Frappe Item doctype fields to our ProductModel structure.
|
||||
/// Frappe fields:
|
||||
/// - name: Item code (e.g., "CHG S01P")
|
||||
/// - item_name: Display name
|
||||
/// - description: Product description
|
||||
/// - price: Price (from product detail API)
|
||||
/// - standard_rate: Price (from product list API)
|
||||
/// - stock_uom: Unit of measurement
|
||||
/// - thumbnail: Thumbnail image URL
|
||||
/// - image_list: Array of images with image_url
|
||||
/// - brand: Brand name
|
||||
/// - item_group_name: Category/group name
|
||||
/// - custom_link_360: Custom 360 view link
|
||||
/// - attributes: Array of product attributes (Size, Color, Surface, etc.)
|
||||
factory ProductModel.fromFrappeJson(Map<String, dynamic> json) {
|
||||
// Handle thumbnail - prepend base URL if needed
|
||||
String? thumbnailUrl;
|
||||
if (json['thumbnail'] != null && (json['thumbnail'] as String).isNotEmpty) {
|
||||
final thumbnailPath = json['thumbnail'] as String;
|
||||
if (thumbnailPath.startsWith('/')) {
|
||||
thumbnailUrl = '${ApiConstants.baseUrl}$thumbnailPath';
|
||||
} else if (thumbnailPath.startsWith('http')) {
|
||||
thumbnailUrl = thumbnailPath;
|
||||
} else {
|
||||
thumbnailUrl = '${ApiConstants.baseUrl}/$thumbnailPath';
|
||||
}
|
||||
}
|
||||
|
||||
// Handle image_list array (from product detail API)
|
||||
final List<String> imagesList = [];
|
||||
final Map<String, String> imageCaptionsMap = {};
|
||||
|
||||
if (json['image_list'] != null && json['image_list'] is List) {
|
||||
final imageListData = json['image_list'] as List;
|
||||
for (final imgData in imageListData) {
|
||||
if (imgData is Map<String, dynamic> && imgData['image_url'] != null) {
|
||||
final imageUrl = imgData['image_url'] as String;
|
||||
imagesList.add(imageUrl);
|
||||
|
||||
// Store image caption (image_name: A, B, C, etc.)
|
||||
if (imgData['image_name'] != null) {
|
||||
imageCaptionsMap[imageUrl] = imgData['image_name'] as String;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to single image field (from product list API)
|
||||
else if (json['image'] != null && (json['image'] as String).isNotEmpty) {
|
||||
final imagePath = json['image'] as String;
|
||||
String imageUrl;
|
||||
if (imagePath.startsWith('/')) {
|
||||
imageUrl = '${ApiConstants.baseUrl}$imagePath';
|
||||
} else if (imagePath.startsWith('http')) {
|
||||
imageUrl = imagePath;
|
||||
} else {
|
||||
imageUrl = '${ApiConstants.baseUrl}/$imagePath';
|
||||
}
|
||||
imagesList.add(imageUrl);
|
||||
}
|
||||
|
||||
// Parse attributes array into specifications map
|
||||
final Map<String, dynamic> specificationsMap = {};
|
||||
if (json['attributes'] != null && json['attributes'] is List) {
|
||||
final attributesData = json['attributes'] as List;
|
||||
for (final attr in attributesData) {
|
||||
if (attr is Map<String, dynamic> &&
|
||||
attr['attribute_name'] != null &&
|
||||
attr['attribute_value'] != null) {
|
||||
specificationsMap[attr['attribute_name'] as String] =
|
||||
attr['attribute_value'] as String;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// Handle price from both product detail (price) and product list (standard_rate)
|
||||
final price = (json['price'] as num?)?.toDouble() ??
|
||||
(json['standard_rate'] as num?)?.toDouble() ??
|
||||
0.0;
|
||||
|
||||
return ProductModel(
|
||||
productId: json['name'] as String, // Item code
|
||||
name: json['item_name'] as String? ?? json['name'] as String,
|
||||
description: json['description'] as String?,
|
||||
basePrice: price,
|
||||
images: imagesList.isNotEmpty ? jsonEncode(imagesList) : null,
|
||||
thumbnail: thumbnailUrl,
|
||||
imageCaptions: imageCaptionsMap.isNotEmpty
|
||||
? jsonEncode(imageCaptionsMap)
|
||||
: null,
|
||||
customLink360: json['custom_link_360'] as String?,
|
||||
specifications: specificationsMap.isNotEmpty
|
||||
? jsonEncode(specificationsMap)
|
||||
: null,
|
||||
category: json['item_group_name'] as String? ??
|
||||
json['item_group'] as String?, // Try item_group_name first, fallback to item_group
|
||||
brand: json['brand'] as String?,
|
||||
unit: json['stock_uom'] as String? ?? 'm²',
|
||||
isActive: (json['disabled'] as int?) == 0, // Frappe uses 'disabled' field
|
||||
isFeatured: false, // Not provided by API, default to false
|
||||
erpnextItemCode: json['name'] as String, // Store item code for reference
|
||||
createdAt: json['creation'] != null
|
||||
? DateTime.tryParse(json['creation'] as String) ?? now
|
||||
: now,
|
||||
updatedAt: json['modified'] != null
|
||||
? DateTime.tryParse(json['modified'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert ProductModel to JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
@@ -139,10 +259,11 @@ class ProductModel extends HiveObject {
|
||||
'description': description,
|
||||
'base_price': basePrice,
|
||||
'images': images != null ? jsonDecode(images!) : null,
|
||||
'thumbnail': thumbnail,
|
||||
'image_captions': imageCaptions != null
|
||||
? jsonDecode(imageCaptions!)
|
||||
: null,
|
||||
'link_360': link360,
|
||||
'custom_link_360': customLink360,
|
||||
'specifications': specifications != null
|
||||
? jsonDecode(specifications!)
|
||||
: null,
|
||||
@@ -208,7 +329,7 @@ class ProductModel extends HiveObject {
|
||||
}
|
||||
|
||||
/// Check if product has 360 view
|
||||
bool get has360View => link360 != null && link360!.isNotEmpty;
|
||||
bool get has360View => customLink360 != null && customLink360!.isNotEmpty;
|
||||
|
||||
// =========================================================================
|
||||
// COPY WITH
|
||||
@@ -221,8 +342,9 @@ class ProductModel extends HiveObject {
|
||||
String? description,
|
||||
double? basePrice,
|
||||
String? images,
|
||||
String? thumbnail,
|
||||
String? imageCaptions,
|
||||
String? link360,
|
||||
String? customLink360,
|
||||
String? specifications,
|
||||
String? category,
|
||||
String? brand,
|
||||
@@ -239,8 +361,9 @@ class ProductModel extends HiveObject {
|
||||
description: description ?? this.description,
|
||||
basePrice: basePrice ?? this.basePrice,
|
||||
images: images ?? this.images,
|
||||
thumbnail: thumbnail ?? this.thumbnail,
|
||||
imageCaptions: imageCaptions ?? this.imageCaptions,
|
||||
link360: link360 ?? this.link360,
|
||||
customLink360: customLink360 ?? this.customLink360,
|
||||
specifications: specifications ?? this.specifications,
|
||||
category: category ?? this.category,
|
||||
brand: brand ?? this.brand,
|
||||
@@ -280,8 +403,9 @@ class ProductModel extends HiveObject {
|
||||
description: description,
|
||||
basePrice: basePrice,
|
||||
images: imagesList ?? [],
|
||||
thumbnail: thumbnail,
|
||||
imageCaptions: imageCaptionsMap ?? {},
|
||||
link360: link360,
|
||||
customLink360: customLink360,
|
||||
specifications: specificationsMap ?? {},
|
||||
category: category,
|
||||
brand: brand,
|
||||
|
||||
@@ -22,24 +22,25 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
|
||||
description: fields[2] as String?,
|
||||
basePrice: (fields[3] as num).toDouble(),
|
||||
images: fields[4] as String?,
|
||||
imageCaptions: fields[5] as String?,
|
||||
link360: fields[6] as String?,
|
||||
specifications: fields[7] as String?,
|
||||
category: fields[8] as String?,
|
||||
brand: fields[9] as String?,
|
||||
unit: fields[10] as String?,
|
||||
isActive: fields[11] as bool,
|
||||
isFeatured: fields[12] as bool,
|
||||
erpnextItemCode: fields[13] as String?,
|
||||
createdAt: fields[14] as DateTime,
|
||||
updatedAt: fields[15] as DateTime?,
|
||||
thumbnail: fields[5] as String?,
|
||||
imageCaptions: fields[6] as String?,
|
||||
customLink360: fields[7] as String?,
|
||||
specifications: fields[8] as String?,
|
||||
category: fields[9] as String?,
|
||||
brand: fields[10] as String?,
|
||||
unit: fields[11] as String?,
|
||||
isActive: fields[12] as bool,
|
||||
isFeatured: fields[13] as bool,
|
||||
erpnextItemCode: fields[14] as String?,
|
||||
createdAt: fields[15] as DateTime,
|
||||
updatedAt: fields[16] as DateTime?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, ProductModel obj) {
|
||||
writer
|
||||
..writeByte(16)
|
||||
..writeByte(17)
|
||||
..writeByte(0)
|
||||
..write(obj.productId)
|
||||
..writeByte(1)
|
||||
@@ -51,26 +52,28 @@ class ProductModelAdapter extends TypeAdapter<ProductModel> {
|
||||
..writeByte(4)
|
||||
..write(obj.images)
|
||||
..writeByte(5)
|
||||
..write(obj.imageCaptions)
|
||||
..write(obj.thumbnail)
|
||||
..writeByte(6)
|
||||
..write(obj.link360)
|
||||
..write(obj.imageCaptions)
|
||||
..writeByte(7)
|
||||
..write(obj.specifications)
|
||||
..write(obj.customLink360)
|
||||
..writeByte(8)
|
||||
..write(obj.category)
|
||||
..write(obj.specifications)
|
||||
..writeByte(9)
|
||||
..write(obj.brand)
|
||||
..write(obj.category)
|
||||
..writeByte(10)
|
||||
..write(obj.unit)
|
||||
..write(obj.brand)
|
||||
..writeByte(11)
|
||||
..write(obj.isActive)
|
||||
..write(obj.unit)
|
||||
..writeByte(12)
|
||||
..write(obj.isFeatured)
|
||||
..write(obj.isActive)
|
||||
..writeByte(13)
|
||||
..write(obj.erpnextItemCode)
|
||||
..write(obj.isFeatured)
|
||||
..writeByte(14)
|
||||
..write(obj.createdAt)
|
||||
..write(obj.erpnextItemCode)
|
||||
..writeByte(15)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(16)
|
||||
..write(obj.updatedAt);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/// Repository Implementation: Products Repository
|
||||
///
|
||||
/// Concrete implementation of the products repository interface.
|
||||
/// Handles data from local datasource and converts to domain entities.
|
||||
/// Handles data from remote datasource (Frappe API) and converts to domain entities.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/products/data/datasources/products_local_datasource.dart';
|
||||
import 'package:worker/features/products/data/datasources/products_remote_datasource.dart';
|
||||
import 'package:worker/features/products/domain/entities/category.dart';
|
||||
import 'package:worker/features/products/domain/entities/product.dart';
|
||||
import 'package:worker/features/products/domain/repositories/products_repository.dart';
|
||||
@@ -12,61 +13,77 @@ import 'package:worker/features/products/domain/repositories/products_repository
|
||||
/// Products Repository Implementation
|
||||
///
|
||||
/// Implements the repository interface defined in the domain layer.
|
||||
/// Coordinates data from local datasource and converts models to entities.
|
||||
/// Uses remote datasource (Frappe API) for fetching products.
|
||||
/// Local datasource is kept for categories (mock data).
|
||||
class ProductsRepositoryImpl implements ProductsRepository {
|
||||
final ProductsLocalDataSource localDataSource;
|
||||
final ProductsRemoteDataSource remoteDataSource;
|
||||
|
||||
const ProductsRepositoryImpl({required this.localDataSource});
|
||||
const ProductsRepositoryImpl({
|
||||
required this.localDataSource,
|
||||
required this.remoteDataSource,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<Product>> getAllProducts() async {
|
||||
try {
|
||||
final productModels = await localDataSource.getAllProducts();
|
||||
// Fetch from Frappe API
|
||||
final productModels = await remoteDataSource.getAllProducts();
|
||||
return productModels.map((model) => model.toEntity()).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to get products: $e');
|
||||
print('[ProductsRepository] Error getting products: $e');
|
||||
rethrow; // Re-throw to let providers handle the error
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Product>> searchProducts(String query) async {
|
||||
try {
|
||||
final productModels = await localDataSource.searchProducts(query);
|
||||
// Search via remote API
|
||||
final productModels = await remoteDataSource.searchProducts(query);
|
||||
return productModels.map((model) => model.toEntity()).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to search products: $e');
|
||||
print('[ProductsRepository] Error searching products: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Product>> getProductsByCategory(String categoryId) async {
|
||||
try {
|
||||
final productModels = await localDataSource.getProductsByCategory(
|
||||
// Filter by category via remote API
|
||||
final productModels = await remoteDataSource.getProductsByCategory(
|
||||
categoryId,
|
||||
);
|
||||
return productModels.map((model) => model.toEntity()).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to get products by category: $e');
|
||||
print('[ProductsRepository] Error getting products by category: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Product> getProductById(String id) async {
|
||||
try {
|
||||
final productModel = await localDataSource.getProductById(id);
|
||||
// Fetch product detail from Frappe API
|
||||
final productModel = await remoteDataSource.getProductDetail(id);
|
||||
return productModel.toEntity();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to get product: $e');
|
||||
print('[ProductsRepository] Error getting product: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Category>> getCategories() async {
|
||||
try {
|
||||
// For now, use local mock categories
|
||||
// TODO: Fetch categories from Frappe API if available
|
||||
final categoryModels = await localDataSource.getCategories();
|
||||
return categoryModels.map((model) => model.toEntity()).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to get categories: $e');
|
||||
print('[ProductsRepository] Error getting categories: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,14 @@ class Product {
|
||||
/// Product images (URLs)
|
||||
final List<String> images;
|
||||
|
||||
/// Thumbnail image URL
|
||||
final String? thumbnail;
|
||||
|
||||
/// Image captions
|
||||
final Map<String, String> imageCaptions;
|
||||
|
||||
/// 360-degree view link
|
||||
final String? link360;
|
||||
/// Custom 360-degree view link
|
||||
final String? customLink360;
|
||||
|
||||
/// Product specifications
|
||||
final Map<String, dynamic> specifications;
|
||||
@@ -63,8 +66,9 @@ class Product {
|
||||
this.description,
|
||||
required this.basePrice,
|
||||
required this.images,
|
||||
this.thumbnail,
|
||||
required this.imageCaptions,
|
||||
this.link360,
|
||||
this.customLink360,
|
||||
required this.specifications,
|
||||
this.category,
|
||||
this.brand,
|
||||
@@ -86,7 +90,7 @@ class Product {
|
||||
String? get categoryId => category;
|
||||
|
||||
/// Check if product has 360 view
|
||||
bool get has360View => link360 != null && link360!.isNotEmpty;
|
||||
bool get has360View => customLink360 != null && customLink360!.isNotEmpty;
|
||||
|
||||
/// Check if product has multiple images
|
||||
bool get hasMultipleImages => images.length > 1;
|
||||
@@ -123,8 +127,9 @@ class Product {
|
||||
String? description,
|
||||
double? basePrice,
|
||||
List<String>? images,
|
||||
String? thumbnail,
|
||||
Map<String, String>? imageCaptions,
|
||||
String? link360,
|
||||
String? customLink360,
|
||||
Map<String, dynamic>? specifications,
|
||||
String? category,
|
||||
String? brand,
|
||||
@@ -141,8 +146,9 @@ class Product {
|
||||
description: description ?? this.description,
|
||||
basePrice: basePrice ?? this.basePrice,
|
||||
images: images ?? this.images,
|
||||
thumbnail: thumbnail ?? this.thumbnail,
|
||||
imageCaptions: imageCaptions ?? this.imageCaptions,
|
||||
link360: link360 ?? this.link360,
|
||||
customLink360: customLink360 ?? this.customLink360,
|
||||
specifications: specifications ?? this.specifications,
|
||||
category: category ?? this.category,
|
||||
brand: brand ?? this.brand,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/// Use Case: Get Product Detail
|
||||
///
|
||||
/// Fetches a single product by its ID from the repository.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/products/domain/entities/product.dart';
|
||||
import 'package:worker/features/products/domain/repositories/products_repository.dart';
|
||||
|
||||
/// Get Product Detail Use Case
|
||||
///
|
||||
/// Fetches detailed information for a single product by ID.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final getProductDetail = GetProductDetail(repository);
|
||||
/// final product = await getProductDetail(productId: 'GIB20 G02');
|
||||
/// ```
|
||||
class GetProductDetail {
|
||||
const GetProductDetail(this._repository);
|
||||
|
||||
final ProductsRepository _repository;
|
||||
|
||||
/// Execute the use case
|
||||
///
|
||||
/// [productId] - The unique identifier of the product
|
||||
/// Returns a [Product] entity
|
||||
/// Throws [Exception] if the product is not found or on error
|
||||
Future<Product> call({required String productId}) async {
|
||||
return await _repository.getProductById(productId);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/favorites/presentation/providers/favorites_provider.dart';
|
||||
import 'package:worker/features/products/domain/entities/product.dart';
|
||||
import 'package:worker/features/products/presentation/providers/products_provider.dart';
|
||||
import 'package:worker/features/products/presentation/widgets/product_detail/image_gallery_section.dart';
|
||||
@@ -34,7 +35,6 @@ class ProductDetailPage extends ConsumerStatefulWidget {
|
||||
|
||||
class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
int _quantity = 1;
|
||||
bool _isFavorite = false;
|
||||
|
||||
void _increaseQuantity() {
|
||||
setState(() {
|
||||
@@ -58,21 +58,23 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleFavorite() {
|
||||
setState(() {
|
||||
_isFavorite = !_isFavorite;
|
||||
});
|
||||
void _toggleFavorite() async {
|
||||
// Toggle favorite using favorites provider
|
||||
await ref.read(favoritesProvider.notifier).toggleFavorite(widget.productId);
|
||||
|
||||
// Show feedback
|
||||
final isFavorite = ref.read(isFavoriteProvider(widget.productId));
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
_isFavorite ? 'Đã thêm vào yêu thích' : 'Đã xóa khỏi yêu thích',
|
||||
isFavorite ? 'Đã thêm vào yêu thích' : 'Đã xóa khỏi yêu thích',
|
||||
),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _shareProduct(Product product) {
|
||||
// Show share options
|
||||
@@ -166,7 +168,11 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final productsAsync = ref.watch(productsProvider);
|
||||
// Use productDetailProvider with productId parameter
|
||||
final productAsync = ref.watch(productDetailProvider(productId: widget.productId));
|
||||
|
||||
// Watch favorite status from favorites provider
|
||||
final isFavorite = ref.watch(isFavoriteProvider(widget.productId));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
@@ -188,11 +194,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share, color: Colors.black),
|
||||
onPressed: () {
|
||||
productsAsync.whenData((products) {
|
||||
final product = products.firstWhere(
|
||||
(p) => p.productId == widget.productId,
|
||||
orElse: () => products.first,
|
||||
);
|
||||
productAsync.whenData((product) {
|
||||
_shareProduct(product);
|
||||
});
|
||||
},
|
||||
@@ -200,22 +202,16 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
// Favorite button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: _isFavorite ? AppColors.danger : Colors.black,
|
||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFavorite ? AppColors.danger : Colors.black,
|
||||
),
|
||||
onPressed: _toggleFavorite,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
),
|
||||
body: productsAsync.when(
|
||||
data: (products) {
|
||||
// Find the product by ID
|
||||
final product = products.firstWhere(
|
||||
(p) => p.productId == widget.productId,
|
||||
orElse: () => products.first, // Fallback for demo
|
||||
);
|
||||
|
||||
body: productAsync.when(
|
||||
data: (product) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Scrollable content
|
||||
@@ -248,6 +244,7 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
right: 0,
|
||||
child: StickyActionBar(
|
||||
quantity: _quantity,
|
||||
unit: product.unit ?? 'm²',
|
||||
onIncrease: _increaseQuantity,
|
||||
onDecrease: _decreaseQuantity,
|
||||
onQuantityChanged: _updateQuantity,
|
||||
@@ -289,8 +286,9 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
await ref.read(productsProvider.notifier).refresh();
|
||||
onPressed: () {
|
||||
// Invalidate to trigger refetch
|
||||
ref.invalidate(productDetailProvider(productId: widget.productId));
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Thử lại'),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:worker/core/router/app_router.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/cart/presentation/providers/cart_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/categories_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/product_filter_options_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/products_provider.dart';
|
||||
import 'package:worker/features/products/presentation/widgets/category_filter_chips.dart';
|
||||
import 'package:worker/features/products/presentation/widgets/product_filter_drawer.dart';
|
||||
@@ -31,11 +32,14 @@ class ProductsPage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final categoriesAsync = ref.watch(categoriesProvider);
|
||||
final productsAsync = ref.watch(productsProvider);
|
||||
final cartItemCount = ref.watch(cartItemCountProvider);
|
||||
|
||||
// Preload filter options for better UX when opening filter drawer
|
||||
ref.watch(productFilterOptionsProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8), // Match HTML background
|
||||
endDrawer: const ProductFilterDrawer(),
|
||||
@@ -57,10 +61,7 @@ class ProductsPage extends ConsumerWidget {
|
||||
backgroundColor: AppColors.danger,
|
||||
textColor: AppColors.white,
|
||||
isLabelVisible: cartItemCount > 0,
|
||||
child: const Icon(
|
||||
Icons.shopping_cart_outlined,
|
||||
color: Colors.black,
|
||||
),
|
||||
child: const Icon(Icons.shopping_cart_outlined, color: Colors.black),
|
||||
),
|
||||
onPressed: () => context.push(RouteNames.cart),
|
||||
),
|
||||
@@ -77,9 +78,7 @@ class ProductsPage extends ConsumerWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
// Search Bar (Expanded)
|
||||
const Expanded(
|
||||
child: ProductSearchBar(),
|
||||
),
|
||||
const Expanded(child: ProductSearchBar()),
|
||||
const SizedBox(width: 8),
|
||||
// Filter Button
|
||||
SizedBox(
|
||||
@@ -90,25 +89,14 @@ class ProductsPage extends ConsumerWidget {
|
||||
Scaffold.of(scaffoldContext).openEndDrawer();
|
||||
},
|
||||
icon: const Icon(Icons.filter_list, size: 20),
|
||||
label: const Text(
|
||||
'Lọc',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
label: const Text('Lọc', style: TextStyle(fontSize: 12)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.grey900,
|
||||
side: const BorderSide(
|
||||
color: AppColors.white,
|
||||
width: 0,
|
||||
),
|
||||
side: const BorderSide(color: AppColors.white, width: 0),
|
||||
backgroundColor: AppColors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
InputFieldSpecs.borderRadius,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -120,10 +108,8 @@ class ProductsPage extends ConsumerWidget {
|
||||
// Category Filter Chips
|
||||
categoriesAsync.when(
|
||||
data: (categories) => CategoryFilterChips(categories: categories),
|
||||
loading: () => const SizedBox(
|
||||
height: 48.0,
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2.0)),
|
||||
),
|
||||
loading: () =>
|
||||
const SizedBox(height: 48.0, child: Center(child: CircularProgressIndicator(strokeWidth: 2.0))),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
@@ -149,21 +135,16 @@ class ProductsPage extends ConsumerWidget {
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('${product.name} đã thêm vào giỏ hàng'),
|
||||
content: Text('${product.name} đã thêm vào giỏ hàng'),
|
||||
duration: const Duration(seconds: 2),
|
||||
action: SnackBarAction(
|
||||
label: 'Xem',
|
||||
onPressed: () => context.go(RouteNames.cart),
|
||||
),
|
||||
action: SnackBarAction(label: 'Xem', onPressed: () => context.go(RouteNames.cart)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => _buildLoadingState(),
|
||||
error: (error, stack) =>
|
||||
_buildErrorState(context, l10n, error, ref),
|
||||
error: (error, stack) => _buildErrorState(context, l10n, error, ref),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -179,25 +160,14 @@ class ProductsPage extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.inventory_2_outlined,
|
||||
size: 80.0,
|
||||
color: AppColors.grey500.withAlpha(128),
|
||||
),
|
||||
Icon(Icons.inventory_2_outlined, size: 80.0, color: AppColors.grey500.withAlpha(128)),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
l10n.noProductsFound,
|
||||
style: const TextStyle(
|
||||
fontSize: 18.0,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.w500, color: AppColors.grey900),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
l10n.noResults,
|
||||
style: const TextStyle(fontSize: 14.0, color: AppColors.grey500),
|
||||
),
|
||||
Text(l10n.noResults, style: const TextStyle(fontSize: 14.0, color: AppColors.grey500)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -205,37 +175,22 @@ class ProductsPage extends ConsumerWidget {
|
||||
|
||||
/// Build loading state
|
||||
Widget _buildLoadingState() {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primaryBlue),
|
||||
);
|
||||
return const Center(child: CircularProgressIndicator(color: AppColors.primaryBlue));
|
||||
}
|
||||
|
||||
/// Build error state
|
||||
Widget _buildErrorState(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
Object error,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
Widget _buildErrorState(BuildContext context, AppLocalizations l10n, Object error, WidgetRef ref) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.xl),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 80.0,
|
||||
color: AppColors.danger.withAlpha(128),
|
||||
),
|
||||
Icon(Icons.error_outline, size: 80.0, color: AppColors.danger.withAlpha(128)),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text(
|
||||
l10n.error,
|
||||
style: const TextStyle(
|
||||
fontSize: 18.0,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
style: const TextStyle(fontSize: 18.0, fontWeight: FontWeight.w600, color: AppColors.grey900),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
@@ -253,10 +208,7 @@ class ProductsPage extends ConsumerWidget {
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: AppColors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: AppSpacing.md),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -4,10 +4,9 @@
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/products/data/datasources/products_local_datasource.dart';
|
||||
import 'package:worker/features/products/data/repositories/products_repository_impl.dart';
|
||||
import 'package:worker/features/products/domain/entities/category.dart';
|
||||
import 'package:worker/features/products/domain/usecases/get_categories.dart';
|
||||
import 'package:worker/features/products/presentation/providers/products_provider.dart';
|
||||
|
||||
part 'categories_provider.g.dart';
|
||||
|
||||
@@ -28,8 +27,8 @@ part 'categories_provider.g.dart';
|
||||
/// ```
|
||||
@riverpod
|
||||
Future<List<Category>> categories(Ref ref) async {
|
||||
final localDataSource = const ProductsLocalDataSourceImpl();
|
||||
final repository = ProductsRepositoryImpl(localDataSource: localDataSource);
|
||||
// Get products repository with injected dependencies
|
||||
final repository = await ref.watch(productsRepositoryProvider.future);
|
||||
final useCase = GetCategories(repository);
|
||||
|
||||
return await useCase();
|
||||
|
||||
@@ -92,4 +92,4 @@ final class CategoriesProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$categoriesHash() => r'6de35d3271d6d6572d9cdf5ed68edd26036115fc';
|
||||
String _$categoriesHash() => r'811c668d2624bbc198bc7c563ed14c7d9ffc390b';
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/// Provider: Product Filter Options Provider
|
||||
///
|
||||
/// Merges data from 3 APIs (groups, brands, attributes) for product filtering.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/products/presentation/providers/products_provider.dart';
|
||||
|
||||
part 'product_filter_options_provider.g.dart';
|
||||
|
||||
/// Filter Options Data Model
|
||||
///
|
||||
/// Combines all filter options from the 3 APIs
|
||||
class ProductFilterOptions {
|
||||
const ProductFilterOptions({
|
||||
required this.groups,
|
||||
required this.brands,
|
||||
required this.attributeGroups,
|
||||
});
|
||||
|
||||
/// Product groups (Item Groups)
|
||||
final List<FilterOption> groups;
|
||||
|
||||
/// Product brands
|
||||
final List<FilterOption> brands;
|
||||
|
||||
/// Product attribute groups (Colour, Size, Surface, etc.)
|
||||
/// Each group contains a list of attribute values
|
||||
final List<AttributeGroup> attributeGroups;
|
||||
}
|
||||
|
||||
/// Attribute Group Model
|
||||
///
|
||||
/// Represents a group of attributes (e.g., Colour, Size, Surface)
|
||||
/// with its associated values
|
||||
class AttributeGroup {
|
||||
const AttributeGroup({
|
||||
required this.name,
|
||||
required this.attributeName,
|
||||
required this.values,
|
||||
});
|
||||
|
||||
/// Internal name
|
||||
final String name;
|
||||
|
||||
/// Display name
|
||||
final String attributeName;
|
||||
|
||||
/// List of attribute values
|
||||
final List<AttributeValue> values;
|
||||
}
|
||||
|
||||
/// Attribute Value Model
|
||||
///
|
||||
/// Represents a single attribute value within a group
|
||||
class AttributeValue {
|
||||
const AttributeValue({
|
||||
required this.name,
|
||||
required this.attributeValue,
|
||||
this.abbr,
|
||||
});
|
||||
|
||||
/// Internal name/ID
|
||||
final String name;
|
||||
|
||||
/// Display value
|
||||
final String attributeValue;
|
||||
|
||||
/// Abbreviation (optional)
|
||||
final String? abbr;
|
||||
}
|
||||
|
||||
/// Filter Option Model
|
||||
///
|
||||
/// Represents a single filter option with value and label
|
||||
class FilterOption {
|
||||
const FilterOption({
|
||||
required this.value,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
final String value;
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// Product Filter Options Provider
|
||||
///
|
||||
/// Fetches and combines data from 3 Frappe APIs:
|
||||
/// 1. Product Groups (Item Groups)
|
||||
/// 2. Product Brands
|
||||
/// 3. Product Attributes
|
||||
///
|
||||
/// Memory footprint: ~5-15 KB (negligible)
|
||||
/// Cache strategy: Keep alive for session duration
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
|
||||
///
|
||||
/// filterOptionsAsync.when(
|
||||
/// data: (options) => ProductFilterDrawer(options: options),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<ProductFilterOptions> productFilterOptions(Ref ref) async {
|
||||
try {
|
||||
// Get remote datasource
|
||||
final remoteDataSource = await ref.watch(productsRemoteDataSourceProvider.future);
|
||||
|
||||
// Fetch all 3 APIs in parallel
|
||||
final results = await Future.wait([
|
||||
remoteDataSource.getProductGroups(),
|
||||
remoteDataSource.getProductBrands(),
|
||||
remoteDataSource.getProductAttributes(),
|
||||
]);
|
||||
|
||||
final groupsData = results[0] as List<Map<String, dynamic>>;
|
||||
final brandsData = results[1] as List<String>;
|
||||
final attributesData = results[2] as List<Map<String, dynamic>>;
|
||||
|
||||
// Convert groups to FilterOption
|
||||
final groups = groupsData
|
||||
.map((group) => FilterOption(
|
||||
value: group['name'] as String,
|
||||
label: group['item_group_name'] as String? ?? group['name'] as String,
|
||||
))
|
||||
.toList();
|
||||
|
||||
// Convert brands to FilterOption
|
||||
final brands = brandsData
|
||||
.map((brand) => FilterOption(
|
||||
value: brand,
|
||||
label: brand,
|
||||
))
|
||||
.toList();
|
||||
|
||||
// Convert attributes to AttributeGroup
|
||||
// Each attribute has: name, attribute_name, and values list
|
||||
final attributeGroups = attributesData.map((attr) {
|
||||
final valuesList = attr['values'] as List? ?? [];
|
||||
|
||||
final values = valuesList.map((val) {
|
||||
return AttributeValue(
|
||||
name: val['name'] as String,
|
||||
attributeValue: val['attribute_value'] as String,
|
||||
abbr: val['abbr'] as String?,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return AttributeGroup(
|
||||
name: attr['name'] as String,
|
||||
attributeName: attr['attribute_name'] as String? ?? attr['name'] as String,
|
||||
values: values,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
return ProductFilterOptions(
|
||||
groups: groups,
|
||||
brands: brands,
|
||||
attributeGroups: attributeGroups,
|
||||
);
|
||||
} catch (e) {
|
||||
print('[ProductFilterOptionsProvider] Error fetching filter options: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'product_filter_options_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Product Filter Options Provider
|
||||
///
|
||||
/// Fetches and combines data from 3 Frappe APIs:
|
||||
/// 1. Product Groups (Item Groups)
|
||||
/// 2. Product Brands
|
||||
/// 3. Product Attributes
|
||||
///
|
||||
/// Memory footprint: ~5-15 KB (negligible)
|
||||
/// Cache strategy: Keep alive for session duration
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
|
||||
///
|
||||
/// filterOptionsAsync.when(
|
||||
/// data: (options) => ProductFilterDrawer(options: options),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(productFilterOptions)
|
||||
const productFilterOptionsProvider = ProductFilterOptionsProvider._();
|
||||
|
||||
/// Product Filter Options Provider
|
||||
///
|
||||
/// Fetches and combines data from 3 Frappe APIs:
|
||||
/// 1. Product Groups (Item Groups)
|
||||
/// 2. Product Brands
|
||||
/// 3. Product Attributes
|
||||
///
|
||||
/// Memory footprint: ~5-15 KB (negligible)
|
||||
/// Cache strategy: Keep alive for session duration
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
|
||||
///
|
||||
/// filterOptionsAsync.when(
|
||||
/// data: (options) => ProductFilterDrawer(options: options),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
final class ProductFilterOptionsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<ProductFilterOptions>,
|
||||
ProductFilterOptions,
|
||||
FutureOr<ProductFilterOptions>
|
||||
>
|
||||
with
|
||||
$FutureModifier<ProductFilterOptions>,
|
||||
$FutureProvider<ProductFilterOptions> {
|
||||
/// Product Filter Options Provider
|
||||
///
|
||||
/// Fetches and combines data from 3 Frappe APIs:
|
||||
/// 1. Product Groups (Item Groups)
|
||||
/// 2. Product Brands
|
||||
/// 3. Product Attributes
|
||||
///
|
||||
/// Memory footprint: ~5-15 KB (negligible)
|
||||
/// Cache strategy: Keep alive for session duration
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
|
||||
///
|
||||
/// filterOptionsAsync.when(
|
||||
/// data: (options) => ProductFilterDrawer(options: options),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
const ProductFilterOptionsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'productFilterOptionsProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productFilterOptionsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<ProductFilterOptions> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<ProductFilterOptions> create(Ref ref) {
|
||||
return productFilterOptions(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$productFilterOptionsHash() =>
|
||||
r'253586215f05ca2fd1ccae7922b5925150614af0';
|
||||
@@ -2,23 +2,55 @@
|
||||
///
|
||||
/// Manages the state of products data using Riverpod.
|
||||
/// Provides filtered products based on category and search query.
|
||||
/// Fetches data from Frappe ERPNext API via remote data source.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/core/network/dio_client.dart';
|
||||
import 'package:worker/core/services/frappe_auth_provider.dart';
|
||||
import 'package:worker/features/products/data/datasources/products_local_datasource.dart';
|
||||
import 'package:worker/features/products/data/datasources/products_remote_datasource.dart';
|
||||
import 'package:worker/features/products/data/repositories/products_repository_impl.dart';
|
||||
import 'package:worker/features/products/domain/entities/product.dart';
|
||||
import 'package:worker/features/products/domain/repositories/products_repository.dart';
|
||||
import 'package:worker/features/products/domain/usecases/get_products.dart';
|
||||
import 'package:worker/features/products/domain/usecases/search_products.dart';
|
||||
import 'package:worker/features/products/domain/usecases/get_product_detail.dart';
|
||||
import 'package:worker/features/products/presentation/providers/selected_category_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/search_query_provider.dart';
|
||||
|
||||
part 'products_provider.g.dart';
|
||||
|
||||
/// Products Local DataSource Provider
|
||||
@riverpod
|
||||
ProductsLocalDataSource productsLocalDataSource(Ref ref) {
|
||||
return const ProductsLocalDataSourceImpl();
|
||||
}
|
||||
|
||||
/// Products Remote DataSource Provider
|
||||
@riverpod
|
||||
Future<ProductsRemoteDataSource> productsRemoteDataSource(Ref ref) async {
|
||||
final dioClient = await ref.watch(dioClientProvider.future);
|
||||
final frappeAuthService = ref.watch(frappeAuthServiceProvider);
|
||||
return ProductsRemoteDataSource(dioClient, frappeAuthService);
|
||||
}
|
||||
|
||||
/// Products Repository Provider
|
||||
@riverpod
|
||||
Future<ProductsRepository> productsRepository(Ref ref) async {
|
||||
final localDataSource = ref.watch(productsLocalDataSourceProvider);
|
||||
final remoteDataSource = await ref.watch(productsRemoteDataSourceProvider.future);
|
||||
return ProductsRepositoryImpl(
|
||||
localDataSource: localDataSource,
|
||||
remoteDataSource: remoteDataSource,
|
||||
);
|
||||
}
|
||||
|
||||
/// Products Provider
|
||||
///
|
||||
/// Fetches and filters products based on selected category and search query.
|
||||
/// Automatically updates when category or search query changes.
|
||||
/// Data is fetched from Frappe ERPNext API.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
@@ -38,9 +70,8 @@ class Products extends _$Products {
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
final searchQuery = ref.watch(searchQueryProvider);
|
||||
|
||||
// Initialize dependencies
|
||||
final localDataSource = const ProductsLocalDataSourceImpl();
|
||||
final repository = ProductsRepositoryImpl(localDataSource: localDataSource);
|
||||
// Get repository with injected data sources
|
||||
final repository = await ref.watch(productsRepositoryProvider.future);
|
||||
|
||||
// Apply filters
|
||||
List<Product> products;
|
||||
@@ -78,11 +109,34 @@ class Products extends _$Products {
|
||||
///
|
||||
/// Provides all products without any filtering.
|
||||
/// Useful for product selection dialogs, etc.
|
||||
/// Fetches from Frappe ERPNext API.
|
||||
@riverpod
|
||||
Future<List<Product>> allProducts(Ref ref) async {
|
||||
final localDataSource = const ProductsLocalDataSourceImpl();
|
||||
final repository = ProductsRepositoryImpl(localDataSource: localDataSource);
|
||||
final repository = await ref.watch(productsRepositoryProvider.future);
|
||||
final getProductsUseCase = GetProducts(repository);
|
||||
|
||||
return await getProductsUseCase();
|
||||
}
|
||||
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
@riverpod
|
||||
Future<Product> productDetail(Ref ref, {required String productId}) async {
|
||||
final repository = await ref.watch(productsRepositoryProvider.future);
|
||||
final getProductDetailUseCase = GetProductDetail(repository);
|
||||
|
||||
return await getProductDetailUseCase(productId: productId);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,158 @@ part of 'products_provider.dart';
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Products Local DataSource Provider
|
||||
|
||||
@ProviderFor(productsLocalDataSource)
|
||||
const productsLocalDataSourceProvider = ProductsLocalDataSourceProvider._();
|
||||
|
||||
/// Products Local DataSource Provider
|
||||
|
||||
final class ProductsLocalDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
ProductsLocalDataSource,
|
||||
ProductsLocalDataSource,
|
||||
ProductsLocalDataSource
|
||||
>
|
||||
with $Provider<ProductsLocalDataSource> {
|
||||
/// Products Local DataSource Provider
|
||||
const ProductsLocalDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'productsLocalDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productsLocalDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ProductsLocalDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ProductsLocalDataSource create(Ref ref) {
|
||||
return productsLocalDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ProductsLocalDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ProductsLocalDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$productsLocalDataSourceHash() =>
|
||||
r'c9f87b0affb7b86b890c38175b2b5ff328b7cfa4';
|
||||
|
||||
/// Products Remote DataSource Provider
|
||||
|
||||
@ProviderFor(productsRemoteDataSource)
|
||||
const productsRemoteDataSourceProvider = ProductsRemoteDataSourceProvider._();
|
||||
|
||||
/// Products Remote DataSource Provider
|
||||
|
||||
final class ProductsRemoteDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<ProductsRemoteDataSource>,
|
||||
ProductsRemoteDataSource,
|
||||
FutureOr<ProductsRemoteDataSource>
|
||||
>
|
||||
with
|
||||
$FutureModifier<ProductsRemoteDataSource>,
|
||||
$FutureProvider<ProductsRemoteDataSource> {
|
||||
/// Products Remote DataSource Provider
|
||||
const ProductsRemoteDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'productsRemoteDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productsRemoteDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<ProductsRemoteDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<ProductsRemoteDataSource> create(Ref ref) {
|
||||
return productsRemoteDataSource(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$productsRemoteDataSourceHash() =>
|
||||
r'4f08cec50d95b05954ca18a7b04d5f09d1ffd059';
|
||||
|
||||
/// Products Repository Provider
|
||||
|
||||
@ProviderFor(productsRepository)
|
||||
const productsRepositoryProvider = ProductsRepositoryProvider._();
|
||||
|
||||
/// Products Repository Provider
|
||||
|
||||
final class ProductsRepositoryProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<ProductsRepository>,
|
||||
ProductsRepository,
|
||||
FutureOr<ProductsRepository>
|
||||
>
|
||||
with
|
||||
$FutureModifier<ProductsRepository>,
|
||||
$FutureProvider<ProductsRepository> {
|
||||
/// Products Repository Provider
|
||||
const ProductsRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'productsRepositoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productsRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<ProductsRepository> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<ProductsRepository> create(Ref ref) {
|
||||
return productsRepository(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$productsRepositoryHash() =>
|
||||
r'f1ddd3ec17bbbb70e87a8d47e7c58f681c80c2ae';
|
||||
|
||||
/// Products Provider
|
||||
///
|
||||
/// Fetches and filters products based on selected category and search query.
|
||||
/// Automatically updates when category or search query changes.
|
||||
/// Data is fetched from Frappe ERPNext API.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
@@ -31,6 +179,7 @@ const productsProvider = ProductsProvider._();
|
||||
///
|
||||
/// Fetches and filters products based on selected category and search query.
|
||||
/// Automatically updates when category or search query changes.
|
||||
/// Data is fetched from Frappe ERPNext API.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
@@ -48,6 +197,7 @@ final class ProductsProvider
|
||||
///
|
||||
/// Fetches and filters products based on selected category and search query.
|
||||
/// Automatically updates when category or search query changes.
|
||||
/// Data is fetched from Frappe ERPNext API.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
@@ -78,12 +228,13 @@ final class ProductsProvider
|
||||
Products create() => Products();
|
||||
}
|
||||
|
||||
String _$productsHash() => r'0f1b32d2c14b9d8d600ffb0270f54d32af753e1f';
|
||||
String _$productsHash() => r'b892402a88484d301cdabd1fde5822ddd29538bf';
|
||||
|
||||
/// Products Provider
|
||||
///
|
||||
/// Fetches and filters products based on selected category and search query.
|
||||
/// Automatically updates when category or search query changes.
|
||||
/// Data is fetched from Frappe ERPNext API.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
@@ -119,6 +270,7 @@ abstract class _$Products extends $AsyncNotifier<List<Product>> {
|
||||
///
|
||||
/// Provides all products without any filtering.
|
||||
/// Useful for product selection dialogs, etc.
|
||||
/// Fetches from Frappe ERPNext API.
|
||||
|
||||
@ProviderFor(allProducts)
|
||||
const allProductsProvider = AllProductsProvider._();
|
||||
@@ -127,6 +279,7 @@ const allProductsProvider = AllProductsProvider._();
|
||||
///
|
||||
/// Provides all products without any filtering.
|
||||
/// Useful for product selection dialogs, etc.
|
||||
/// Fetches from Frappe ERPNext API.
|
||||
|
||||
final class AllProductsProvider
|
||||
extends
|
||||
@@ -140,6 +293,7 @@ final class AllProductsProvider
|
||||
///
|
||||
/// Provides all products without any filtering.
|
||||
/// Useful for product selection dialogs, etc.
|
||||
/// Fetches from Frappe ERPNext API.
|
||||
const AllProductsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
@@ -166,4 +320,152 @@ final class AllProductsProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$allProductsHash() => r'a02e989ad36e644d9b62e681b3ced88e10e4d4c3';
|
||||
String _$allProductsHash() => r'402d7c6e8d119c7c7eab5e696fb8163831259def';
|
||||
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(productDetail)
|
||||
const productDetailProvider = ProductDetailFamily._();
|
||||
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
final class ProductDetailProvider
|
||||
extends $FunctionalProvider<AsyncValue<Product>, Product, FutureOr<Product>>
|
||||
with $FutureModifier<Product>, $FutureProvider<Product> {
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
const ProductDetailProvider._({
|
||||
required ProductDetailFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'productDetailProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$productDetailHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'productDetailProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<Product> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<Product> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return productDetail(ref, productId: argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ProductDetailProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$productDetailHash() => r'ca219f1451f518c84ca1832aacb3c83920f4bfd2';
|
||||
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
final class ProductDetailFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<Product>, String> {
|
||||
const ProductDetailFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'productDetailProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Product Detail Provider
|
||||
///
|
||||
/// Fetches a single product by ID from Frappe ERPNext API.
|
||||
/// Uses getProductDetail endpoint for efficient single product fetch.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// final productAsync = ref.watch(productDetailProvider(productId: 'GIB20 G02'));
|
||||
///
|
||||
/// productAsync.when(
|
||||
/// data: (product) => ProductDetailView(product: product),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
ProductDetailProvider call({required String productId}) =>
|
||||
ProductDetailProvider._(argument: productId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'productDetailProvider';
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ class ProductCard extends ConsumerWidget {
|
||||
|
||||
return Card(
|
||||
elevation: ProductCardSpecs.elevation,
|
||||
margin: const EdgeInsets.all(8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(ProductCardSpecs.borderRadius),
|
||||
),
|
||||
@@ -61,7 +62,7 @@ class ProductCard extends ConsumerWidget {
|
||||
top: Radius.circular(ProductCardSpecs.borderRadius),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: product.imageUrl,
|
||||
imageUrl: product.thumbnail ?? '',
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
|
||||
@@ -48,7 +48,7 @@ class _ImageGallerySectionState extends State<ImageGallerySection> {
|
||||
}
|
||||
|
||||
void _open360View() {
|
||||
if (widget.product.link360 != null) {
|
||||
if (widget.product.customLink360 != null) {
|
||||
// TODO: Open in browser
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
|
||||
@@ -113,19 +113,19 @@ class ProductInfoSection extends StatelessWidget {
|
||||
// Size Info
|
||||
Expanded(
|
||||
child: _QuickInfoCard(
|
||||
icon: Icons.straighten,
|
||||
icon: Icons.straighten, // expand icon
|
||||
label: 'Kích thước',
|
||||
value: product.getSpecification('size') ?? '1200x1200',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Warranty Info
|
||||
// Packaging Info
|
||||
Expanded(
|
||||
child: _QuickInfoCard(
|
||||
icon: Icons.shield,
|
||||
label: 'Bảo hành',
|
||||
value: product.getSpecification('warranty') ?? '15 năm',
|
||||
icon: Icons.inventory_2_outlined, // cube/box icon
|
||||
label: 'Đóng gói',
|
||||
value: product.getSpecification('packaging') ?? '2 viên/thùng',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -133,9 +133,9 @@ class ProductInfoSection extends StatelessWidget {
|
||||
// Delivery Info
|
||||
Expanded(
|
||||
child: _QuickInfoCard(
|
||||
icon: Icons.local_shipping,
|
||||
icon: Icons.local_shipping_outlined, // truck icon
|
||||
label: 'Giao hàng',
|
||||
value: '2-3 ngày',
|
||||
value: '2-3 Ngày',
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -30,7 +30,8 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
// Start with Specifications tab (index 0)
|
||||
_tabController = TabController(length: 2, vsync: this, initialIndex: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -67,7 +68,6 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
|
||||
indicatorColor: AppColors.primaryBlue,
|
||||
indicatorWeight: 2,
|
||||
tabs: const [
|
||||
Tab(text: 'Mô tả'),
|
||||
Tab(text: 'Thông số'),
|
||||
Tab(text: 'Đánh giá'),
|
||||
],
|
||||
@@ -80,7 +80,6 @@ class _ProductTabsSectionState extends State<ProductTabsSection>
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_DescriptionTab(product: widget.product),
|
||||
_SpecificationsTab(product: widget.product),
|
||||
const _ReviewsTab(),
|
||||
],
|
||||
|
||||
@@ -11,8 +11,8 @@ import 'package:worker/core/theme/colors.dart';
|
||||
/// Sticky Action Bar
|
||||
///
|
||||
/// Fixed at the bottom of the screen with:
|
||||
/// - Quantity controls (-, input, +)
|
||||
/// - Add to cart button (full width, primary blue)
|
||||
/// - Quantity section with label, controls, and conversion text
|
||||
/// - Add to cart button
|
||||
class StickyActionBar extends StatelessWidget {
|
||||
final int quantity;
|
||||
final VoidCallback onIncrease;
|
||||
@@ -20,6 +20,7 @@ class StickyActionBar extends StatelessWidget {
|
||||
final ValueChanged<int> onQuantityChanged;
|
||||
final VoidCallback onAddToCart;
|
||||
final bool isOutOfStock;
|
||||
final String unit;
|
||||
|
||||
const StickyActionBar({
|
||||
super.key,
|
||||
@@ -29,8 +30,16 @@ class StickyActionBar extends StatelessWidget {
|
||||
required this.onQuantityChanged,
|
||||
required this.onAddToCart,
|
||||
this.isOutOfStock = false,
|
||||
this.unit = 'm²',
|
||||
});
|
||||
|
||||
String _getConversionText() {
|
||||
// Calculate conversion: each m² ≈ 0.36 boxes, each box = varies
|
||||
final pieces = (quantity / 0.36).ceil();
|
||||
final actualArea = (pieces * 0.36).toStringAsFixed(2);
|
||||
return 'Tương đương: $pieces viên / $actualArea m²';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -52,8 +61,26 @@ class StickyActionBar extends StatelessWidget {
|
||||
top: false,
|
||||
child: Row(
|
||||
children: [
|
||||
// Quantity Section
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Label
|
||||
Text(
|
||||
'Số lượng ($unit)',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Quantity Controls
|
||||
Container(
|
||||
width: 142,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFFe0e0e0)),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -67,8 +94,7 @@ class StickyActionBar extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Quantity Input
|
||||
SizedBox(
|
||||
width: 60,
|
||||
Expanded(
|
||||
child: TextField(
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
@@ -76,7 +102,9 @@ class StickyActionBar extends StatelessWidget {
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
@@ -99,6 +127,19 @@ class StickyActionBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Conversion Text
|
||||
Text(
|
||||
_getConversionText(),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Add to Cart Button
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/products/presentation/providers/product_filters_provider.dart';
|
||||
import 'package:worker/features/products/presentation/providers/product_filter_options_provider.dart';
|
||||
|
||||
/// Product Filter Drawer Widget
|
||||
///
|
||||
@@ -20,55 +21,15 @@ import 'package:worker/features/products/presentation/providers/product_filters_
|
||||
class ProductFilterDrawer extends ConsumerWidget {
|
||||
const ProductFilterDrawer({super.key});
|
||||
|
||||
// Filter options (from HTML)
|
||||
static const List<FilterOption> _productLines = [
|
||||
FilterOption(value: 'tam-lon', label: 'Tấm lớn'),
|
||||
FilterOption(value: 'third-firing', label: 'Third-Firing'),
|
||||
FilterOption(value: 'outdoor', label: 'Outdoor'),
|
||||
FilterOption(value: 'van-da', label: 'Vân đá'),
|
||||
FilterOption(value: 'xi-mang', label: 'Xi măng'),
|
||||
FilterOption(value: 'van-go', label: 'Vân gỗ'),
|
||||
FilterOption(value: 'xuong-trang', label: 'Xương trắng'),
|
||||
FilterOption(value: 'cam-thach', label: 'Cẩm thạch'),
|
||||
];
|
||||
|
||||
static const List<FilterOption> _spaces = [
|
||||
FilterOption(value: 'phong-khach', label: 'Phòng khách'),
|
||||
FilterOption(value: 'phong-ngu', label: 'Phòng ngủ'),
|
||||
FilterOption(value: 'phong-tam', label: 'Phòng tắm'),
|
||||
FilterOption(value: 'nha-bep', label: 'Nhà bếp'),
|
||||
FilterOption(value: 'khong-gian-khac', label: 'Không gian khác'),
|
||||
];
|
||||
|
||||
static const List<FilterOption> _sizes = [
|
||||
FilterOption(value: '200x1600', label: '200x1600'),
|
||||
FilterOption(value: '1200x2400', label: '1200x2400'),
|
||||
FilterOption(value: '7500x1500', label: '7500x1500'),
|
||||
FilterOption(value: '1200x1200', label: '1200x1200'),
|
||||
FilterOption(value: '600x1200', label: '600x1200'),
|
||||
FilterOption(value: '450x900', label: '450x900'),
|
||||
];
|
||||
|
||||
static const List<FilterOption> _surfaces = [
|
||||
FilterOption(value: 'satin', label: 'SATIN'),
|
||||
FilterOption(value: 'honed', label: 'HONED'),
|
||||
FilterOption(value: 'matt', label: 'MATT'),
|
||||
FilterOption(value: 'polish', label: 'POLISH'),
|
||||
FilterOption(value: 'babyskin', label: 'BABYSKIN'),
|
||||
];
|
||||
|
||||
static const List<FilterOption> _brands = [
|
||||
FilterOption(value: 'eurotile', label: 'Eurotile'),
|
||||
FilterOption(value: 'vasta-stone', label: 'Vasta Stone'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final filtersState = ref.watch(productFiltersProvider);
|
||||
final filterOptionsAsync = ref.watch(productFilterOptionsProvider);
|
||||
|
||||
return Drawer(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
child: filterOptionsAsync.when(
|
||||
data: (filterOptions) => Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
@@ -105,10 +66,11 @@ class ProductFilterDrawer extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Dòng sản phẩm
|
||||
// Nhóm sản phẩm (Item Groups) - from API
|
||||
_buildFilterGroup(
|
||||
title: 'Dòng sản phẩm',
|
||||
options: _productLines,
|
||||
title: 'Nhóm sản phẩm',
|
||||
options: filterOptions.groups,
|
||||
initiallyExpanded: true,
|
||||
selectedValues: filtersState.productLines,
|
||||
onToggle: (value) => ref
|
||||
.read(productFiltersProvider.notifier)
|
||||
@@ -117,52 +79,35 @@ class ProductFilterDrawer extends ConsumerWidget {
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Không gian
|
||||
_buildFilterGroup(
|
||||
title: 'Không gian',
|
||||
options: _spaces,
|
||||
selectedValues: filtersState.spaces,
|
||||
onToggle: (value) => ref
|
||||
.read(productFiltersProvider.notifier)
|
||||
.toggleSpace(value),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Kích thước
|
||||
_buildFilterGroup(
|
||||
title: 'Kích thước',
|
||||
options: _sizes,
|
||||
selectedValues: filtersState.sizes,
|
||||
onToggle: (value) => ref
|
||||
.read(productFiltersProvider.notifier)
|
||||
.toggleSize(value),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Bề mặt
|
||||
_buildFilterGroup(
|
||||
title: 'Bề mặt',
|
||||
options: _surfaces,
|
||||
selectedValues: filtersState.surfaces,
|
||||
onToggle: (value) => ref
|
||||
.read(productFiltersProvider.notifier)
|
||||
.toggleSurface(value),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Thương hiệu
|
||||
// Thương hiệu (Brands) - from API
|
||||
_buildFilterGroup(
|
||||
title: 'Thương hiệu',
|
||||
options: _brands,
|
||||
options: filterOptions.brands,
|
||||
selectedValues: filtersState.brands,
|
||||
onToggle: (value) => ref
|
||||
.read(productFiltersProvider.notifier)
|
||||
.toggleBrand(value),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
|
||||
// Attribute Groups (Colour, Size, Surface) - from API
|
||||
...filterOptions.attributeGroups.map((attrGroup) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildAttributeGroup(
|
||||
title: attrGroup.attributeName,
|
||||
attributeGroup: attrGroup,
|
||||
selectedValues: filtersState.sizes, // TODO: Map to correct filter state
|
||||
onToggle: (value) => ref
|
||||
.read(productFiltersProvider.notifier)
|
||||
.toggleSize(value),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
],
|
||||
);
|
||||
}),
|
||||
|
||||
const SizedBox(height: 100), // Space for footer buttons
|
||||
],
|
||||
),
|
||||
@@ -251,6 +196,56 @@ class ProductFilterDrawer extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryBlue),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
const Text(
|
||||
'Không thể tải bộ lọc',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.invalidate(productFilterOptionsProvider);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: AppColors.white,
|
||||
),
|
||||
child: const Text('Thử lại'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -260,11 +255,18 @@ class ProductFilterDrawer extends ConsumerWidget {
|
||||
required List<FilterOption> options,
|
||||
required Set<String> selectedValues,
|
||||
required Function(String) onToggle,
|
||||
bool initiallyExpanded = false,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
return Theme(
|
||||
data: ThemeData(
|
||||
dividerColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: const EdgeInsets.only(left: 8),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
@@ -272,8 +274,7 @@ class ProductFilterDrawer extends ConsumerWidget {
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Column(
|
||||
initiallyExpanded: initiallyExpanded,
|
||||
children: options.map((option) {
|
||||
return CheckboxListTile(
|
||||
title: Text(
|
||||
@@ -294,18 +295,62 @@ class ProductFilterDrawer extends ConsumerWidget {
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAttributeGroup({
|
||||
required String title,
|
||||
required AttributeGroup attributeGroup,
|
||||
required Set<String> selectedValues,
|
||||
required Function(String) onToggle,
|
||||
}) {
|
||||
return Theme(
|
||||
data: ThemeData(
|
||||
dividerColor: Colors.transparent,
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: const EdgeInsets.only(left: 8),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
initiallyExpanded: false,
|
||||
children: attributeGroup.values.map((value) {
|
||||
return CheckboxListTile(
|
||||
title: Text(
|
||||
value.attributeValue,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
// subtitle: value.abbr != null
|
||||
// ? Text(
|
||||
// 'Mã: ${value.abbr}',
|
||||
// style: const TextStyle(
|
||||
// fontSize: 12,
|
||||
// color: AppColors.grey500,
|
||||
// ),
|
||||
// )
|
||||
// : null,
|
||||
value: selectedValues.contains(value.name),
|
||||
onChanged: (bool? checked) {
|
||||
onToggle(value.name);
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
activeColor: AppColors.primaryBlue,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter Option Model
|
||||
class FilterOption {
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
const FilterOption({
|
||||
required this.value,
|
||||
required this.label,
|
||||
});
|
||||
}
|
||||
|
||||
40
pubspec.lock
40
pubspec.lock
@@ -153,6 +153,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
change_app_package_name:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: change_app_package_name
|
||||
sha256: "8e43b754fe960426904d77ed4c62fa8c9834deaf6e293ae40963fa447482c4c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -265,6 +273,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -467,6 +483,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.21.3+1"
|
||||
flutter_html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_html
|
||||
sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -655,6 +679,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -828,6 +860,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
list_counter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: list_counter
|
||||
sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -73,11 +73,13 @@ dependencies:
|
||||
shared_preferences: ^2.2.3
|
||||
flutter_secure_storage: ^9.2.4
|
||||
|
||||
|
||||
# Navigation
|
||||
go_router: ^14.6.2
|
||||
|
||||
# Icons
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_html: ^3.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -94,6 +96,7 @@ dev_dependencies:
|
||||
|
||||
# Linting
|
||||
flutter_lints: ^5.0.0
|
||||
change_app_package_name: ^1.5.0
|
||||
|
||||
# Testing
|
||||
mockito: ^5.4.4
|
||||
|
||||
Reference in New Issue
Block a user