Compare commits
4 Commits
5e9b0cb562
...
88ac2f2f07
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88ac2f2f07 | ||
|
|
a07f165f0c | ||
|
|
3741239d83 | ||
|
|
7ef12fa83a |
22
docs/price.sh
Normal file
22
docs/price.sh
Normal file
@@ -0,0 +1,22 @@
|
||||
#get price list
|
||||
curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.pricing.get_pricing_info' \
|
||||
--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \
|
||||
--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"pricing_type" : "PRICE_LIST",
|
||||
"limit_page_length" : 0,
|
||||
"limit_start" : 0
|
||||
}'
|
||||
//note: PRICING_RULE = Chính sách giá,PRICE_LIST= bảng giá
|
||||
|
||||
#response
|
||||
{
|
||||
"message": [
|
||||
{
|
||||
"title": "EUROTILE",
|
||||
"file_url": "https://land.dbiz.com/private/files/City.xlsx",
|
||||
"updated_at": "2025-11-26 11:36:43"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,12 +46,22 @@
|
||||
<label class="form-label">Đơn vị thiết kế</label>
|
||||
<input type="text" class="form-input" id="designUnit" placeholder="Tên đơn vị thiết kế">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Đơn vị thi công</label>
|
||||
<input type="text" class="form-input" id="designUnit" placeholder="Tên đơn vị thi công">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Details -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">Chi tiết dự án</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Tổng diện tích<span class="text-red-500">*</span></label>
|
||||
<input type="text" class="form-input" id="projectOwner" placeholder="Nhập diện tích m²" required>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Sản phẩm đưa vào thiết kế <span class="text-red-500">*</span></label>
|
||||
<textarea class="form-input" id="projectProducts" rows="4" placeholder="Liệt kê các sản phẩm gạch đã sử dụng trong công trình (tên sản phẩm, mã SP, số lượng...)" required></textarea>
|
||||
@@ -425,7 +435,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// function resetForm() {
|
||||
function resetForm() {
|
||||
if (confirm('Bạn có chắc muốn nhập lại toàn bộ thông tin?')) {
|
||||
document.getElementById('projectForm').reset();
|
||||
uploadedFiles = [];
|
||||
|
||||
@@ -94,6 +94,8 @@ PODS:
|
||||
- nanopb/encode (= 2.30910.0)
|
||||
- nanopb/decode (2.30910.0)
|
||||
- nanopb/encode (2.30910.0)
|
||||
- open_file_ios (0.0.1):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
@@ -121,6 +123,7 @@ DEPENDENCIES:
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
@@ -161,6 +164,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||
open_file_ios:
|
||||
:path: ".symlinks/plugins/open_file_ios/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
share_plus:
|
||||
@@ -193,6 +198,7 @@ SPEC CHECKSUMS:
|
||||
MLKitVision: e858c5f125ecc288e4a31127928301eaba9ae0c1
|
||||
mobile_scanner: 96e91f2e1fb396bb7df8da40429ba8dfad664740
|
||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||
open_file_ios: 461db5853723763573e140de3193656f91990d9e
|
||||
path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
SDWebImage: 16309af6d214ba3f77a7c6f6fdda888cb313a50a
|
||||
|
||||
@@ -27,6 +27,7 @@ import 'package:worker/features/chat/presentation/pages/chat_list_page.dart';
|
||||
import 'package:worker/features/favorites/presentation/pages/favorites_page.dart';
|
||||
import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart';
|
||||
import 'package:worker/features/loyalty/presentation/pages/points_history_page.dart';
|
||||
import 'package:worker/features/loyalty/presentation/pages/points_records_page.dart';
|
||||
import 'package:worker/features/loyalty/presentation/pages/rewards_page.dart';
|
||||
import 'package:worker/features/main/presentation/pages/main_scaffold.dart';
|
||||
import 'package:worker/features/news/presentation/pages/news_detail_page.dart';
|
||||
@@ -41,10 +42,13 @@ import 'package:worker/features/price_policy/price_policy.dart';
|
||||
import 'package:worker/features/products/presentation/pages/product_detail_page.dart';
|
||||
import 'package:worker/features/products/presentation/pages/products_page.dart';
|
||||
import 'package:worker/features/products/presentation/pages/write_review_page.dart';
|
||||
import 'package:worker/features/projects/presentation/pages/submission_create_page.dart';
|
||||
import 'package:worker/features/projects/presentation/pages/submissions_page.dart';
|
||||
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
|
||||
import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
|
||||
import 'package:worker/features/showrooms/presentation/pages/design_request_create_page.dart';
|
||||
import 'package:worker/features/showrooms/presentation/pages/design_request_detail_page.dart';
|
||||
import 'package:worker/features/showrooms/presentation/pages/model_house_detail_page.dart';
|
||||
import 'package:worker/features/showrooms/presentation/pages/model_houses_page.dart';
|
||||
|
||||
/// Router Provider
|
||||
@@ -271,6 +275,14 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
MaterialPage(key: state.pageKey, child: const PointsHistoryPage()),
|
||||
),
|
||||
|
||||
// Points Records Route
|
||||
GoRoute(
|
||||
path: RouteNames.pointsRecords,
|
||||
name: 'loyalty_points_records',
|
||||
pageBuilder: (context, state) =>
|
||||
MaterialPage(key: state.pageKey, child: const PointsRecordsPage()),
|
||||
),
|
||||
|
||||
// Orders Route
|
||||
GoRoute(
|
||||
path: RouteNames.orders,
|
||||
@@ -354,6 +366,22 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
},
|
||||
),
|
||||
|
||||
// Submissions Route
|
||||
GoRoute(
|
||||
path: RouteNames.submissions,
|
||||
name: RouteNames.submissions,
|
||||
pageBuilder: (context, state) =>
|
||||
MaterialPage(key: state.pageKey, child: const SubmissionsPage()),
|
||||
),
|
||||
|
||||
// Submission Create Route
|
||||
GoRoute(
|
||||
path: RouteNames.submissionCreate,
|
||||
name: RouteNames.submissionCreate,
|
||||
pageBuilder: (context, state) =>
|
||||
MaterialPage(key: state.pageKey, child: const SubmissionCreatePage()),
|
||||
),
|
||||
|
||||
// Quotes Route
|
||||
GoRoute(
|
||||
path: RouteNames.quotes,
|
||||
@@ -449,6 +477,19 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
MaterialPage(key: state.pageKey, child: const ModelHousesPage()),
|
||||
),
|
||||
|
||||
// Model House Detail Route
|
||||
GoRoute(
|
||||
path: RouteNames.modelHouseDetail,
|
||||
name: RouteNames.modelHouseDetail,
|
||||
pageBuilder: (context, state) {
|
||||
final modelId = state.pathParameters['id'];
|
||||
return MaterialPage(
|
||||
key: state.pageKey,
|
||||
child: ModelHouseDetailPage(modelId: modelId ?? ''),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Design Request Create Route
|
||||
GoRoute(
|
||||
path: RouteNames.designRequestCreate,
|
||||
@@ -529,7 +570,7 @@ class RouteNames {
|
||||
// Main Routes
|
||||
static const String home = '/';
|
||||
static const String products = '/products';
|
||||
static const String productDetail = '/products/:id';
|
||||
static const String productDetail = '$products/:id';
|
||||
static const String writeReview = 'write-review';
|
||||
static const String cart = '/cart';
|
||||
static const String favorites = '/favorites';
|
||||
@@ -538,37 +579,40 @@ class RouteNames {
|
||||
|
||||
// Loyalty Routes
|
||||
static const String loyalty = '/loyalty';
|
||||
static const String rewards = '/loyalty/rewards';
|
||||
static const String pointsHistory = '/loyalty/points-history';
|
||||
static const String myGifts = '/loyalty/gifts';
|
||||
static const String referral = '/loyalty/referral';
|
||||
static const String rewards = '$loyalty/rewards';
|
||||
static const String pointsHistory = '$loyalty/points-history';
|
||||
static const String pointsRecords = '$loyalty/points-records';
|
||||
static const String myGifts = '$loyalty/gifts';
|
||||
static const String referral = '$loyalty/referral';
|
||||
|
||||
// Orders & Payments Routes
|
||||
static const String orders = '/orders';
|
||||
static const String orderDetail = '/orders/:id';
|
||||
static const String orderDetail = '$orders/:id';
|
||||
static const String payments = '/payments';
|
||||
static const String paymentDetail = '/payments/:id';
|
||||
static const String paymentDetail = '$payments/:id';
|
||||
static const String paymentQr = '/payment-qr';
|
||||
|
||||
// Projects & Quotes Routes
|
||||
static const String projects = '/projects';
|
||||
static const String projectDetail = '/projects/:id';
|
||||
static const String projectCreate = '/projects/create';
|
||||
static const String projectDetail = '$projects/:id';
|
||||
static const String projectCreate = '$projects/create';
|
||||
static const String submissions = '/submissions';
|
||||
static const String submissionCreate = '$submissions/create';
|
||||
static const String quotes = '/quotes';
|
||||
static const String quoteDetail = '/quotes/:id';
|
||||
static const String quoteCreate = '/quotes/create';
|
||||
static const String quoteDetail = '$quotes/:id';
|
||||
static const String quoteCreate = '$quotes/create';
|
||||
|
||||
// Account Routes
|
||||
static const String account = '/account';
|
||||
static const String profile = '/account/profile';
|
||||
static const String addresses = '/account/addresses';
|
||||
static const String addressForm = '/account/addresses/form';
|
||||
static const String changePassword = '/account/change-password';
|
||||
static const String settings = '/account/settings';
|
||||
static const String profile = '$account/profile';
|
||||
static const String addresses = '$account/addresses';
|
||||
static const String addressForm = '$addresses/form';
|
||||
static const String changePassword = '$account/change-password';
|
||||
static const String settings = '$account/settings';
|
||||
|
||||
// Promotions & Notifications Routes
|
||||
static const String promotions = '/promotions';
|
||||
static const String promotionDetail = '/promotions/:id';
|
||||
static const String promotionDetail = '$promotions/:id';
|
||||
static const String notifications = '/notifications';
|
||||
|
||||
// Price Policy Route
|
||||
@@ -576,16 +620,16 @@ class RouteNames {
|
||||
|
||||
// News Route
|
||||
static const String news = '/news';
|
||||
static const String newsDetail = '/news/:id';
|
||||
static const String newsDetail = '$news/:id';
|
||||
|
||||
// Chat Route
|
||||
static const String chat = '/chat';
|
||||
|
||||
// Model Houses & Design Requests Routes
|
||||
static const String modelHouses = '/model-houses';
|
||||
static const String designRequestCreate =
|
||||
'/model-houses/design-request/create';
|
||||
static const String designRequestDetail = '/model-houses/design-request/:id';
|
||||
static const String modelHouseDetail = '$modelHouses/:id';
|
||||
static const String designRequestCreate = '$modelHouses/design-request/create';
|
||||
static const String designRequestDetail = '$modelHouses/design-request/:id';
|
||||
|
||||
// Authentication Routes
|
||||
static const String splash = '/splash';
|
||||
|
||||
@@ -197,8 +197,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
QuickAction(
|
||||
icon: FontAwesomeIcons.circlePlus,
|
||||
label: 'Ghi nhận điểm',
|
||||
onTap: () =>
|
||||
_showComingSoon(context, 'Ghi nhận điểm', l10n),
|
||||
onTap: () => context.push(RouteNames.pointsRecords),
|
||||
),
|
||||
QuickAction(
|
||||
icon: FontAwesomeIcons.gift,
|
||||
@@ -225,8 +224,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
QuickAction(
|
||||
icon: FontAwesomeIcons.building,
|
||||
label: 'Đăng ký dự án',
|
||||
onTap: () =>
|
||||
_showComingSoon(context, 'Đăng ký dự án', l10n),
|
||||
onTap: () => context.push(RouteNames.submissions),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/// Remote Data Source: Points Record
|
||||
///
|
||||
/// Handles API communication for points records.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/loyalty/domain/entities/points_record.dart';
|
||||
|
||||
/// Points Record Remote Data Source Interface
|
||||
abstract class PointsRecordRemoteDataSource {
|
||||
/// Get all points records for current user
|
||||
Future<List<PointsRecord>> getPointsRecords();
|
||||
|
||||
/// Get single points record by ID
|
||||
Future<PointsRecord> getPointsRecordById(String recordId);
|
||||
|
||||
/// Submit new points record
|
||||
Future<PointsRecord> submitPointsRecord(PointsRecord record);
|
||||
}
|
||||
|
||||
/// Points Record Remote Data Source Implementation (Mock)
|
||||
class PointsRecordRemoteDataSourceImpl
|
||||
implements PointsRecordRemoteDataSource {
|
||||
@override
|
||||
Future<List<PointsRecord>> getPointsRecords() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
return [
|
||||
PointsRecord(
|
||||
recordId: 'PRR001',
|
||||
userId: 'user123',
|
||||
invoiceNumber: 'INV-VG-001',
|
||||
storeName: 'Công ty TNHH Vingroup',
|
||||
transactionDate: DateTime(2023, 11, 15),
|
||||
invoiceAmount: 2500000,
|
||||
notes: 'Gạch granite cao cấp cho khu vực lobby và hành lang',
|
||||
attachments: const [
|
||||
'https://example.com/invoice1.jpg',
|
||||
'https://example.com/invoice2.jpg',
|
||||
],
|
||||
status: PointsStatus.approved,
|
||||
pointsEarned: 250,
|
||||
submittedAt: DateTime(2023, 11, 15, 10, 0),
|
||||
processedAt: DateTime(2023, 11, 20, 14, 30),
|
||||
processedBy: 'admin001',
|
||||
),
|
||||
PointsRecord(
|
||||
recordId: 'PRR002',
|
||||
userId: 'user123',
|
||||
invoiceNumber: 'INV-BTX-002',
|
||||
storeName: 'Tập đoàn Bitexco',
|
||||
transactionDate: DateTime(2023, 11, 25),
|
||||
invoiceAmount: 1250000,
|
||||
notes: 'Gạch porcelain 80x80 cho sảnh chính và khu mua sắm',
|
||||
attachments: const [
|
||||
'https://example.com/invoice3.jpg',
|
||||
],
|
||||
status: PointsStatus.pending,
|
||||
submittedAt: DateTime(2023, 11, 25, 9, 15),
|
||||
),
|
||||
PointsRecord(
|
||||
recordId: 'PRR003',
|
||||
userId: 'user123',
|
||||
invoiceNumber: 'INV-ABC-003',
|
||||
storeName: 'Công ty TNHH ABC Manufacturing',
|
||||
transactionDate: DateTime(2023, 11, 20),
|
||||
invoiceAmount: 4200000,
|
||||
notes: 'Gạch chống trơn cho khu vực sản xuất và kho bãi',
|
||||
attachments: const [
|
||||
'https://example.com/invoice4.jpg',
|
||||
'https://example.com/invoice5.jpg',
|
||||
],
|
||||
status: PointsStatus.rejected,
|
||||
rejectReason: 'Hình ảnh minh chứng không hợp lệ',
|
||||
submittedAt: DateTime(2023, 11, 20, 11, 0),
|
||||
processedAt: DateTime(2023, 11, 28, 16, 45),
|
||||
processedBy: 'admin002',
|
||||
),
|
||||
PointsRecord(
|
||||
recordId: 'PRR004',
|
||||
userId: 'user123',
|
||||
invoiceNumber: 'INV-ECO-004',
|
||||
storeName: 'Ecopark Group',
|
||||
transactionDate: DateTime(2023, 10, 10),
|
||||
invoiceAmount: 3700000,
|
||||
notes: 'Gạch ceramic vân gỗ cho khu vực phòng khách và sân vườn',
|
||||
attachments: const [
|
||||
'https://example.com/invoice6.jpg',
|
||||
],
|
||||
status: PointsStatus.approved,
|
||||
pointsEarned: 370,
|
||||
submittedAt: DateTime(2023, 10, 10, 8, 30),
|
||||
processedAt: DateTime(2023, 10, 15, 10, 20),
|
||||
processedBy: 'admin001',
|
||||
),
|
||||
PointsRecord(
|
||||
recordId: 'PRR005',
|
||||
userId: 'user123',
|
||||
invoiceNumber: 'INV-DMD-005',
|
||||
storeName: 'Diamond Hospitality Group',
|
||||
transactionDate: DateTime(2023, 12, 1),
|
||||
invoiceAmount: 8600000,
|
||||
notes: 'Gạch marble tự nhiên cho lobby và phòng suite',
|
||||
attachments: const [
|
||||
'https://example.com/invoice7.jpg',
|
||||
'https://example.com/invoice8.jpg',
|
||||
'https://example.com/invoice9.jpg',
|
||||
],
|
||||
status: PointsStatus.pending,
|
||||
submittedAt: DateTime(2023, 12, 1, 13, 0),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PointsRecord> getPointsRecordById(String recordId) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
final records = await getPointsRecords();
|
||||
return records.firstWhere(
|
||||
(record) => record.recordId == recordId,
|
||||
orElse: () => throw Exception('Points record not found'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PointsRecord> submitPointsRecord(PointsRecord record) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
// Simulate successful submission
|
||||
return record.copyWith(
|
||||
recordId: 'PRR${DateTime.now().millisecondsSinceEpoch}',
|
||||
status: PointsStatus.pending,
|
||||
submittedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/// Repository Implementation: Points Record
|
||||
///
|
||||
/// Implements points record repository interface.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/loyalty/data/datasources/points_record_remote_datasource.dart';
|
||||
import 'package:worker/features/loyalty/domain/entities/points_record.dart';
|
||||
import 'package:worker/features/loyalty/domain/repositories/points_record_repository.dart';
|
||||
|
||||
/// Points Record Repository Implementation
|
||||
class PointsRecordRepositoryImpl implements PointsRecordRepository {
|
||||
const PointsRecordRepositoryImpl(this._remoteDataSource);
|
||||
|
||||
final PointsRecordRemoteDataSource _remoteDataSource;
|
||||
|
||||
@override
|
||||
Future<List<PointsRecord>> getPointsRecords() async {
|
||||
try {
|
||||
return await _remoteDataSource.getPointsRecords();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PointsRecord> getPointsRecordById(String recordId) async {
|
||||
try {
|
||||
return await _remoteDataSource.getPointsRecordById(recordId);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PointsRecord> submitPointsRecord(PointsRecord record) async {
|
||||
try {
|
||||
return await _remoteDataSource.submitPointsRecord(record);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,11 +18,11 @@ enum PointsStatus {
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case PointsStatus.pending:
|
||||
return 'Pending';
|
||||
return 'Chờ duyệt';
|
||||
case PointsStatus.approved:
|
||||
return 'Approved';
|
||||
return 'Đã duyệt';
|
||||
case PointsStatus.rejected:
|
||||
return 'Rejected';
|
||||
return 'Bị từ chối';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/// Repository Interface: Points Record
|
||||
///
|
||||
/// Defines contract for points record operations.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/loyalty/domain/entities/points_record.dart';
|
||||
|
||||
/// Points Record Repository Interface
|
||||
abstract class PointsRecordRepository {
|
||||
/// Get all points records for current user
|
||||
Future<List<PointsRecord>> getPointsRecords();
|
||||
|
||||
/// Get single points record by ID
|
||||
Future<PointsRecord> getPointsRecordById(String recordId);
|
||||
|
||||
/// Submit new points record
|
||||
Future<PointsRecord> submitPointsRecord(PointsRecord record);
|
||||
}
|
||||
19
lib/features/loyalty/domain/usecases/get_points_records.dart
Normal file
19
lib/features/loyalty/domain/usecases/get_points_records.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
/// Use Case: Get Points Records
|
||||
///
|
||||
/// Retrieves all points records for the current user.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/loyalty/domain/entities/points_record.dart';
|
||||
import 'package:worker/features/loyalty/domain/repositories/points_record_repository.dart';
|
||||
|
||||
/// Get Points Records Use Case
|
||||
class GetPointsRecords {
|
||||
const GetPointsRecords(this._repository);
|
||||
|
||||
final PointsRecordRepository _repository;
|
||||
|
||||
/// Execute use case
|
||||
Future<List<PointsRecord>> call() async {
|
||||
return await _repository.getPointsRecords();
|
||||
}
|
||||
}
|
||||
386
lib/features/loyalty/presentation/pages/points_records_page.dart
Normal file
386
lib/features/loyalty/presentation/pages/points_records_page.dart
Normal file
@@ -0,0 +1,386 @@
|
||||
/// Page: Points Records List
|
||||
///
|
||||
/// Displays list of user's points records with filters.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/loyalty/domain/entities/points_record.dart';
|
||||
import 'package:worker/features/loyalty/presentation/providers/points_records_provider.dart';
|
||||
|
||||
/// Points Records Page
|
||||
class PointsRecordsPage extends ConsumerWidget {
|
||||
const PointsRecordsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final recordsAsync = ref.watch(filteredPointsRecordsProvider);
|
||||
final filter = ref.watch(pointsRecordsFilterProvider);
|
||||
final selectedStatus = filter.selectedStatus;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.arrowLeft,
|
||||
color: Colors.black,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Danh sách Ghi nhận điểm',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.plus,
|
||||
color: Colors.black,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
// TODO: Navigate to points record create page
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Tính năng tạo ghi nhận điểm sẽ được cập nhật'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
centerTitle: false,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search Bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Mã yêu cầu',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.grey500),
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.grey100),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.grey100),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(pointsRecordsFilterProvider.notifier).updateSearchQuery(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Status Filter Tabs
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip(
|
||||
context,
|
||||
ref,
|
||||
label: 'Tất cả',
|
||||
isSelected: selectedStatus == null,
|
||||
onTap: () =>
|
||||
ref.read(pointsRecordsFilterProvider.notifier).clearStatusFilter(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
...PointsStatus.values.map(
|
||||
(status) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildFilterChip(
|
||||
context,
|
||||
ref,
|
||||
label: status.displayName,
|
||||
isSelected: selectedStatus == status,
|
||||
onTap: () =>
|
||||
ref.read(pointsRecordsFilterProvider.notifier).selectStatus(status),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Points Records List
|
||||
Expanded(
|
||||
child: recordsAsync.when(
|
||||
data: (records) {
|
||||
if (records.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(allPointsRecordsProvider.notifier).refresh();
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FaIcon(
|
||||
FontAwesomeIcons.folderOpen,
|
||||
size: 64,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Không có ghi nhận điểm nào',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Không tìm thấy ghi nhận điểm phù hợp',
|
||||
style: TextStyle(color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(allPointsRecordsProvider.notifier).refresh();
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: records.length,
|
||||
itemBuilder: (context, index) {
|
||||
final record = records[index];
|
||||
return _buildRecordCard(context, record);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, stack) => RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(allPointsRecordsProvider.notifier).refresh();
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Có lỗi xảy ra',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Kéo xuống để thử lại',
|
||||
style: TextStyle(color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String label,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.primaryBlue : AppColors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppColors.primaryBlue : AppColors.grey100,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? AppColors.white : AppColors.grey900,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordCard(BuildContext context, PointsRecord record) {
|
||||
final currencyFormat = NumberFormat.currency(
|
||||
locale: 'vi_VN',
|
||||
symbol: '₫',
|
||||
decimalDigits: 0,
|
||||
);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// TODO: Navigate to points record detail
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Chi tiết ghi nhận ${record.recordId}')),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'#${record.recordId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
_buildStatusBadge(record.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Ngày gửi: ${DateFormat('dd/MM/yyyy').format(record.submittedAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Giá trị đơn hàng: ${currencyFormat.format(record.invoiceAmount)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
if (record.rejectReason != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF2F2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const FaIcon(
|
||||
FontAwesomeIcons.triangleExclamation,
|
||||
size: 14,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
record.rejectReason!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge(PointsStatus status) {
|
||||
final color = _getStatusColor(status);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
status.displayName,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getStatusColor(PointsStatus status) {
|
||||
switch (status) {
|
||||
case PointsStatus.pending:
|
||||
return AppColors.warning;
|
||||
case PointsStatus.approved:
|
||||
return AppColors.success;
|
||||
case PointsStatus.rejected:
|
||||
return AppColors.danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/// Providers: Points Records
|
||||
///
|
||||
/// State management for points records feature.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/loyalty/data/datasources/points_record_remote_datasource.dart';
|
||||
import 'package:worker/features/loyalty/data/repositories/points_record_repository_impl.dart';
|
||||
import 'package:worker/features/loyalty/domain/entities/points_record.dart';
|
||||
import 'package:worker/features/loyalty/domain/repositories/points_record_repository.dart';
|
||||
import 'package:worker/features/loyalty/domain/usecases/get_points_records.dart';
|
||||
|
||||
part 'points_records_provider.g.dart';
|
||||
|
||||
// ============================================================================
|
||||
// Data Layer Providers
|
||||
// ============================================================================
|
||||
|
||||
/// Points Record Remote Data Source Provider
|
||||
@riverpod
|
||||
PointsRecordRemoteDataSource pointsRecordRemoteDataSource(Ref ref) {
|
||||
return PointsRecordRemoteDataSourceImpl();
|
||||
}
|
||||
|
||||
/// Points Record Repository Provider
|
||||
@riverpod
|
||||
PointsRecordRepository pointsRecordRepository(Ref ref) {
|
||||
final remoteDataSource = ref.watch(pointsRecordRemoteDataSourceProvider);
|
||||
return PointsRecordRepositoryImpl(remoteDataSource);
|
||||
}
|
||||
|
||||
/// Get Points Records Use Case Provider
|
||||
@riverpod
|
||||
GetPointsRecords getPointsRecords(Ref ref) {
|
||||
final repository = ref.watch(pointsRecordRepositoryProvider);
|
||||
return GetPointsRecords(repository);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Presentation Layer Providers
|
||||
// ============================================================================
|
||||
|
||||
/// All Points Records Provider (AsyncNotifier)
|
||||
@riverpod
|
||||
class AllPointsRecords extends _$AllPointsRecords {
|
||||
@override
|
||||
Future<List<PointsRecord>> build() async {
|
||||
final useCase = ref.watch(getPointsRecordsProvider);
|
||||
return await useCase();
|
||||
}
|
||||
|
||||
/// Refresh points records
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final useCase = ref.read(getPointsRecordsProvider);
|
||||
return await useCase();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Points Records Filter State Provider
|
||||
@riverpod
|
||||
class PointsRecordsFilter extends _$PointsRecordsFilter {
|
||||
@override
|
||||
({String searchQuery, PointsStatus? selectedStatus}) build() {
|
||||
return (searchQuery: '', selectedStatus: null);
|
||||
}
|
||||
|
||||
/// Update search query
|
||||
void updateSearchQuery(String query) {
|
||||
state = (searchQuery: query, selectedStatus: state.selectedStatus);
|
||||
}
|
||||
|
||||
/// Select status filter
|
||||
void selectStatus(PointsStatus? status) {
|
||||
state = (searchQuery: state.searchQuery, selectedStatus: status);
|
||||
}
|
||||
|
||||
/// Clear status filter
|
||||
void clearStatusFilter() {
|
||||
state = (searchQuery: state.searchQuery, selectedStatus: null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered Points Records Provider
|
||||
@riverpod
|
||||
AsyncValue<List<PointsRecord>> filteredPointsRecords(Ref ref) {
|
||||
final dataAsync = ref.watch(allPointsRecordsProvider);
|
||||
final filter = ref.watch(pointsRecordsFilterProvider);
|
||||
|
||||
return dataAsync.whenData((records) {
|
||||
var filtered = records;
|
||||
|
||||
// Apply status filter
|
||||
if (filter.selectedStatus != null) {
|
||||
filtered = filtered
|
||||
.where((record) => record.status == filter.selectedStatus)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (filter.searchQuery.isNotEmpty) {
|
||||
final query = filter.searchQuery.toLowerCase();
|
||||
filtered = filtered.where((record) {
|
||||
final idMatch = record.recordId.toLowerCase().contains(query);
|
||||
final invoiceMatch = record.invoiceNumber.toLowerCase().contains(query);
|
||||
final storeMatch = record.storeName.toLowerCase().contains(query);
|
||||
return idMatch || invoiceMatch || storeMatch;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Sort by submission date (newest first)
|
||||
filtered.sort((a, b) => b.submittedAt.compareTo(a.submittedAt));
|
||||
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'points_records_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Points Record Remote Data Source Provider
|
||||
|
||||
@ProviderFor(pointsRecordRemoteDataSource)
|
||||
const pointsRecordRemoteDataSourceProvider =
|
||||
PointsRecordRemoteDataSourceProvider._();
|
||||
|
||||
/// Points Record Remote Data Source Provider
|
||||
|
||||
final class PointsRecordRemoteDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
PointsRecordRemoteDataSource,
|
||||
PointsRecordRemoteDataSource,
|
||||
PointsRecordRemoteDataSource
|
||||
>
|
||||
with $Provider<PointsRecordRemoteDataSource> {
|
||||
/// Points Record Remote Data Source Provider
|
||||
const PointsRecordRemoteDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'pointsRecordRemoteDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$pointsRecordRemoteDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<PointsRecordRemoteDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
PointsRecordRemoteDataSource create(Ref ref) {
|
||||
return pointsRecordRemoteDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(PointsRecordRemoteDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<PointsRecordRemoteDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$pointsRecordRemoteDataSourceHash() =>
|
||||
r'b0a68a4bb8f05281afa84885a916611c131e172a';
|
||||
|
||||
/// Points Record Repository Provider
|
||||
|
||||
@ProviderFor(pointsRecordRepository)
|
||||
const pointsRecordRepositoryProvider = PointsRecordRepositoryProvider._();
|
||||
|
||||
/// Points Record Repository Provider
|
||||
|
||||
final class PointsRecordRepositoryProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
PointsRecordRepository,
|
||||
PointsRecordRepository,
|
||||
PointsRecordRepository
|
||||
>
|
||||
with $Provider<PointsRecordRepository> {
|
||||
/// Points Record Repository Provider
|
||||
const PointsRecordRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'pointsRecordRepositoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$pointsRecordRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<PointsRecordRepository> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
PointsRecordRepository create(Ref ref) {
|
||||
return pointsRecordRepository(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(PointsRecordRepository value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<PointsRecordRepository>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$pointsRecordRepositoryHash() =>
|
||||
r'892ad8ca77bd5c59dba2e285163ef5e016c0ca0d';
|
||||
|
||||
/// Get Points Records Use Case Provider
|
||||
|
||||
@ProviderFor(getPointsRecords)
|
||||
const getPointsRecordsProvider = GetPointsRecordsProvider._();
|
||||
|
||||
/// Get Points Records Use Case Provider
|
||||
|
||||
final class GetPointsRecordsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
GetPointsRecords,
|
||||
GetPointsRecords,
|
||||
GetPointsRecords
|
||||
>
|
||||
with $Provider<GetPointsRecords> {
|
||||
/// Get Points Records Use Case Provider
|
||||
const GetPointsRecordsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'getPointsRecordsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$getPointsRecordsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<GetPointsRecords> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
GetPointsRecords create(Ref ref) {
|
||||
return getPointsRecords(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(GetPointsRecords value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<GetPointsRecords>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$getPointsRecordsHash() => r'f7ea3c5c9675878967cc34b18416adb3a665ccf8';
|
||||
|
||||
/// All Points Records Provider (AsyncNotifier)
|
||||
|
||||
@ProviderFor(AllPointsRecords)
|
||||
const allPointsRecordsProvider = AllPointsRecordsProvider._();
|
||||
|
||||
/// All Points Records Provider (AsyncNotifier)
|
||||
final class AllPointsRecordsProvider
|
||||
extends $AsyncNotifierProvider<AllPointsRecords, List<PointsRecord>> {
|
||||
/// All Points Records Provider (AsyncNotifier)
|
||||
const AllPointsRecordsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'allPointsRecordsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$allPointsRecordsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AllPointsRecords create() => AllPointsRecords();
|
||||
}
|
||||
|
||||
String _$allPointsRecordsHash() => r'cd64b6952f9abfe1142773b4b88a051b74e8d763';
|
||||
|
||||
/// All Points Records Provider (AsyncNotifier)
|
||||
|
||||
abstract class _$AllPointsRecords extends $AsyncNotifier<List<PointsRecord>> {
|
||||
FutureOr<List<PointsRecord>> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<List<PointsRecord>>, List<PointsRecord>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<List<PointsRecord>>, List<PointsRecord>>,
|
||||
AsyncValue<List<PointsRecord>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Points Records Filter State Provider
|
||||
|
||||
@ProviderFor(PointsRecordsFilter)
|
||||
const pointsRecordsFilterProvider = PointsRecordsFilterProvider._();
|
||||
|
||||
/// Points Records Filter State Provider
|
||||
final class PointsRecordsFilterProvider
|
||||
extends
|
||||
$NotifierProvider<
|
||||
PointsRecordsFilter,
|
||||
({String searchQuery, PointsStatus? selectedStatus})
|
||||
> {
|
||||
/// Points Records Filter State Provider
|
||||
const PointsRecordsFilterProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'pointsRecordsFilterProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$pointsRecordsFilterHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
PointsRecordsFilter create() => PointsRecordsFilter();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(
|
||||
({String searchQuery, PointsStatus? selectedStatus}) value,
|
||||
) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride:
|
||||
$SyncValueProvider<
|
||||
({String searchQuery, PointsStatus? selectedStatus})
|
||||
>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$pointsRecordsFilterHash() =>
|
||||
r'ae81040dff096894330a2a744959190545435c48';
|
||||
|
||||
/// Points Records Filter State Provider
|
||||
|
||||
abstract class _$PointsRecordsFilter
|
||||
extends $Notifier<({String searchQuery, PointsStatus? selectedStatus})> {
|
||||
({String searchQuery, PointsStatus? selectedStatus}) build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<
|
||||
({String searchQuery, PointsStatus? selectedStatus}),
|
||||
({String searchQuery, PointsStatus? selectedStatus})
|
||||
>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
({String searchQuery, PointsStatus? selectedStatus}),
|
||||
({String searchQuery, PointsStatus? selectedStatus})
|
||||
>,
|
||||
({String searchQuery, PointsStatus? selectedStatus}),
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered Points Records Provider
|
||||
|
||||
@ProviderFor(filteredPointsRecords)
|
||||
const filteredPointsRecordsProvider = FilteredPointsRecordsProvider._();
|
||||
|
||||
/// Filtered Points Records Provider
|
||||
|
||||
final class FilteredPointsRecordsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<PointsRecord>>,
|
||||
AsyncValue<List<PointsRecord>>,
|
||||
AsyncValue<List<PointsRecord>>
|
||||
>
|
||||
with $Provider<AsyncValue<List<PointsRecord>>> {
|
||||
/// Filtered Points Records Provider
|
||||
const FilteredPointsRecordsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'filteredPointsRecordsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$filteredPointsRecordsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AsyncValue<List<PointsRecord>>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AsyncValue<List<PointsRecord>> create(Ref ref) {
|
||||
return filteredPointsRecords(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AsyncValue<List<PointsRecord>> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AsyncValue<List<PointsRecord>>>(
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$filteredPointsRecordsHash() =>
|
||||
r'afb0691b799f053b5c7fff2f8b64065917b5cd33';
|
||||
@@ -11,6 +11,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/database/models/enums.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/core/utils/extensions.dart';
|
||||
import 'package:worker/features/orders/presentation/providers/invoices_provider.dart';
|
||||
|
||||
/// Payment Detail Page
|
||||
@@ -65,16 +66,12 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
orElse: () => invoices.first,
|
||||
);
|
||||
|
||||
final currencyFormatter = NumberFormat.currency(
|
||||
locale: 'vi_VN',
|
||||
symbol: 'đ',
|
||||
decimalDigits: 0,
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 12,
|
||||
children: [
|
||||
// Invoice Header Card
|
||||
_buildInvoiceHeader(
|
||||
@@ -82,13 +79,11 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
invoice.orderId,
|
||||
invoice.issueDate,
|
||||
invoice.status,
|
||||
currencyFormatter,
|
||||
invoice.totalAmount,
|
||||
invoice.amountPaid,
|
||||
invoice.amountRemaining,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Dates and Customer Info Card
|
||||
_buildCustomerInfo(
|
||||
@@ -97,26 +92,21 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
invoice.isOverdue,
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Product List Card
|
||||
_buildProductList(),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Payment History Card
|
||||
_buildPaymentHistory(
|
||||
invoice.amountPaid,
|
||||
invoice.issueDate,
|
||||
currencyFormatter,
|
||||
invoice.issueDate
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Download Section Card
|
||||
_buildDownloadSection(invoice.invoiceNumber),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Support Button
|
||||
Container(
|
||||
@@ -148,7 +138,6 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Payment Button
|
||||
Container(
|
||||
@@ -195,7 +184,6 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -236,7 +224,6 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
String? orderId,
|
||||
DateTime issueDate,
|
||||
InvoiceStatus status,
|
||||
NumberFormat currencyFormatter,
|
||||
double totalAmount,
|
||||
double amountPaid,
|
||||
double amountRemaining,
|
||||
@@ -289,12 +276,12 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
children: [
|
||||
_buildSummaryRow(
|
||||
'Tổng tiền hóa đơn:',
|
||||
currencyFormatter.format(totalAmount),
|
||||
totalAmount.toVNCurrency,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildSummaryRow(
|
||||
'Đã thanh toán:',
|
||||
currencyFormatter.format(amountPaid),
|
||||
amountPaid.toVNCurrency,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
@@ -302,7 +289,7 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
),
|
||||
_buildSummaryRow(
|
||||
'Còn lại:',
|
||||
currencyFormatter.format(amountRemaining),
|
||||
amountRemaining.toVNCurrency,
|
||||
isHighlighted: true,
|
||||
valueColor: amountRemaining > 0
|
||||
? AppColors.danger
|
||||
@@ -560,7 +547,6 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
Widget _buildPaymentHistory(
|
||||
double amountPaid,
|
||||
DateTime paymentDate,
|
||||
NumberFormat currencyFormatter,
|
||||
) {
|
||||
final hasHistory = amountPaid > 0;
|
||||
|
||||
@@ -572,11 +558,11 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
const Row(
|
||||
children: [
|
||||
FaIcon(FontAwesomeIcons.clockRotateLeft, color: AppColors.primaryBlue, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Lịch sử thanh toán',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
@@ -604,10 +590,12 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
color: AppColors.success.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const FaIcon(
|
||||
FontAwesomeIcons.check,
|
||||
color: AppColors.success,
|
||||
size: 18,
|
||||
child: const Center(
|
||||
child: FaIcon(
|
||||
FontAwesomeIcons.check,
|
||||
color: AppColors.success,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -643,7 +631,7 @@ class PaymentDetailPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
currencyFormatter.format(amountPaid),
|
||||
amountPaid.toVNCurrency,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// Page: Payments Page
|
||||
///
|
||||
/// Displays list of invoices/payments with tab filters.
|
||||
/// Displays list of invoices/payments.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/database/models/enums.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/orders/data/models/invoice_model.dart';
|
||||
import 'package:worker/features/orders/presentation/providers/invoices_provider.dart';
|
||||
@@ -17,131 +16,31 @@ import 'package:worker/features/orders/presentation/widgets/invoice_card.dart';
|
||||
/// Payments Page
|
||||
///
|
||||
/// Features:
|
||||
/// - Tab bar for invoice status filtering
|
||||
/// - List of invoice cards
|
||||
/// - Pull-to-refresh
|
||||
/// - Empty states for each tab
|
||||
class PaymentsPage extends ConsumerStatefulWidget {
|
||||
/// - Empty state
|
||||
class PaymentsPage extends ConsumerWidget {
|
||||
const PaymentsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PaymentsPage> createState() => _PaymentsPageState();
|
||||
}
|
||||
|
||||
class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
final List<Map<String, String>> _tabs = [
|
||||
{'key': 'all', 'label': 'Tất cả'},
|
||||
{'key': 'unpaid', 'label': 'Chưa thanh toán'},
|
||||
{'key': 'overdue', 'label': 'Quá hạn'},
|
||||
{'key': 'paid', 'label': 'Đã thanh toán'},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: _tabs.length, vsync: this);
|
||||
_tabController.addListener(_onTabChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController
|
||||
..removeListener(_onTabChanged)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onTabChanged() {
|
||||
setState(() {}); // Rebuild to show filtered list
|
||||
}
|
||||
|
||||
/// Filter invoices based on tab key
|
||||
List<InvoiceModel> _filterInvoices(
|
||||
List<InvoiceModel> invoices,
|
||||
String tabKey,
|
||||
) {
|
||||
var filtered = List<InvoiceModel>.from(invoices);
|
||||
|
||||
switch (tabKey) {
|
||||
case 'unpaid':
|
||||
// Unpaid tab: issued status only
|
||||
filtered = filtered
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.issued && !invoice.isPaid,
|
||||
)
|
||||
.toList();
|
||||
break;
|
||||
case 'overdue':
|
||||
// Overdue tab: overdue status
|
||||
filtered = filtered
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.overdue || invoice.isOverdue,
|
||||
)
|
||||
.toList();
|
||||
break;
|
||||
case 'paid':
|
||||
// Paid tab: paid status
|
||||
filtered = filtered
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.paid || invoice.isPaid,
|
||||
)
|
||||
.toList();
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
// All tab: no filtering
|
||||
break;
|
||||
}
|
||||
|
||||
// Sort by issue date (newest first)
|
||||
filtered.sort((a, b) => b.issueDate.compareTo(a.issueDate));
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/// Get counts for each tab
|
||||
Map<String, int> _getCounts(List<InvoiceModel> invoices) {
|
||||
return {
|
||||
'all': invoices.length,
|
||||
'unpaid': invoices
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.issued && !invoice.isPaid,
|
||||
)
|
||||
.length,
|
||||
'overdue': invoices
|
||||
.where(
|
||||
(invoice) =>
|
||||
invoice.status == InvoiceStatus.overdue || invoice.isOverdue,
|
||||
)
|
||||
.length,
|
||||
'paid': invoices
|
||||
.where(
|
||||
(invoice) => invoice.status == InvoiceStatus.paid || invoice.isPaid,
|
||||
)
|
||||
.length,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final invoicesAsync = ref.watch(invoicesProvider);
|
||||
|
||||
return invoicesAsync.when(
|
||||
data: (allInvoices) {
|
||||
final counts = _getCounts(allInvoices);
|
||||
data: (invoices) {
|
||||
// Sort by issue date (newest first)
|
||||
final sortedInvoices = List<InvoiceModel>.from(invoices)
|
||||
..sort((a, b) => b.issueDate.compareTo(a.issueDate));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.arrowLeft,
|
||||
color: Colors.black,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text(
|
||||
@@ -150,123 +49,53 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
),
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
foregroundColor: AppColors.grey900,
|
||||
centerTitle: false,
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
labelColor: AppColors.primaryBlue,
|
||||
unselectedLabelColor: AppColors.grey500,
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
indicatorColor: AppColors.primaryBlue,
|
||||
indicatorWeight: 3,
|
||||
tabs: _tabs.map((tab) {
|
||||
final count = counts[tab['key']] ?? 0;
|
||||
return Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(tab['label']!),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
body: sortedInvoices.isEmpty
|
||||
? _buildEmptyState(ref)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(invoicesProvider.notifier).refresh();
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: sortedInvoices.length,
|
||||
itemBuilder: (context, index) {
|
||||
final invoice = sortedInvoices[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: InvoiceCard(
|
||||
invoice: invoice,
|
||||
onTap: () {
|
||||
context.push('/payments/${invoice.invoiceId}');
|
||||
},
|
||||
onPaymentTap: () {
|
||||
context.push('/payments/${invoice.invoiceId}');
|
||||
},
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _tabController.index == _tabs.indexOf(tab)
|
||||
? AppColors.primaryBlue.withValues(alpha: 0.1)
|
||||
: AppColors.grey100,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _tabController.index == _tabs.indexOf(tab)
|
||||
? AppColors.primaryBlue
|
||||
: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
print('refresh');
|
||||
await ref.read(invoicesProvider.notifier).refresh();
|
||||
},
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: _tabs.map((tab) {
|
||||
final filteredInvoices = _filterInvoices(
|
||||
allInvoices,
|
||||
tab['key']!,
|
||||
);
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Invoices List
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: filteredInvoices.isEmpty
|
||||
? _buildEmptyState(tab['label']!)
|
||||
: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((
|
||||
context,
|
||||
index,
|
||||
) {
|
||||
final invoice = filteredInvoices[index];
|
||||
return InvoiceCard(
|
||||
invoice: invoice,
|
||||
onTap: () {
|
||||
context.push(
|
||||
'/payments/${invoice.invoiceId}',
|
||||
);
|
||||
},
|
||||
onPaymentTap: () {
|
||||
context.push(
|
||||
'/payments/${invoice.invoiceId}',
|
||||
);
|
||||
},
|
||||
);
|
||||
}, childCount: filteredInvoices.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.arrowLeft,
|
||||
color: Colors.black,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Danh sách hoá đơn',
|
||||
'Thanh toán',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
foregroundColor: AppColors.grey900,
|
||||
centerTitle: false,
|
||||
),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
@@ -275,16 +104,19 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.arrowLeft,
|
||||
color: Colors.black,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Danh sách hoá đơn',
|
||||
'Thanh toán',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
foregroundColor: AppColors.grey900,
|
||||
centerTitle: false,
|
||||
),
|
||||
body: Center(
|
||||
@@ -319,54 +151,44 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
|
||||
}
|
||||
|
||||
/// Build empty state
|
||||
Widget _buildEmptyState(String tabLabel) {
|
||||
String message;
|
||||
IconData icon;
|
||||
|
||||
switch (tabLabel) {
|
||||
case 'Chưa thanh toán':
|
||||
message = 'Không có hóa đơn chưa thanh toán';
|
||||
icon = FontAwesomeIcons.receipt;
|
||||
break;
|
||||
case 'Quá hạn':
|
||||
message = 'Không có hóa đơn quá hạn';
|
||||
icon = FontAwesomeIcons.triangleExclamation;
|
||||
break;
|
||||
case 'Đã thanh toán':
|
||||
message = 'Không có hóa đơn đã thanh toán';
|
||||
icon = FontAwesomeIcons.circleCheck;
|
||||
break;
|
||||
default:
|
||||
message = 'Không có hóa đơn nào';
|
||||
icon = FontAwesomeIcons.receipt;
|
||||
}
|
||||
|
||||
return SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FaIcon(
|
||||
icon,
|
||||
size: 80,
|
||||
color: AppColors.grey500.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey500,
|
||||
Widget _buildEmptyState(WidgetRef ref) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(invoicesProvider.notifier).refresh();
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 500,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FaIcon(
|
||||
FontAwesomeIcons.receipt,
|
||||
size: 80,
|
||||
color: AppColors.grey500.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Không có hóa đơn nào',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Kéo xuống để làm mới',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Kéo xuống để làm mới',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
/// Price Policy Local DataSource
|
||||
///
|
||||
/// Handles all local data operations for price policy documents.
|
||||
/// Currently provides mock data for development and testing.
|
||||
/// Will be extended to use Hive cache when backend API is available.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/price_policy/data/models/price_document_model.dart';
|
||||
|
||||
/// Price Policy Local Data Source
|
||||
///
|
||||
/// Provides mock data for price policy documents.
|
||||
/// In production, this will cache data from the remote API.
|
||||
class PricePolicyLocalDataSource {
|
||||
/// Get all price policy documents
|
||||
///
|
||||
/// Returns a list of all documents from mock data.
|
||||
/// In production, this will fetch from Hive cache.
|
||||
Future<List<PriceDocumentModel>> getAllDocuments() async {
|
||||
// Simulate network delay
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
return _mockDocuments;
|
||||
}
|
||||
|
||||
/// Get documents by category
|
||||
///
|
||||
/// Returns filtered list of documents matching the [category].
|
||||
Future<List<PriceDocumentModel>> getDocumentsByCategory(
|
||||
String category,
|
||||
) async {
|
||||
// Simulate network delay
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
return _mockDocuments
|
||||
.where((doc) => doc.category.toLowerCase() == category.toLowerCase())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Get a specific document by ID
|
||||
///
|
||||
/// Returns the document if found, null otherwise.
|
||||
Future<PriceDocumentModel?> getDocumentById(String documentId) async {
|
||||
// Simulate network delay
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
try {
|
||||
return _mockDocuments.firstWhere((doc) => doc.id == documentId);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if cache is valid
|
||||
///
|
||||
/// Returns true if cached data is still valid.
|
||||
/// Currently always returns false since we're using mock data.
|
||||
Future<bool> isCacheValid() async {
|
||||
// TODO: Implement cache validation when using Hive
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Cache documents locally
|
||||
///
|
||||
/// Saves documents to Hive for offline access.
|
||||
/// Currently not implemented (using mock data).
|
||||
Future<void> cacheDocuments(List<PriceDocumentModel> documents) async {
|
||||
// TODO: Implement Hive caching when backend API is ready
|
||||
}
|
||||
|
||||
/// Clear cached documents
|
||||
///
|
||||
/// Removes all cached documents from Hive.
|
||||
/// Currently not implemented (using mock data).
|
||||
Future<void> clearCache() async {
|
||||
// TODO: Implement cache clearing when using Hive
|
||||
}
|
||||
|
||||
/// Mock documents matching HTML design
|
||||
///
|
||||
/// This data will be replaced with real API data in production.
|
||||
static final List<PriceDocumentModel> _mockDocuments = [
|
||||
// Policy documents (Chính sách giá)
|
||||
const PriceDocumentModel(
|
||||
id: 'policy-eurotile-10-2025',
|
||||
title: 'Chính sách giá Eurotile T10/2025',
|
||||
description:
|
||||
'Chính sách giá mới nhất cho sản phẩm gạch Eurotile, áp dụng từ tháng 10/2025',
|
||||
publishedDate: '2025-10-01T00:00:00.000Z',
|
||||
documentType: 'pdf',
|
||||
category: 'policy',
|
||||
downloadUrl: '/documents/policy-eurotile-10-2025.pdf',
|
||||
fileSize: '2.5 MB',
|
||||
),
|
||||
const PriceDocumentModel(
|
||||
id: 'policy-vasta-10-2025',
|
||||
title: 'Chính sách giá Vasta Stone T10/2025',
|
||||
description:
|
||||
'Chính sách giá đá tự nhiên Vasta Stone, hiệu lực từ tháng 10/2025',
|
||||
publishedDate: '2025-10-01T00:00:00.000Z',
|
||||
documentType: 'pdf',
|
||||
category: 'policy',
|
||||
downloadUrl: '/documents/policy-vasta-10-2025.pdf',
|
||||
fileSize: '1.8 MB',
|
||||
),
|
||||
const PriceDocumentModel(
|
||||
id: 'policy-dealer-2025',
|
||||
title: 'Chính sách chiết khấu đại lý 2025',
|
||||
description:
|
||||
'Chương trình chiết khấu và ưu đãi dành cho đại lý, thầu thợ',
|
||||
publishedDate: '2025-09-15T00:00:00.000Z',
|
||||
documentType: 'pdf',
|
||||
category: 'policy',
|
||||
downloadUrl: '/documents/policy-dealer-2025.pdf',
|
||||
fileSize: '3.2 MB',
|
||||
),
|
||||
const PriceDocumentModel(
|
||||
id: 'policy-payment-2025',
|
||||
title: 'Điều kiện thanh toán & giao hàng',
|
||||
description:
|
||||
'Điều khoản thanh toán, chính sách giao hàng và bảo hành sản phẩm',
|
||||
publishedDate: '2025-08-01T00:00:00.000Z',
|
||||
documentType: 'pdf',
|
||||
category: 'policy',
|
||||
downloadUrl: '/documents/policy-payment-2025.pdf',
|
||||
fileSize: '1.5 MB',
|
||||
),
|
||||
|
||||
// Price list documents (Bảng giá)
|
||||
const PriceDocumentModel(
|
||||
id: 'pricelist-granite-2025',
|
||||
title: 'Bảng giá Gạch Granite Eurotile 2025',
|
||||
description:
|
||||
'Bảng giá chi tiết toàn bộ sản phẩm gạch granite, kích thước 60x60, 80x80, 120x120',
|
||||
publishedDate: '2025-10-01T00:00:00.000Z',
|
||||
documentType: 'excel',
|
||||
category: 'priceList',
|
||||
downloadUrl: '/documents/pricelist-granite-2025.xlsx',
|
||||
fileSize: '850 KB',
|
||||
),
|
||||
const PriceDocumentModel(
|
||||
id: 'pricelist-ceramic-2025',
|
||||
title: 'Bảng giá Gạch Ceramic Eurotile 2025',
|
||||
description: 'Bảng giá gạch ceramic vân gỗ, vân đá, vân xi măng các loại',
|
||||
publishedDate: '2025-10-01T00:00:00.000Z',
|
||||
documentType: 'excel',
|
||||
category: 'priceList',
|
||||
downloadUrl: '/documents/pricelist-ceramic-2025.xlsx',
|
||||
fileSize: '720 KB',
|
||||
),
|
||||
const PriceDocumentModel(
|
||||
id: 'pricelist-stone-2025',
|
||||
title: 'Bảng giá Đá tự nhiên Vasta Stone 2025',
|
||||
description:
|
||||
'Bảng giá đá marble, granite tự nhiên nhập khẩu, kích thước tấm lớn',
|
||||
publishedDate: '2025-10-01T00:00:00.000Z',
|
||||
documentType: 'excel',
|
||||
category: 'priceList',
|
||||
downloadUrl: '/documents/pricelist-stone-2025.xlsx',
|
||||
fileSize: '950 KB',
|
||||
),
|
||||
const PriceDocumentModel(
|
||||
id: 'pricelist-accessories-2025',
|
||||
title: 'Bảng giá Phụ kiện & Vật liệu 2025',
|
||||
description:
|
||||
'Giá keo dán, chà ron, nẹp nhựa, nẹp inox và các phụ kiện thi công',
|
||||
publishedDate: '2025-09-15T00:00:00.000Z',
|
||||
documentType: 'excel',
|
||||
category: 'priceList',
|
||||
downloadUrl: '/documents/pricelist-accessories-2025.xlsx',
|
||||
fileSize: '640 KB',
|
||||
),
|
||||
const PriceDocumentModel(
|
||||
id: 'pricelist-outdoor-2025',
|
||||
title: 'Bảng giá Gạch Outdoor & Chống trơn 2025',
|
||||
description:
|
||||
'Bảng giá sản phẩm outdoor, gạch chống trơn dành cho ngoại thất',
|
||||
publishedDate: '2025-09-01T00:00:00.000Z',
|
||||
documentType: 'excel',
|
||||
category: 'priceList',
|
||||
downloadUrl: '/documents/pricelist-outdoor-2025.xlsx',
|
||||
fileSize: '780 KB',
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// Remote Data Source: Price Policy
|
||||
///
|
||||
/// Handles API communication for price policy documents.
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:worker/core/network/dio_client.dart';
|
||||
import 'package:worker/features/price_policy/data/models/price_document_model.dart';
|
||||
|
||||
/// Price Policy Remote Data Source Interface
|
||||
abstract class PricePolicyRemoteDataSource {
|
||||
/// Get documents by pricing type
|
||||
Future<List<PriceDocumentModel>> getDocumentsByType(String pricingType);
|
||||
|
||||
/// Get all documents (both pricing rule and price list)
|
||||
Future<List<PriceDocumentModel>> getAllDocuments();
|
||||
}
|
||||
|
||||
/// Price Policy Remote Data Source Implementation
|
||||
class PricePolicyRemoteDataSourceImpl implements PricePolicyRemoteDataSource {
|
||||
|
||||
const PricePolicyRemoteDataSourceImpl(this._dioClient);
|
||||
final DioClient _dioClient;
|
||||
|
||||
@override
|
||||
Future<List<PriceDocumentModel>> getDocumentsByType(String pricingType) async {
|
||||
try {
|
||||
final response = await _dioClient.post<Map<String, dynamic>>(
|
||||
'/api/method/building_material.building_material.api.pricing.get_pricing_info',
|
||||
data: {
|
||||
'pricing_type': pricingType,
|
||||
'limit_page_length': 0,
|
||||
'limit_start': 0,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final apiResponse = PricingApiResponse.fromJson(response.data!);
|
||||
return apiResponse.message;
|
||||
} on DioException catch (e) {
|
||||
throw Exception('Failed to fetch pricing documents: ${e.message}');
|
||||
} catch (e) {
|
||||
throw Exception('Failed to parse pricing documents: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<PriceDocumentModel>> getAllDocuments() async {
|
||||
try {
|
||||
// Fetch both pricing rule and price list in parallel
|
||||
final results = await Future.wait([
|
||||
getDocumentsByType('PRICING_RULE'),
|
||||
getDocumentsByType('PRICE_LIST'),
|
||||
]);
|
||||
|
||||
// Combine results
|
||||
return [...results[0], ...results[1]];
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch all documents: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +1,115 @@
|
||||
/// Data Model: Price Document Model
|
||||
/// Data Model: Price Document
|
||||
///
|
||||
/// Data layer model for price policy documents.
|
||||
/// Handles JSON serialization and conversion to/from domain entity.
|
||||
/// Data model for price policy documents from API.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/price_policy/domain/entities/price_document.dart';
|
||||
|
||||
/// Price Document Model
|
||||
///
|
||||
/// Used in the data layer for:
|
||||
/// - JSON serialization/deserialization from API
|
||||
/// - Conversion to domain entity
|
||||
/// - Local storage (if needed)
|
||||
/// Price document data model
|
||||
class PriceDocumentModel {
|
||||
/// Unique document ID
|
||||
final String id;
|
||||
|
||||
/// Document title
|
||||
final String title;
|
||||
|
||||
/// Document description
|
||||
final String description;
|
||||
|
||||
/// Date the document was published (ISO 8601 string)
|
||||
final String publishedDate;
|
||||
|
||||
/// Type of document (pdf or excel)
|
||||
final String documentType;
|
||||
|
||||
/// Category (policy or priceList)
|
||||
final String category;
|
||||
|
||||
/// URL to download the document
|
||||
final String downloadUrl;
|
||||
final String fileUrl;
|
||||
|
||||
/// Optional file size display string
|
||||
final String? fileSize;
|
||||
/// Last updated timestamp
|
||||
final String updatedAt;
|
||||
|
||||
/// Constructor
|
||||
const PriceDocumentModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.publishedDate,
|
||||
required this.documentType,
|
||||
required this.category,
|
||||
required this.downloadUrl,
|
||||
this.fileSize,
|
||||
required this.fileUrl,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Create model from JSON
|
||||
/// Create from JSON
|
||||
factory PriceDocumentModel.fromJson(Map<String, dynamic> json) {
|
||||
return PriceDocumentModel(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
publishedDate: json['published_date'] as String,
|
||||
documentType: json['document_type'] as String,
|
||||
category: json['category'] as String,
|
||||
downloadUrl: json['download_url'] as String,
|
||||
fileSize: json['file_size'] as String?,
|
||||
title: json['title'] as String? ?? '',
|
||||
fileUrl: json['file_url'] as String? ?? '',
|
||||
updatedAt: json['updated_at'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert model to JSON
|
||||
/// Convert to JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'published_date': publishedDate,
|
||||
'document_type': documentType,
|
||||
'category': category,
|
||||
'download_url': downloadUrl,
|
||||
'file_size': fileSize,
|
||||
'file_url': fileUrl,
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert model to domain entity
|
||||
PriceDocument toEntity() {
|
||||
/// Convert to domain entity
|
||||
PriceDocument toEntity(DocumentCategory category) {
|
||||
return PriceDocument(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
publishedDate: DateTime.parse(publishedDate),
|
||||
documentType: _parseDocumentType(documentType),
|
||||
category: _parseCategory(category),
|
||||
downloadUrl: downloadUrl,
|
||||
fileSize: fileSize,
|
||||
fileUrl: fileUrl,
|
||||
updatedAt: _parseDateTime(updatedAt),
|
||||
category: category,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create model from domain entity
|
||||
/// Parse datetime string from API format
|
||||
/// Format: "2025-11-26 11:36:43"
|
||||
DateTime _parseDateTime(String dateTimeStr) {
|
||||
try {
|
||||
// Replace space with 'T' for ISO 8601 format
|
||||
final isoFormat = dateTimeStr.trim().replaceFirst(' ', 'T');
|
||||
return DateTime.parse(isoFormat);
|
||||
} catch (e) {
|
||||
// If parsing fails, return current datetime
|
||||
return DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from domain entity
|
||||
factory PriceDocumentModel.fromEntity(PriceDocument entity) {
|
||||
return PriceDocumentModel(
|
||||
id: entity.id,
|
||||
title: entity.title,
|
||||
description: entity.description,
|
||||
publishedDate: entity.publishedDate.toIso8601String(),
|
||||
documentType: _documentTypeToString(entity.documentType),
|
||||
category: _categoryToString(entity.category),
|
||||
downloadUrl: entity.downloadUrl,
|
||||
fileSize: entity.fileSize,
|
||||
fileUrl: entity.fileUrl,
|
||||
updatedAt: _formatDateTime(entity.updatedAt),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse document type from string
|
||||
static DocumentType _parseDocumentType(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return DocumentType.pdf;
|
||||
case 'excel':
|
||||
return DocumentType.excel;
|
||||
default:
|
||||
return DocumentType.pdf;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse category from string
|
||||
static DocumentCategory _parseCategory(String category) {
|
||||
switch (category.toLowerCase()) {
|
||||
case 'policy':
|
||||
return DocumentCategory.policy;
|
||||
case 'pricelist':
|
||||
case 'price_list':
|
||||
return DocumentCategory.priceList;
|
||||
default:
|
||||
return DocumentCategory.policy;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert document type to string
|
||||
static String _documentTypeToString(DocumentType type) {
|
||||
switch (type) {
|
||||
case DocumentType.pdf:
|
||||
return 'pdf';
|
||||
case DocumentType.excel:
|
||||
return 'excel';
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert category to string
|
||||
static String _categoryToString(DocumentCategory category) {
|
||||
switch (category) {
|
||||
case DocumentCategory.policy:
|
||||
return 'policy';
|
||||
case DocumentCategory.priceList:
|
||||
return 'priceList';
|
||||
}
|
||||
/// Format datetime to API format
|
||||
static String _formatDateTime(DateTime dateTime) {
|
||||
return '${dateTime.year}-'
|
||||
'${dateTime.month.toString().padLeft(2, '0')}-'
|
||||
'${dateTime.day.toString().padLeft(2, '0')} '
|
||||
'${dateTime.hour.toString().padLeft(2, '0')}:'
|
||||
'${dateTime.minute.toString().padLeft(2, '0')}:'
|
||||
'${dateTime.second.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PriceDocumentModel(id: $id, title: $title, category: $category, '
|
||||
'documentType: $documentType, publishedDate: $publishedDate)';
|
||||
return 'PriceDocumentModel(title: $title, fileUrl: $fileUrl, updatedAt: $updatedAt)';
|
||||
}
|
||||
}
|
||||
|
||||
/// API Response wrapper
|
||||
class PricingApiResponse {
|
||||
/// List of price documents
|
||||
final List<PriceDocumentModel> message;
|
||||
|
||||
const PricingApiResponse({required this.message});
|
||||
|
||||
/// Create from JSON
|
||||
factory PricingApiResponse.fromJson(Map<String, dynamic> json) {
|
||||
final messageList = json['message'] as List<dynamic>? ?? [];
|
||||
return PricingApiResponse(
|
||||
message: messageList
|
||||
.map((item) => PriceDocumentModel.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'message': message.map((doc) => doc.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,44 @@
|
||||
/// Repository Implementation: Price Policy Repository
|
||||
///
|
||||
/// Concrete implementation of the PricePolicyRepository interface.
|
||||
/// Coordinates between local and remote data sources to provide price policy data.
|
||||
///
|
||||
/// Currently uses mock data from local datasource.
|
||||
/// Will implement offline-first strategy when backend API is available.
|
||||
/// Fetches price policy documents from remote API.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/price_policy/data/datasources/price_policy_local_datasource.dart';
|
||||
import 'package:worker/features/price_policy/data/datasources/price_policy_remote_datasource.dart';
|
||||
import 'package:worker/features/price_policy/domain/entities/price_document.dart';
|
||||
import 'package:worker/features/price_policy/domain/repositories/price_policy_repository.dart';
|
||||
|
||||
/// Price Policy Repository Implementation
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// - Coordinate between local cache and remote API (when available)
|
||||
/// - Convert data models to domain entities
|
||||
/// - Handle errors gracefully
|
||||
/// - Manage cache invalidation
|
||||
class PricePolicyRepositoryImpl implements PricePolicyRepository {
|
||||
/// Local data source
|
||||
final PricePolicyLocalDataSource localDataSource;
|
||||
|
||||
/// Remote data source (API) - TODO: Add when API is ready
|
||||
// final PricePolicyRemoteDataSource remoteDataSource;
|
||||
/// Remote data source
|
||||
final PricePolicyRemoteDataSource remoteDataSource;
|
||||
|
||||
/// Constructor
|
||||
PricePolicyRepositoryImpl({
|
||||
required this.localDataSource,
|
||||
// required this.remoteDataSource, // TODO: Add when API ready
|
||||
const PricePolicyRepositoryImpl({
|
||||
required this.remoteDataSource,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<PriceDocument>> getAllDocuments() async {
|
||||
try {
|
||||
// TODO: Implement offline-first strategy
|
||||
// 1. Check if cache is valid
|
||||
// 2. Return cached data if valid
|
||||
// 3. If cache invalid, fetch from remote
|
||||
// Fetch documents separately to maintain category info
|
||||
final pricingRuleModels =
|
||||
await remoteDataSource.getDocumentsByType('PRICING_RULE');
|
||||
final priceListModels =
|
||||
await remoteDataSource.getDocumentsByType('PRICE_LIST');
|
||||
|
||||
// For now, get from local datasource (mock data)
|
||||
final models = await localDataSource.getAllDocuments();
|
||||
final entities = <PriceDocument>[
|
||||
...pricingRuleModels.map((model) => model.toEntity(DocumentCategory.policy)),
|
||||
...priceListModels.map((model) => model.toEntity(DocumentCategory.priceList)),
|
||||
];
|
||||
|
||||
// Convert models to entities
|
||||
final entities = models.map((model) => model.toEntity()).toList();
|
||||
|
||||
// Sort by published date (newest first)
|
||||
entities.sort((a, b) => b.publishedDate.compareTo(a.publishedDate));
|
||||
// Sort by update date (newest first)
|
||||
entities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
|
||||
return entities;
|
||||
} catch (e) {
|
||||
// Log error and return empty list
|
||||
// In production, this should throw proper domain failures
|
||||
print('[PricePolicyRepository] Error getting documents: $e');
|
||||
return [];
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,37 +47,35 @@ class PricePolicyRepositoryImpl implements PricePolicyRepository {
|
||||
DocumentCategory category,
|
||||
) async {
|
||||
try {
|
||||
// Convert category to string for datasource
|
||||
final categoryString = _categoryToString(category);
|
||||
// Convert category to API parameter
|
||||
final pricingType = category.apiValue;
|
||||
|
||||
// Get documents from local datasource
|
||||
final models = await localDataSource.getDocumentsByCategory(
|
||||
categoryString,
|
||||
);
|
||||
// Fetch documents by type from API
|
||||
final models = await remoteDataSource.getDocumentsByType(pricingType);
|
||||
|
||||
// Convert models to entities
|
||||
final entities = models.map((model) => model.toEntity()).toList();
|
||||
// Convert models to entities with the correct category
|
||||
final entities = models.map((model) => model.toEntity(category)).toList();
|
||||
|
||||
// Sort by published date (newest first)
|
||||
entities.sort((a, b) => b.publishedDate.compareTo(a.publishedDate));
|
||||
// Sort by update date (newest first)
|
||||
entities.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
|
||||
return entities;
|
||||
} catch (e) {
|
||||
print('[PricePolicyRepository] Error getting documents by category: $e');
|
||||
return [];
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PriceDocument?> getDocumentById(String documentId) async {
|
||||
try {
|
||||
// Get document from local datasource
|
||||
final model = await localDataSource.getDocumentById(documentId);
|
||||
|
||||
// Convert model to entity
|
||||
return model?.toEntity();
|
||||
// Since API doesn't have a get-by-id endpoint,
|
||||
// we fetch all and find the matching one
|
||||
final allDocuments = await getAllDocuments();
|
||||
return allDocuments.firstWhere(
|
||||
(doc) => doc.title == documentId,
|
||||
orElse: () => throw Exception('Document not found'),
|
||||
);
|
||||
} catch (e) {
|
||||
print('[PricePolicyRepository] Error getting document by id: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -100,35 +83,10 @@ class PricePolicyRepositoryImpl implements PricePolicyRepository {
|
||||
@override
|
||||
Future<List<PriceDocument>> refreshDocuments() async {
|
||||
try {
|
||||
// TODO: Implement remote fetch when API is available
|
||||
// 1. Fetch from remote API
|
||||
// 2. Cache the results locally
|
||||
// 3. Return fresh data
|
||||
|
||||
// For now, just clear and refetch from local
|
||||
await localDataSource.clearCache();
|
||||
final models = await localDataSource.getAllDocuments();
|
||||
|
||||
// Convert models to entities
|
||||
final entities = models.map((model) => model.toEntity()).toList();
|
||||
|
||||
// Sort by published date (newest first)
|
||||
entities.sort((a, b) => b.publishedDate.compareTo(a.publishedDate));
|
||||
|
||||
return entities;
|
||||
// Refresh is same as getAllDocuments since we're fetching from API
|
||||
return await getAllDocuments();
|
||||
} catch (e) {
|
||||
print('[PricePolicyRepository] Error refreshing documents: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper method to convert category enum to string
|
||||
String _categoryToString(DocumentCategory category) {
|
||||
switch (category) {
|
||||
case DocumentCategory.policy:
|
||||
return 'policy';
|
||||
case DocumentCategory.priceList:
|
||||
return 'priceList';
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,44 +4,47 @@
|
||||
/// This entity is framework-independent and contains only business logic.
|
||||
library;
|
||||
|
||||
/// Price policy document entity
|
||||
class PriceDocument {
|
||||
/// Unique document ID
|
||||
final String id;
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Price policy document entity
|
||||
class PriceDocument extends Equatable {
|
||||
/// Document title
|
||||
final String title;
|
||||
|
||||
/// Document description
|
||||
final String description;
|
||||
/// URL to download the document
|
||||
final String fileUrl;
|
||||
|
||||
/// Date the document was published
|
||||
final DateTime publishedDate;
|
||||
|
||||
/// Type of document (PDF or Excel)
|
||||
final DocumentType documentType;
|
||||
/// Date the document was last updated
|
||||
final DateTime updatedAt;
|
||||
|
||||
/// Category (policy or price list)
|
||||
final DocumentCategory category;
|
||||
|
||||
/// URL to download the document
|
||||
final String downloadUrl;
|
||||
|
||||
/// Optional file size display string
|
||||
final String? fileSize;
|
||||
/// Local file path after download (in-memory cache for current session)
|
||||
final String? filePath;
|
||||
|
||||
/// Constructor
|
||||
const PriceDocument({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.publishedDate,
|
||||
required this.documentType,
|
||||
required this.fileUrl,
|
||||
required this.updatedAt,
|
||||
required this.category,
|
||||
required this.downloadUrl,
|
||||
this.fileSize,
|
||||
this.filePath,
|
||||
});
|
||||
|
||||
/// Get document type based on file extension
|
||||
DocumentType get documentType {
|
||||
final lowerUrl = fileUrl.toLowerCase();
|
||||
if (lowerUrl.endsWith('.pdf')) {
|
||||
return DocumentType.pdf;
|
||||
} else if (lowerUrl.endsWith('.xlsx') ||
|
||||
lowerUrl.endsWith('.xls') ||
|
||||
lowerUrl.endsWith('.csv')) {
|
||||
return DocumentType.excel;
|
||||
}
|
||||
return DocumentType.excel; // Default to excel
|
||||
}
|
||||
|
||||
/// Check if document is a PDF
|
||||
bool get isPdf => documentType == DocumentType.pdf;
|
||||
|
||||
@@ -56,9 +59,15 @@ class PriceDocument {
|
||||
|
||||
/// Get formatted published date (dd/MM/yyyy)
|
||||
String get formattedDate {
|
||||
return '${publishedDate.day.toString().padLeft(2, '0')}/'
|
||||
'${publishedDate.month.toString().padLeft(2, '0')}/'
|
||||
'${publishedDate.year}';
|
||||
return '${updatedAt.day.toString().padLeft(2, '0')}/'
|
||||
'${updatedAt.month.toString().padLeft(2, '0')}/'
|
||||
'${updatedAt.year}';
|
||||
}
|
||||
|
||||
/// Get formatted date with time (dd/MM/yyyy HH:mm)
|
||||
String get formattedDateTime {
|
||||
return '$formattedDate ${updatedAt.hour.toString().padLeft(2, '0')}:'
|
||||
'${updatedAt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Get formatted date with prefix based on category
|
||||
@@ -72,64 +81,30 @@ class PriceDocument {
|
||||
|
||||
/// Copy with method for immutability
|
||||
PriceDocument copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
DateTime? publishedDate,
|
||||
DocumentType? documentType,
|
||||
String? fileUrl,
|
||||
DateTime? updatedAt,
|
||||
DocumentCategory? category,
|
||||
String? downloadUrl,
|
||||
String? fileSize,
|
||||
String? filePath,
|
||||
}) {
|
||||
return PriceDocument(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
publishedDate: publishedDate ?? this.publishedDate,
|
||||
documentType: documentType ?? this.documentType,
|
||||
fileUrl: fileUrl ?? this.fileUrl,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
category: category ?? this.category,
|
||||
downloadUrl: downloadUrl ?? this.downloadUrl,
|
||||
fileSize: fileSize ?? this.fileSize,
|
||||
filePath: filePath ?? this.filePath,
|
||||
);
|
||||
}
|
||||
|
||||
/// Equality operator
|
||||
/// Equatable props for equality comparison
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is PriceDocument &&
|
||||
other.id == id &&
|
||||
other.title == title &&
|
||||
other.description == description &&
|
||||
other.publishedDate == publishedDate &&
|
||||
other.documentType == documentType &&
|
||||
other.category == category &&
|
||||
other.downloadUrl == downloadUrl &&
|
||||
other.fileSize == fileSize;
|
||||
}
|
||||
|
||||
/// Hash code
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
publishedDate,
|
||||
documentType,
|
||||
category,
|
||||
downloadUrl,
|
||||
fileSize,
|
||||
);
|
||||
}
|
||||
List<Object?> get props => [title, fileUrl, updatedAt, category, filePath];
|
||||
|
||||
/// String representation
|
||||
@override
|
||||
String toString() {
|
||||
return 'PriceDocument(id: $id, title: $title, description: $description, '
|
||||
'publishedDate: $publishedDate, documentType: $documentType, '
|
||||
'category: $category, downloadUrl: $downloadUrl, fileSize: $fileSize)';
|
||||
return 'PriceDocument(title: $title, fileUrl: $fileUrl, '
|
||||
'updatedAt: $updatedAt, category: $category, filePath: $filePath)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,8 +113,8 @@ enum DocumentType { pdf, excel }
|
||||
|
||||
/// Document category enum
|
||||
enum DocumentCategory {
|
||||
policy, // Chính sách giá
|
||||
priceList, // Bảng giá
|
||||
policy, // Chính sách giá (PRICING_RULE)
|
||||
priceList, // Bảng giá (PRICE_LIST)
|
||||
}
|
||||
|
||||
// Extension for display
|
||||
@@ -163,4 +138,14 @@ extension DocumentCategoryX on DocumentCategory {
|
||||
return 'Bảng giá';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get API parameter value for this category
|
||||
String get apiValue {
|
||||
switch (this) {
|
||||
case DocumentCategory.policy:
|
||||
return 'PRICING_RULE';
|
||||
case DocumentCategory.priceList:
|
||||
return 'PRICE_LIST';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../../../../core/constants/ui_constants.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../../domain/entities/price_document.dart';
|
||||
import '../providers/price_documents_provider.dart';
|
||||
@@ -55,36 +60,55 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppColors.white,
|
||||
unselectedLabelColor: AppColors.grey900,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
indicator: BoxDecoration(
|
||||
color: AppColors.primaryBlue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: 'Chính sách giá'),
|
||||
Tab(text: 'Bảng giá'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
body: Column(
|
||||
children: [
|
||||
// Policy tab
|
||||
_buildDocumentList(DocumentCategory.policy),
|
||||
// Price list tab
|
||||
_buildDocumentList(DocumentCategory.priceList),
|
||||
// TabBar with padding
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey100,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: TabBar(
|
||||
controller: _tabController,
|
||||
labelColor: AppColors.white,
|
||||
unselectedLabelColor: AppColors.grey900,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
indicator: BoxDecoration(
|
||||
color: AppColors.primaryBlue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
dividerColor: Colors.transparent,
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
tabs: const [
|
||||
Tab(text: 'Chính sách giá'),
|
||||
Tab(text: 'Bảng giá'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// TabBarView
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// Policy tab
|
||||
_buildDocumentList(DocumentCategory.policy),
|
||||
// Price list tab
|
||||
_buildDocumentList(DocumentCategory.priceList),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -96,7 +120,7 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
|
||||
return documentsAsync.when(
|
||||
data: (documents) {
|
||||
if (documents.isEmpty) {
|
||||
return Center(
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -105,7 +129,7 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
|
||||
size: 64,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
'Chưa có tài liệu',
|
||||
style: TextStyle(fontSize: 16, color: AppColors.grey500),
|
||||
@@ -118,8 +142,9 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// Refresh documents from repository
|
||||
ref.invalidate(filteredPriceDocumentsProvider(category));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
await ref
|
||||
.read(filteredPriceDocumentsProvider(category).notifier)
|
||||
.refresh();
|
||||
},
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
@@ -130,7 +155,7 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
|
||||
final document = documents[index];
|
||||
return DocumentCard(
|
||||
document: document,
|
||||
onDownload: () => _handleDownload(document),
|
||||
onDownload: () => _handleDownload(document, category),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -150,7 +175,9 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.invalidate(filteredPriceDocumentsProvider(category));
|
||||
ref
|
||||
.read(filteredPriceDocumentsProvider(category).notifier)
|
||||
.refresh();
|
||||
},
|
||||
child: const Text('Thử lại'),
|
||||
),
|
||||
@@ -160,22 +187,95 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDownload(PriceDocument document) {
|
||||
// In real app, this would trigger actual download
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Đang tải: ${document.title}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
Future<void> _handleDownload(
|
||||
PriceDocument document,
|
||||
DocumentCategory category,
|
||||
) async {
|
||||
try {
|
||||
// Check if file already downloaded and exists
|
||||
if (document.filePath != null) {
|
||||
final file = File(document.filePath!);
|
||||
if (await file.exists()) {
|
||||
// File exists, just open it
|
||||
final result = await OpenFile.open(document.filePath!);
|
||||
if (result.type != ResultType.done && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Không thể mở file: ${result.message}'),
|
||||
backgroundColor: AppColors.danger,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate download
|
||||
// TODO: Implement actual file download functionality
|
||||
// - Use url_launcher or dio to download file
|
||||
// - Show progress indicator
|
||||
// - Save to device storage
|
||||
// File not downloaded yet, show loading snackbar
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Đang tải: ${document.title}'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
|
||||
// Get DioClient
|
||||
final dioClient = await ref.read(dioClientProvider.future);
|
||||
|
||||
// Get download directory
|
||||
final directory = await getApplicationDocumentsDirectory();
|
||||
|
||||
// Extract filename from URL or use title
|
||||
final uri = Uri.parse(document.fileUrl);
|
||||
final filename = uri.pathSegments.isNotEmpty
|
||||
? uri.pathSegments.last
|
||||
: '${document.title}.${document.documentType == DocumentType.pdf ? "pdf" : "xlsx"}';
|
||||
|
||||
final savePath = '${directory.path}/$filename';
|
||||
|
||||
// Download file with authentication headers (automatically added by AuthInterceptor)
|
||||
await dioClient.downloadFile(
|
||||
document.fileUrl,
|
||||
savePath,
|
||||
onReceiveProgress: (received, total) {
|
||||
// Progress tracking available here if needed: (received / total * 100)
|
||||
},
|
||||
);
|
||||
|
||||
// Update document with file path in provider
|
||||
ref
|
||||
.read(filteredPriceDocumentsProvider(category).notifier)
|
||||
.updateDocumentFilePath(document.title, savePath);
|
||||
|
||||
// Show success message
|
||||
if (mounted) {
|
||||
// Clear any existing snackbars
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
// Show success message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Tải thành công: ${document.title}'),
|
||||
backgroundColor: AppColors.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Show error message
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Lỗi tải file: ${e.toString()}'),
|
||||
backgroundColor: AppColors.danger,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showInfoDialog() {
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/price_policy/data/datasources/price_policy_local_datasource.dart';
|
||||
import 'package:worker/core/network/dio_client.dart';
|
||||
import 'package:worker/features/price_policy/data/datasources/price_policy_remote_datasource.dart';
|
||||
import 'package:worker/features/price_policy/data/repositories/price_policy_repository_impl.dart';
|
||||
import 'package:worker/features/price_policy/domain/entities/price_document.dart';
|
||||
import 'package:worker/features/price_policy/domain/repositories/price_policy_repository.dart';
|
||||
|
||||
part 'price_documents_provider.g.dart';
|
||||
|
||||
/// Provider for local data source
|
||||
@riverpod
|
||||
PricePolicyLocalDataSource pricePolicyLocalDataSource(Ref ref) {
|
||||
return PricePolicyLocalDataSource();
|
||||
}
|
||||
|
||||
/// Provider for price policy repository
|
||||
@riverpod
|
||||
PricePolicyRepository pricePolicyRepository(Ref ref) {
|
||||
final localDataSource = ref.watch(pricePolicyLocalDataSourceProvider);
|
||||
|
||||
return PricePolicyRepositoryImpl(localDataSource: localDataSource);
|
||||
Future<PricePolicyRepository> pricePolicyRepository(Ref ref) async {
|
||||
final dioClient = await ref.watch(dioClientProvider.future);
|
||||
final remoteDataSource = PricePolicyRemoteDataSourceImpl(dioClient);
|
||||
return PricePolicyRepositoryImpl(remoteDataSource: remoteDataSource);
|
||||
}
|
||||
|
||||
/// Provider for all price policy documents
|
||||
@riverpod
|
||||
Future<List<PriceDocument>> priceDocuments(Ref ref) async {
|
||||
final repository = ref.watch(pricePolicyRepositoryProvider);
|
||||
final repository = await ref.watch(pricePolicyRepositoryProvider.future);
|
||||
return repository.getAllDocuments();
|
||||
}
|
||||
|
||||
/// Provider for filtered documents by category
|
||||
/// Provider for filtered documents by category with file path management
|
||||
@riverpod
|
||||
Future<List<PriceDocument>> filteredPriceDocuments(
|
||||
Ref ref,
|
||||
DocumentCategory category,
|
||||
) async {
|
||||
final repository = ref.watch(pricePolicyRepositoryProvider);
|
||||
return repository.getDocumentsByCategory(category);
|
||||
class FilteredPriceDocuments extends _$FilteredPriceDocuments {
|
||||
@override
|
||||
Future<List<PriceDocument>> build(DocumentCategory category) async {
|
||||
final repository = await ref.watch(pricePolicyRepositoryProvider.future);
|
||||
return repository.getDocumentsByCategory(category);
|
||||
}
|
||||
|
||||
/// Update a document's file path after download
|
||||
void updateDocumentFilePath(String documentTitle, String filePath) {
|
||||
state = state.whenData((documents) {
|
||||
return documents.map((doc) {
|
||||
if (doc.title == documentTitle) {
|
||||
return doc.copyWith(filePath: filePath);
|
||||
}
|
||||
return doc;
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh documents
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = await ref.read(pricePolicyRepositoryProvider.future);
|
||||
return repository.getDocumentsByCategory(category);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,60 +8,6 @@ part of 'price_documents_provider.dart';
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provider for local data source
|
||||
|
||||
@ProviderFor(pricePolicyLocalDataSource)
|
||||
const pricePolicyLocalDataSourceProvider =
|
||||
PricePolicyLocalDataSourceProvider._();
|
||||
|
||||
/// Provider for local data source
|
||||
|
||||
final class PricePolicyLocalDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
PricePolicyLocalDataSource,
|
||||
PricePolicyLocalDataSource,
|
||||
PricePolicyLocalDataSource
|
||||
>
|
||||
with $Provider<PricePolicyLocalDataSource> {
|
||||
/// Provider for local data source
|
||||
const PricePolicyLocalDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'pricePolicyLocalDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$pricePolicyLocalDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<PricePolicyLocalDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
PricePolicyLocalDataSource create(Ref ref) {
|
||||
return pricePolicyLocalDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(PricePolicyLocalDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<PricePolicyLocalDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$pricePolicyLocalDataSourceHash() =>
|
||||
r'dd1bee761fa7f050835508cf33bf34a788829483';
|
||||
|
||||
/// Provider for price policy repository
|
||||
|
||||
@ProviderFor(pricePolicyRepository)
|
||||
@@ -72,11 +18,13 @@ const pricePolicyRepositoryProvider = PricePolicyRepositoryProvider._();
|
||||
final class PricePolicyRepositoryProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<PricePolicyRepository>,
|
||||
PricePolicyRepository,
|
||||
PricePolicyRepository,
|
||||
PricePolicyRepository
|
||||
FutureOr<PricePolicyRepository>
|
||||
>
|
||||
with $Provider<PricePolicyRepository> {
|
||||
with
|
||||
$FutureModifier<PricePolicyRepository>,
|
||||
$FutureProvider<PricePolicyRepository> {
|
||||
/// Provider for price policy repository
|
||||
const PricePolicyRepositoryProvider._()
|
||||
: super(
|
||||
@@ -94,26 +42,18 @@ final class PricePolicyRepositoryProvider
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<PricePolicyRepository> $createElement(
|
||||
$FutureProviderElement<PricePolicyRepository> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
PricePolicyRepository create(Ref ref) {
|
||||
FutureOr<PricePolicyRepository> create(Ref ref) {
|
||||
return pricePolicyRepository(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(PricePolicyRepository value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<PricePolicyRepository>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$pricePolicyRepositoryHash() =>
|
||||
r'296555a45936d8e43a28bf5add5e7db40495009c';
|
||||
r'35aa21067e77bbb6b91dd29c4772b1c6707be116';
|
||||
|
||||
/// Provider for all price policy documents
|
||||
|
||||
@@ -159,26 +99,18 @@ final class PriceDocumentsProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$priceDocumentsHash() => r'cf2ccf6bd9aaae0c56ab01529fd034a090d99263';
|
||||
String _$priceDocumentsHash() => r'dffe292742776681c22d0ccdb3e091491290057d';
|
||||
|
||||
/// Provider for filtered documents by category
|
||||
/// Provider for filtered documents by category with file path management
|
||||
|
||||
@ProviderFor(filteredPriceDocuments)
|
||||
@ProviderFor(FilteredPriceDocuments)
|
||||
const filteredPriceDocumentsProvider = FilteredPriceDocumentsFamily._();
|
||||
|
||||
/// Provider for filtered documents by category
|
||||
|
||||
/// Provider for filtered documents by category with file path management
|
||||
final class FilteredPriceDocumentsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<PriceDocument>>,
|
||||
List<PriceDocument>,
|
||||
FutureOr<List<PriceDocument>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<PriceDocument>>,
|
||||
$FutureProvider<List<PriceDocument>> {
|
||||
/// Provider for filtered documents by category
|
||||
$AsyncNotifierProvider<FilteredPriceDocuments, List<PriceDocument>> {
|
||||
/// Provider for filtered documents by category with file path management
|
||||
const FilteredPriceDocumentsProvider._({
|
||||
required FilteredPriceDocumentsFamily super.from,
|
||||
required DocumentCategory super.argument,
|
||||
@@ -202,15 +134,7 @@ final class FilteredPriceDocumentsProvider
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<PriceDocument>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<PriceDocument>> create(Ref ref) {
|
||||
final argument = this.argument as DocumentCategory;
|
||||
return filteredPriceDocuments(ref, argument);
|
||||
}
|
||||
FilteredPriceDocuments create() => FilteredPriceDocuments();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
@@ -225,13 +149,16 @@ final class FilteredPriceDocumentsProvider
|
||||
}
|
||||
|
||||
String _$filteredPriceDocumentsHash() =>
|
||||
r'8f5b2ed822694b4dd9523e1a61e202a7ba0c1fbc';
|
||||
r'c06d858ed1027d6408c4b70c29f47a4c4c9eb21c';
|
||||
|
||||
/// Provider for filtered documents by category
|
||||
/// Provider for filtered documents by category with file path management
|
||||
|
||||
final class FilteredPriceDocumentsFamily extends $Family
|
||||
with
|
||||
$FunctionalFamilyOverride<
|
||||
$ClassFamilyOverride<
|
||||
FilteredPriceDocuments,
|
||||
AsyncValue<List<PriceDocument>>,
|
||||
List<PriceDocument>,
|
||||
FutureOr<List<PriceDocument>>,
|
||||
DocumentCategory
|
||||
> {
|
||||
@@ -244,7 +171,7 @@ final class FilteredPriceDocumentsFamily extends $Family
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
/// Provider for filtered documents by category
|
||||
/// Provider for filtered documents by category with file path management
|
||||
|
||||
FilteredPriceDocumentsProvider call(DocumentCategory category) =>
|
||||
FilteredPriceDocumentsProvider._(argument: category, from: this);
|
||||
@@ -252,3 +179,29 @@ final class FilteredPriceDocumentsFamily extends $Family
|
||||
@override
|
||||
String toString() => r'filteredPriceDocumentsProvider';
|
||||
}
|
||||
|
||||
/// Provider for filtered documents by category with file path management
|
||||
|
||||
abstract class _$FilteredPriceDocuments
|
||||
extends $AsyncNotifier<List<PriceDocument>> {
|
||||
late final _$args = ref.$arg as DocumentCategory;
|
||||
DocumentCategory get category => _$args;
|
||||
|
||||
FutureOr<List<PriceDocument>> build(DocumentCategory category);
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build(_$args);
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<List<PriceDocument>>, List<PriceDocument>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<List<PriceDocument>>, List<PriceDocument>>,
|
||||
AsyncValue<List<PriceDocument>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,23 +120,11 @@ class DocumentCard extends StatelessWidget {
|
||||
document.formattedDateWithPrefix,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
),
|
||||
if (document.fileSize != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'•',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
document.fileSize!,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
document.description,
|
||||
document.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
@@ -150,19 +138,24 @@ class DocumentCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildDownloadButton() {
|
||||
final isDownloaded = document.filePath != null;
|
||||
final buttonColor = isDownloaded ? AppColors.success : AppColors.primaryBlue;
|
||||
final buttonIcon = isDownloaded ? Icons.folder_open : Icons.download;
|
||||
final buttonText = isDownloaded ? 'Mở file' : 'Tải về';
|
||||
|
||||
return ElevatedButton.icon(
|
||||
onPressed: onDownload,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
backgroundColor: buttonColor,
|
||||
foregroundColor: AppColors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: const Text(
|
||||
'Tải về',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
icon: Icon(buttonIcon, size: 18),
|
||||
label: Text(
|
||||
buttonText,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/// Submissions Remote Data Source
|
||||
///
|
||||
/// Handles remote API calls for project submissions.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/projects/domain/entities/project_submission.dart';
|
||||
|
||||
/// Submissions Remote Data Source
|
||||
///
|
||||
/// Abstract interface for remote submissions operations.
|
||||
abstract class SubmissionsRemoteDataSource {
|
||||
/// Fetch all submissions from remote API
|
||||
Future<List<ProjectSubmission>> getSubmissions();
|
||||
|
||||
/// Fetch a single submission by ID
|
||||
Future<ProjectSubmission> getSubmissionById(String submissionId);
|
||||
|
||||
/// Create a new submission
|
||||
Future<ProjectSubmission> createSubmission(ProjectSubmission submission);
|
||||
|
||||
/// Update an existing submission
|
||||
Future<ProjectSubmission> updateSubmission(ProjectSubmission submission);
|
||||
|
||||
/// Delete a submission
|
||||
Future<void> deleteSubmission(String submissionId);
|
||||
}
|
||||
|
||||
/// Mock Implementation of Submissions Remote Data Source
|
||||
///
|
||||
/// Provides mock data for development and testing.
|
||||
class SubmissionsRemoteDataSourceImpl implements SubmissionsRemoteDataSource {
|
||||
@override
|
||||
Future<List<ProjectSubmission>> getSubmissions() async {
|
||||
// Simulate network delay
|
||||
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
return [
|
||||
ProjectSubmission(
|
||||
submissionId: 'DA001',
|
||||
userId: 'user123',
|
||||
projectName: 'Chung cư Vinhomes Grand Park - Block A1',
|
||||
projectAddress: 'TP.HCM',
|
||||
projectValue: 850000000,
|
||||
projectType: ProjectType.residential,
|
||||
status: SubmissionStatus.approved,
|
||||
beforePhotos: [],
|
||||
afterPhotos: [],
|
||||
invoices: [],
|
||||
submittedAt: DateTime(2023, 11, 15),
|
||||
reviewedAt: DateTime(2023, 11, 20),
|
||||
pointsEarned: 8500,
|
||||
),
|
||||
ProjectSubmission(
|
||||
submissionId: 'DA002',
|
||||
userId: 'user123',
|
||||
projectName: 'Trung tâm thương mại Bitexco',
|
||||
projectAddress: 'TP.HCM',
|
||||
projectValue: 2200000000,
|
||||
projectType: ProjectType.commercial,
|
||||
status: SubmissionStatus.pending,
|
||||
beforePhotos: [],
|
||||
afterPhotos: [],
|
||||
invoices: [],
|
||||
submittedAt: DateTime(2023, 11, 25),
|
||||
),
|
||||
ProjectSubmission(
|
||||
submissionId: 'DA003',
|
||||
userId: 'user123',
|
||||
projectName: 'Biệt thự sinh thái Ecopark',
|
||||
projectAddress: 'Hà Nội',
|
||||
projectValue: 420000000,
|
||||
projectType: ProjectType.residential,
|
||||
status: SubmissionStatus.approved,
|
||||
beforePhotos: [],
|
||||
afterPhotos: [],
|
||||
invoices: [],
|
||||
submittedAt: DateTime(2023, 10, 10),
|
||||
reviewedAt: DateTime(2023, 10, 15),
|
||||
pointsEarned: 4200,
|
||||
),
|
||||
ProjectSubmission(
|
||||
submissionId: 'DA004',
|
||||
userId: 'user123',
|
||||
projectName: 'Nhà xưởng sản xuất ABC',
|
||||
projectAddress: 'Bình Dương',
|
||||
projectValue: 1500000000,
|
||||
projectType: ProjectType.industrial,
|
||||
status: SubmissionStatus.rejected,
|
||||
beforePhotos: [],
|
||||
afterPhotos: [],
|
||||
invoices: [],
|
||||
submittedAt: DateTime(2023, 11, 20),
|
||||
reviewedAt: DateTime(2023, 11, 28),
|
||||
rejectionReason: 'Thiếu giấy phép xây dựng và báo cáo tác động môi trường',
|
||||
),
|
||||
ProjectSubmission(
|
||||
submissionId: 'DA005',
|
||||
userId: 'user123',
|
||||
projectName: 'Khách sạn 5 sao Diamond Plaza',
|
||||
projectAddress: 'Đà Nẵng',
|
||||
projectValue: 5800000000,
|
||||
projectType: ProjectType.commercial,
|
||||
status: SubmissionStatus.pending,
|
||||
beforePhotos: [],
|
||||
afterPhotos: [],
|
||||
invoices: [],
|
||||
submittedAt: DateTime(2023, 12, 1),
|
||||
),
|
||||
ProjectSubmission(
|
||||
submissionId: 'DA006',
|
||||
userId: 'user123',
|
||||
projectName: 'Khu đô thị thông minh Smart City',
|
||||
projectAddress: 'Hà Nội',
|
||||
projectValue: 8500000000,
|
||||
projectType: ProjectType.residential,
|
||||
status: SubmissionStatus.approved,
|
||||
beforePhotos: [],
|
||||
afterPhotos: [],
|
||||
invoices: [],
|
||||
submittedAt: DateTime(2023, 11, 10),
|
||||
reviewedAt: DateTime(2023, 11, 18),
|
||||
pointsEarned: 85000,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProjectSubmission> getSubmissionById(String submissionId) async {
|
||||
// Simulate network delay
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
final submissions = await getSubmissions();
|
||||
return submissions.firstWhere(
|
||||
(s) => s.submissionId == submissionId,
|
||||
orElse: () => throw Exception('Submission not found'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProjectSubmission> createSubmission(
|
||||
ProjectSubmission submission,
|
||||
) async {
|
||||
// Simulate network delay
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
// In real implementation, this would call the API
|
||||
return submission;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProjectSubmission> updateSubmission(
|
||||
ProjectSubmission submission,
|
||||
) async {
|
||||
// Simulate network delay
|
||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||
|
||||
// In real implementation, this would call the API
|
||||
return submission;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSubmission(String submissionId) async {
|
||||
// Simulate network delay
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
// In real implementation, this would call the API
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/// Submissions Repository Implementation
|
||||
///
|
||||
/// Implements the submissions repository interface.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/projects/data/datasources/submissions_remote_datasource.dart';
|
||||
import 'package:worker/features/projects/domain/entities/project_submission.dart';
|
||||
import 'package:worker/features/projects/domain/repositories/submissions_repository.dart';
|
||||
|
||||
/// Submissions Repository Implementation
|
||||
///
|
||||
/// Handles data operations for project submissions.
|
||||
class SubmissionsRepositoryImpl implements SubmissionsRepository {
|
||||
|
||||
const SubmissionsRepositoryImpl(this._remoteDataSource);
|
||||
final SubmissionsRemoteDataSource _remoteDataSource;
|
||||
|
||||
@override
|
||||
Future<List<ProjectSubmission>> getSubmissions() async {
|
||||
try {
|
||||
return await _remoteDataSource.getSubmissions();
|
||||
} catch (e) {
|
||||
// In real implementation, handle errors properly
|
||||
// For now, rethrow
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProjectSubmission> getSubmissionById(String submissionId) async {
|
||||
try {
|
||||
return await _remoteDataSource.getSubmissionById(submissionId);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProjectSubmission> createSubmission(
|
||||
ProjectSubmission submission,
|
||||
) async {
|
||||
try {
|
||||
return await _remoteDataSource.createSubmission(submission);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProjectSubmission> updateSubmission(
|
||||
ProjectSubmission submission,
|
||||
) async {
|
||||
try {
|
||||
return await _remoteDataSource.updateSubmission(submission);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSubmission(String submissionId) async {
|
||||
try {
|
||||
await _remoteDataSource.deleteSubmission(submissionId);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/// Submissions Repository
|
||||
///
|
||||
/// Repository interface for project submissions operations.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/projects/domain/entities/project_submission.dart';
|
||||
|
||||
/// Submissions Repository
|
||||
///
|
||||
/// Defines contract for project submissions data operations.
|
||||
abstract class SubmissionsRepository {
|
||||
/// Get all project submissions for the current user
|
||||
Future<List<ProjectSubmission>> getSubmissions();
|
||||
|
||||
/// Get a single submission by ID
|
||||
Future<ProjectSubmission> getSubmissionById(String submissionId);
|
||||
|
||||
/// Create a new project submission
|
||||
Future<ProjectSubmission> createSubmission(ProjectSubmission submission);
|
||||
|
||||
/// Update an existing submission
|
||||
Future<ProjectSubmission> updateSubmission(ProjectSubmission submission);
|
||||
|
||||
/// Delete a submission
|
||||
Future<void> deleteSubmission(String submissionId);
|
||||
}
|
||||
23
lib/features/projects/domain/usecases/get_submissions.dart
Normal file
23
lib/features/projects/domain/usecases/get_submissions.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
/// Get Submissions Use Case
|
||||
///
|
||||
/// Retrieves all project submissions for the current user.
|
||||
library;
|
||||
|
||||
import 'package:worker/features/projects/domain/entities/project_submission.dart';
|
||||
import 'package:worker/features/projects/domain/repositories/submissions_repository.dart';
|
||||
|
||||
/// Get Submissions Use Case
|
||||
///
|
||||
/// Business logic for retrieving project submissions.
|
||||
class GetSubmissions {
|
||||
|
||||
const GetSubmissions(this._repository);
|
||||
final SubmissionsRepository _repository;
|
||||
|
||||
/// Execute the use case
|
||||
///
|
||||
/// Returns list of all project submissions for the current user.
|
||||
Future<List<ProjectSubmission>> call() async {
|
||||
return await _repository.getSubmissions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
/// Submission Create Page
|
||||
///
|
||||
/// Form for creating new project submissions.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
|
||||
/// Project Submission Create Page
|
||||
class SubmissionCreatePage extends ConsumerStatefulWidget {
|
||||
const SubmissionCreatePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SubmissionCreatePage> createState() =>
|
||||
_SubmissionCreatePageState();
|
||||
}
|
||||
|
||||
class _SubmissionCreatePageState extends ConsumerState<SubmissionCreatePage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Form controllers
|
||||
final _projectNameController = TextEditingController();
|
||||
final _addressController = TextEditingController();
|
||||
final _ownerController = TextEditingController();
|
||||
final _designUnitController = TextEditingController();
|
||||
final _constructionUnitController = TextEditingController();
|
||||
final _areaController = TextEditingController();
|
||||
final _productsController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
// Form state
|
||||
String? _selectedProgress;
|
||||
DateTime? _expectedStartDate;
|
||||
final List<File> _uploadedFiles = [];
|
||||
bool _showStartDateField = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_projectNameController.dispose();
|
||||
_addressController.dispose();
|
||||
_ownerController.dispose();
|
||||
_designUnitController.dispose();
|
||||
_constructionUnitController.dispose();
|
||||
_areaController.dispose();
|
||||
_productsController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.arrowLeft,
|
||||
color: Colors.black,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Đăng ký Công trình',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.circleInfo,
|
||||
color: Colors.black,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: _showInfoDialog,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
centerTitle: false,
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(4),
|
||||
children: [
|
||||
// Basic Information
|
||||
_buildBasicInfoCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Project Details
|
||||
_buildProjectDetailsCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Additional Information
|
||||
_buildAdditionalInfoCard(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// File Upload
|
||||
_buildFileUploadCard(),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Submit Button
|
||||
_buildSubmitButton(),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBasicInfoCard() {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Thông tin cơ bản',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildTextField(
|
||||
controller: _projectNameController,
|
||||
label: 'Tên công trình',
|
||||
required: true,
|
||||
hint: 'Nhập tên công trình',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildTextField(
|
||||
controller: _addressController,
|
||||
label: 'Địa chỉ',
|
||||
required: true,
|
||||
hint: 'Nhập địa chỉ đầy đủ',
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildTextField(
|
||||
controller: _ownerController,
|
||||
label: 'Chủ đầu tư',
|
||||
required: true,
|
||||
hint: 'Tên chủ đầu tư',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildTextField(
|
||||
controller: _designUnitController,
|
||||
label: 'Đơn vị thiết kế',
|
||||
hint: 'Tên đơn vị thiết kế',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildTextField(
|
||||
controller: _constructionUnitController,
|
||||
label: 'Đơn vị thi công',
|
||||
hint: 'Tên đơn vị thi công',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProjectDetailsCard() {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Chi tiết dự án',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildTextField(
|
||||
controller: _areaController,
|
||||
label: 'Tổng diện tích',
|
||||
required: true,
|
||||
hint: 'Nhập diện tích m²',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildTextField(
|
||||
controller: _productsController,
|
||||
label: 'Sản phẩm đưa vào thiết kế',
|
||||
required: true,
|
||||
hint: 'Liệt kê các sản phẩm gạch đã sử dụng trong công trình (tên sản phẩm, mã SP, số lượng...)',
|
||||
maxLines: 4,
|
||||
helperText: 'Ví dụ: Gạch granite 60x60 - GP-001 - 100m², Gạch mosaic - MS-002 - 50m²',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildProgressDropdown(),
|
||||
|
||||
if (_showStartDateField) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildDateField(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAdditionalInfoCard() {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Thông tin bổ sung',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_buildTextField(
|
||||
controller: _descriptionController,
|
||||
label: 'Mô tả công trình',
|
||||
hint: 'Mô tả ngắn gọn về công trình, diện tích, đặc điểm nổi bật...',
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFileUploadCard() {
|
||||
return Card(
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Ảnh minh chứng',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Upload Area
|
||||
InkWell(
|
||||
onTap: _pickFiles,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20),
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: AppColors.grey100,
|
||||
width: 2,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Column(
|
||||
children: [
|
||||
FaIcon(
|
||||
FontAwesomeIcons.cloudArrowUp,
|
||||
size: 48,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Kéo thả ảnh vào đây',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'hoặc nhấn để chọn file',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (_uploadedFiles.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
..._uploadedFiles.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final file = entry.value;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: _buildFileItem(file, index),
|
||||
);
|
||||
}),
|
||||
],
|
||||
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Hỗ trợ: JPG, PNG, PDF. Tối đa 10MB mỗi file.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextField({
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
String? hint,
|
||||
bool required = false,
|
||||
int maxLines = 1,
|
||||
TextInputType? keyboardType,
|
||||
String? helperText,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
if (required)
|
||||
const Text(
|
||||
' *',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
maxLines: maxLines,
|
||||
keyboardType: keyboardType,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(color: AppColors.grey500),
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.grey100),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.grey100),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.primaryBlue, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.danger),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
validator: required
|
||||
? (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Vui lòng nhập $label';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
: null,
|
||||
),
|
||||
if (helperText != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
helperText,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProgressDropdown() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Text(
|
||||
'Tiến độ công trình',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' *',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedProgress,
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.grey100),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.grey100),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
hint: const Text('Chọn tiến độ'),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'not-started',
|
||||
child: Text('Chưa khởi công'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'foundation',
|
||||
child: Text('Khởi công móng'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'rough-construction',
|
||||
child: Text('Đang phần thô'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'finishing',
|
||||
child: Text('Đang hoàn thiện'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'topped-out',
|
||||
child: Text('Cất nóc'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedProgress = value;
|
||||
_showStartDateField = value == 'not-started';
|
||||
if (!_showStartDateField) {
|
||||
_expectedStartDate = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Vui lòng chọn tiến độ công trình';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDateField() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Ngày dự kiến khởi công',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
InkWell(
|
||||
onTap: _pickDate,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
border: Border.all(color: AppColors.grey100),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_expectedStartDate != null
|
||||
? '${_expectedStartDate!.day}/${_expectedStartDate!.month}/${_expectedStartDate!.year}'
|
||||
: 'Chọn ngày',
|
||||
style: TextStyle(
|
||||
color: _expectedStartDate != null
|
||||
? AppColors.grey900
|
||||
: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
const FaIcon(
|
||||
FontAwesomeIcons.calendar,
|
||||
size: 16,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFileItem(File file, int index) {
|
||||
final fileName = file.path.split('/').last;
|
||||
final fileSizeInBytes = file.lengthSync();
|
||||
final fileSizeInMB = (fileSizeInBytes / (1024 * 1024)).toStringAsFixed(2);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8F9FA),
|
||||
border: Border.all(color: AppColors.grey100),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image.file(
|
||||
file,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: AppColors.grey100,
|
||||
child: const FaIcon(
|
||||
FontAwesomeIcons.image,
|
||||
size: 24,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
fileName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${fileSizeInMB}MB',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.xmark,
|
||||
size: 16,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_uploadedFiles.removeAt(index);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmitButton() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: _handleSubmit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
foregroundColor: AppColors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FaIcon(FontAwesomeIcons.paperPlane, size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Gửi đăng ký',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate() async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: DateTime.now(),
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
|
||||
if (date != null) {
|
||||
setState(() {
|
||||
_expectedStartDate = date;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFiles() async {
|
||||
try {
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final List<XFile> images = await picker.pickMultiImage(
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1920,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (images.isNotEmpty) {
|
||||
setState(() {
|
||||
for (final image in images) {
|
||||
_uploadedFiles.add(File(image.path));
|
||||
}
|
||||
});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Đã thêm ${images.length} ảnh'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Lỗi khi chọn ảnh: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSubmit() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Xác nhận'),
|
||||
content: const Text('Xác nhận gửi đăng ký công trình?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Hủy'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Xác nhận'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Đăng ký công trình đã được gửi thành công!\nChúng tôi sẽ xem xét và liên hệ với bạn sớm nhất.',
|
||||
),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showInfoDialog() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Hướng dẫn đăng ký'),
|
||||
content: const SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Đây là nội dung hướng dẫn sử dụng cho tính năng Đăng ký Công trình:',
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text('• Điền đầy đủ thông tin công trình theo yêu cầu'),
|
||||
Text('• Upload ảnh minh chứng chất lượng cao'),
|
||||
Text('• Mô tả chi tiết sản phẩm đã sử dụng'),
|
||||
Text('• Chọn đúng tiến độ hiện tại của công trình'),
|
||||
Text('• Kiểm tra kỹ thông tin trước khi gửi'),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Đóng'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
372
lib/features/projects/presentation/pages/submissions_page.dart
Normal file
372
lib/features/projects/presentation/pages/submissions_page.dart
Normal file
@@ -0,0 +1,372 @@
|
||||
/// Page: Project Submissions List
|
||||
///
|
||||
/// Displays list of user's project submissions with filters.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/router/app_router.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/projects/domain/entities/project_submission.dart';
|
||||
import 'package:worker/features/projects/presentation/providers/submissions_provider.dart';
|
||||
|
||||
/// Project Submissions Page
|
||||
class SubmissionsPage extends ConsumerWidget {
|
||||
const SubmissionsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final submissionsAsync = ref.watch(filteredSubmissionsProvider);
|
||||
final filter = ref.watch(submissionsFilterProvider);
|
||||
final selectedStatus = filter.selectedStatus;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const FaIcon(FontAwesomeIcons.arrowLeft, color: Colors.black, size: 20),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Danh sách Dự án',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const FaIcon(FontAwesomeIcons.plus, color: Colors.black, size: 20),
|
||||
onPressed: () => context.push(RouteNames.submissionCreate),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
centerTitle: false,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search Bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Mã dự án hoặc tên dự án',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.grey500),
|
||||
filled: true,
|
||||
fillColor: AppColors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.grey100),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.grey100),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(submissionsFilterProvider.notifier).updateSearchQuery(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Status Filter Tabs
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildFilterChip(
|
||||
context,
|
||||
ref,
|
||||
label: 'Tất cả',
|
||||
isSelected: selectedStatus == null,
|
||||
onTap: () => ref.read(submissionsFilterProvider.notifier).clearStatusFilter(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
...SubmissionStatus.values.map((status) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildFilterChip(
|
||||
context,
|
||||
ref,
|
||||
label: status.displayName,
|
||||
isSelected: selectedStatus == status,
|
||||
onTap: () => ref.read(submissionsFilterProvider.notifier).selectStatus(status),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Submissions List
|
||||
Expanded(
|
||||
child: submissionsAsync.when(
|
||||
data: (submissions) {
|
||||
if (submissions.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(allSubmissionsProvider.notifier).refresh();
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FaIcon(
|
||||
FontAwesomeIcons.folderOpen,
|
||||
size: 64,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Không có dự án nào',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Không tìm thấy dự án phù hợp',
|
||||
style: TextStyle(color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(allSubmissionsProvider.notifier).refresh();
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: submissions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final submission = submissions[index];
|
||||
return _buildSubmissionCard(context, submission);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, stack) => RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(allSubmissionsProvider.notifier).refresh();
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Có lỗi xảy ra',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(color: AppColors.grey500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Kéo xuống để thử lại',
|
||||
style: TextStyle(color: AppColors.grey500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterChip(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String label,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppColors.primaryBlue : AppColors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected ? AppColors.primaryBlue : AppColors.grey100,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? AppColors.white : AppColors.grey900,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmissionCard(BuildContext context, ProjectSubmission submission) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// TODO: Navigate to submission detail
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Chi tiết dự án ${submission.submissionId}')),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'#${submission.submissionId}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
_buildStatusBadge(submission.status),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tên công trình: ${submission.projectName}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Ngày nộp: ${DateFormat('dd/MM/yyyy').format(submission.submittedAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Diện tích: ${submission.projectAddress ?? "N/A"}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
if (submission.rejectionReason != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF2F2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const FaIcon(
|
||||
FontAwesomeIcons.triangleExclamation,
|
||||
size: 14,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
submission.rejectionReason!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.danger,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge(SubmissionStatus status) {
|
||||
final color = _getStatusColor(status);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
status.displayName,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getStatusColor(SubmissionStatus status) {
|
||||
switch (status) {
|
||||
case SubmissionStatus.pending:
|
||||
return AppColors.warning;
|
||||
case SubmissionStatus.reviewing:
|
||||
return AppColors.info;
|
||||
case SubmissionStatus.approved:
|
||||
return AppColors.success;
|
||||
case SubmissionStatus.rejected:
|
||||
return AppColors.danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/// Providers: Project Submissions
|
||||
///
|
||||
/// Riverpod providers for managing project submissions state.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/projects/data/datasources/submissions_remote_datasource.dart';
|
||||
import 'package:worker/features/projects/data/repositories/submissions_repository_impl.dart';
|
||||
import 'package:worker/features/projects/domain/entities/project_submission.dart';
|
||||
import 'package:worker/features/projects/domain/repositories/submissions_repository.dart';
|
||||
import 'package:worker/features/projects/domain/usecases/get_submissions.dart';
|
||||
|
||||
part 'submissions_provider.g.dart';
|
||||
|
||||
/// Submissions Remote Data Source Provider
|
||||
@riverpod
|
||||
SubmissionsRemoteDataSource submissionsRemoteDataSource(Ref ref) {
|
||||
return SubmissionsRemoteDataSourceImpl();
|
||||
}
|
||||
|
||||
/// Submissions Repository Provider
|
||||
@riverpod
|
||||
SubmissionsRepository submissionsRepository(Ref ref) {
|
||||
final remoteDataSource = ref.watch(submissionsRemoteDataSourceProvider);
|
||||
return SubmissionsRepositoryImpl(remoteDataSource);
|
||||
}
|
||||
|
||||
/// Get Submissions Use Case Provider
|
||||
@riverpod
|
||||
GetSubmissions getSubmissions(Ref ref) {
|
||||
final repository = ref.watch(submissionsRepositoryProvider);
|
||||
return GetSubmissions(repository);
|
||||
}
|
||||
|
||||
/// All Submissions Provider
|
||||
///
|
||||
/// Fetches and manages submissions data from remote.
|
||||
@riverpod
|
||||
class AllSubmissions extends _$AllSubmissions {
|
||||
@override
|
||||
Future<List<ProjectSubmission>> build() async {
|
||||
final useCase = ref.watch(getSubmissionsProvider);
|
||||
return await useCase();
|
||||
}
|
||||
|
||||
/// Refresh submissions from remote
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final useCase = ref.read(getSubmissionsProvider);
|
||||
return await useCase();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Submissions Filter State
|
||||
///
|
||||
/// Manages search and status filter state.
|
||||
@riverpod
|
||||
class SubmissionsFilter extends _$SubmissionsFilter {
|
||||
@override
|
||||
({String searchQuery, SubmissionStatus? selectedStatus}) build() {
|
||||
return (searchQuery: '', selectedStatus: null);
|
||||
}
|
||||
|
||||
/// Update search query
|
||||
void updateSearchQuery(String query) {
|
||||
state = (searchQuery: query, selectedStatus: state.selectedStatus);
|
||||
}
|
||||
|
||||
/// Select a status filter
|
||||
void selectStatus(SubmissionStatus? status) {
|
||||
state = (searchQuery: state.searchQuery, selectedStatus: status);
|
||||
}
|
||||
|
||||
/// Clear status filter
|
||||
void clearStatusFilter() {
|
||||
state = (searchQuery: state.searchQuery, selectedStatus: null);
|
||||
}
|
||||
|
||||
/// Clear search query
|
||||
void clearSearchQuery() {
|
||||
state = (searchQuery: '', selectedStatus: state.selectedStatus);
|
||||
}
|
||||
|
||||
/// Clear all filters
|
||||
void clearAllFilters() {
|
||||
state = (searchQuery: '', selectedStatus: null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered Submissions Provider
|
||||
///
|
||||
/// Combines submissions data with filter state to return filtered results.
|
||||
@riverpod
|
||||
AsyncValue<List<ProjectSubmission>> filteredSubmissions(Ref ref) {
|
||||
final dataAsync = ref.watch(allSubmissionsProvider);
|
||||
final filter = ref.watch(submissionsFilterProvider);
|
||||
|
||||
return dataAsync.whenData((submissions) {
|
||||
var filtered = submissions;
|
||||
|
||||
// Filter by status
|
||||
if (filter.selectedStatus != null) {
|
||||
filtered = filtered.where((s) => s.status == filter.selectedStatus).toList();
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (filter.searchQuery.isNotEmpty) {
|
||||
final query = filter.searchQuery.toLowerCase();
|
||||
filtered = filtered.where((s) {
|
||||
return s.submissionId.toLowerCase().contains(query) ||
|
||||
s.projectName.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Sort by submitted date (newest first)
|
||||
filtered.sort((a, b) => b.submittedAt.compareTo(a.submittedAt));
|
||||
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'submissions_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Submissions Remote Data Source Provider
|
||||
|
||||
@ProviderFor(submissionsRemoteDataSource)
|
||||
const submissionsRemoteDataSourceProvider =
|
||||
SubmissionsRemoteDataSourceProvider._();
|
||||
|
||||
/// Submissions Remote Data Source Provider
|
||||
|
||||
final class SubmissionsRemoteDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
SubmissionsRemoteDataSource,
|
||||
SubmissionsRemoteDataSource,
|
||||
SubmissionsRemoteDataSource
|
||||
>
|
||||
with $Provider<SubmissionsRemoteDataSource> {
|
||||
/// Submissions Remote Data Source Provider
|
||||
const SubmissionsRemoteDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'submissionsRemoteDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$submissionsRemoteDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<SubmissionsRemoteDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
SubmissionsRemoteDataSource create(Ref ref) {
|
||||
return submissionsRemoteDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(SubmissionsRemoteDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<SubmissionsRemoteDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$submissionsRemoteDataSourceHash() =>
|
||||
r'dc2dd71b6ca22d26382c1dfdf13b88d2249bb5ce';
|
||||
|
||||
/// Submissions Repository Provider
|
||||
|
||||
@ProviderFor(submissionsRepository)
|
||||
const submissionsRepositoryProvider = SubmissionsRepositoryProvider._();
|
||||
|
||||
/// Submissions Repository Provider
|
||||
|
||||
final class SubmissionsRepositoryProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
SubmissionsRepository,
|
||||
SubmissionsRepository,
|
||||
SubmissionsRepository
|
||||
>
|
||||
with $Provider<SubmissionsRepository> {
|
||||
/// Submissions Repository Provider
|
||||
const SubmissionsRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'submissionsRepositoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$submissionsRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<SubmissionsRepository> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
SubmissionsRepository create(Ref ref) {
|
||||
return submissionsRepository(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(SubmissionsRepository value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<SubmissionsRepository>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$submissionsRepositoryHash() =>
|
||||
r'4fa33107966470c07f050b27e669ec1dc4f13fda';
|
||||
|
||||
/// Get Submissions Use Case Provider
|
||||
|
||||
@ProviderFor(getSubmissions)
|
||||
const getSubmissionsProvider = GetSubmissionsProvider._();
|
||||
|
||||
/// Get Submissions Use Case Provider
|
||||
|
||||
final class GetSubmissionsProvider
|
||||
extends $FunctionalProvider<GetSubmissions, GetSubmissions, GetSubmissions>
|
||||
with $Provider<GetSubmissions> {
|
||||
/// Get Submissions Use Case Provider
|
||||
const GetSubmissionsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'getSubmissionsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$getSubmissionsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<GetSubmissions> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
GetSubmissions create(Ref ref) {
|
||||
return getSubmissions(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(GetSubmissions value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<GetSubmissions>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$getSubmissionsHash() => r'91b497f826ae6dc72618ba879289fc449a7ef5cb';
|
||||
|
||||
/// All Submissions Provider
|
||||
///
|
||||
/// Fetches and manages submissions data from remote.
|
||||
|
||||
@ProviderFor(AllSubmissions)
|
||||
const allSubmissionsProvider = AllSubmissionsProvider._();
|
||||
|
||||
/// All Submissions Provider
|
||||
///
|
||||
/// Fetches and manages submissions data from remote.
|
||||
final class AllSubmissionsProvider
|
||||
extends $AsyncNotifierProvider<AllSubmissions, List<ProjectSubmission>> {
|
||||
/// All Submissions Provider
|
||||
///
|
||||
/// Fetches and manages submissions data from remote.
|
||||
const AllSubmissionsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'allSubmissionsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$allSubmissionsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
AllSubmissions create() => AllSubmissions();
|
||||
}
|
||||
|
||||
String _$allSubmissionsHash() => r'40ea0460a8962a4105dabb482bc80573452d4c80';
|
||||
|
||||
/// All Submissions Provider
|
||||
///
|
||||
/// Fetches and manages submissions data from remote.
|
||||
|
||||
abstract class _$AllSubmissions
|
||||
extends $AsyncNotifier<List<ProjectSubmission>> {
|
||||
FutureOr<List<ProjectSubmission>> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<
|
||||
AsyncValue<List<ProjectSubmission>>,
|
||||
List<ProjectSubmission>
|
||||
>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
AsyncValue<List<ProjectSubmission>>,
|
||||
List<ProjectSubmission>
|
||||
>,
|
||||
AsyncValue<List<ProjectSubmission>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Submissions Filter State
|
||||
///
|
||||
/// Manages search and status filter state.
|
||||
|
||||
@ProviderFor(SubmissionsFilter)
|
||||
const submissionsFilterProvider = SubmissionsFilterProvider._();
|
||||
|
||||
/// Submissions Filter State
|
||||
///
|
||||
/// Manages search and status filter state.
|
||||
final class SubmissionsFilterProvider
|
||||
extends
|
||||
$NotifierProvider<
|
||||
SubmissionsFilter,
|
||||
({String searchQuery, SubmissionStatus? selectedStatus})
|
||||
> {
|
||||
/// Submissions Filter State
|
||||
///
|
||||
/// Manages search and status filter state.
|
||||
const SubmissionsFilterProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'submissionsFilterProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$submissionsFilterHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SubmissionsFilter create() => SubmissionsFilter();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(
|
||||
({String searchQuery, SubmissionStatus? selectedStatus}) value,
|
||||
) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride:
|
||||
$SyncValueProvider<
|
||||
({String searchQuery, SubmissionStatus? selectedStatus})
|
||||
>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$submissionsFilterHash() => r'049dd9fa4f6f1bff0d49c6cba0975f9714621883';
|
||||
|
||||
/// Submissions Filter State
|
||||
///
|
||||
/// Manages search and status filter state.
|
||||
|
||||
abstract class _$SubmissionsFilter
|
||||
extends
|
||||
$Notifier<({String searchQuery, SubmissionStatus? selectedStatus})> {
|
||||
({String searchQuery, SubmissionStatus? selectedStatus}) build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref =
|
||||
this.ref
|
||||
as $Ref<
|
||||
({String searchQuery, SubmissionStatus? selectedStatus}),
|
||||
({String searchQuery, SubmissionStatus? selectedStatus})
|
||||
>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<
|
||||
({String searchQuery, SubmissionStatus? selectedStatus}),
|
||||
({String searchQuery, SubmissionStatus? selectedStatus})
|
||||
>,
|
||||
({String searchQuery, SubmissionStatus? selectedStatus}),
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered Submissions Provider
|
||||
///
|
||||
/// Combines submissions data with filter state to return filtered results.
|
||||
|
||||
@ProviderFor(filteredSubmissions)
|
||||
const filteredSubmissionsProvider = FilteredSubmissionsProvider._();
|
||||
|
||||
/// Filtered Submissions Provider
|
||||
///
|
||||
/// Combines submissions data with filter state to return filtered results.
|
||||
|
||||
final class FilteredSubmissionsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<ProjectSubmission>>,
|
||||
AsyncValue<List<ProjectSubmission>>,
|
||||
AsyncValue<List<ProjectSubmission>>
|
||||
>
|
||||
with $Provider<AsyncValue<List<ProjectSubmission>>> {
|
||||
/// Filtered Submissions Provider
|
||||
///
|
||||
/// Combines submissions data with filter state to return filtered results.
|
||||
const FilteredSubmissionsProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'filteredSubmissionsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$filteredSubmissionsHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AsyncValue<List<ProjectSubmission>>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AsyncValue<List<ProjectSubmission>> create(Ref ref) {
|
||||
return filteredSubmissions(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AsyncValue<List<ProjectSubmission>> value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AsyncValue<List<ProjectSubmission>>>(
|
||||
value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$filteredSubmissionsHash() =>
|
||||
r'd0a07ab78a0d98596f01d0ed0a25016d573db5aa';
|
||||
@@ -0,0 +1,654 @@
|
||||
/// Model House Detail Page
|
||||
///
|
||||
/// Displays 360° view launcher, project information, and image gallery.
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:worker/core/constants/ui_constants.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
|
||||
/// Model House Detail Page
|
||||
class ModelHouseDetailPage extends ConsumerWidget {
|
||||
final String modelId;
|
||||
|
||||
const ModelHouseDetailPage({
|
||||
required this.modelId,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Mock data - in real app, fetch from provider
|
||||
final modelData = _getMockData(modelId);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.arrowLeft,
|
||||
color: Colors.black,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Chi tiết Nhà mẫu',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.shareNodes,
|
||||
color: Colors.black,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => _shareModel(context, modelData),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
],
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
centerTitle: false,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 360° View Launcher
|
||||
_build360ViewLauncher(context, modelData),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Project Information
|
||||
_buildProjectInfo(modelData),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Image Gallery
|
||||
_buildImageGallery(context, modelData),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _build360ViewLauncher(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> modelData,
|
||||
) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _launch360View(context, modelData['url360'] as String),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 400,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF667eea), Color(0xFF764ba2)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background image with overlay
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Opacity(
|
||||
opacity: 0.3,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: (modelData['images'] as List<Map<String, String>>)
|
||||
.first['url']!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 360° Icon
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FaIcon(
|
||||
FontAwesomeIcons.arrowsRotate,
|
||||
size: 40,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'360°',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Xem nhà mẫu 360°',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black26,
|
||||
offset: Offset(0, 2),
|
||||
blurRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Trải nghiệm không gian thực tế ảo',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Launch Button
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FaIcon(
|
||||
FontAwesomeIcons.play,
|
||||
size: 14,
|
||||
color: Color(0xFF667eea),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Bắt đầu tham quan',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF667eea),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProjectInfo(Map<String, dynamic> modelData) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
modelData['title'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Specs Grid
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildSpecItem(
|
||||
'Diện tích',
|
||||
modelData['area'] as String,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildSpecItem(
|
||||
'Địa điểm',
|
||||
modelData['location'] as String,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildSpecItem(
|
||||
'Phong cách',
|
||||
modelData['style'] as String,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Description
|
||||
Text(
|
||||
modelData['description'] as String,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF4b5563),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSpecItem(String label, String value) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
label.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageGallery(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> modelData,
|
||||
) {
|
||||
final images = modelData['images'] as List<Map<String, String>>;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Gallery Title
|
||||
const Row(
|
||||
children: [
|
||||
FaIcon(
|
||||
FontAwesomeIcons.images,
|
||||
size: 18,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Thư viện Hình ảnh',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Gallery Grid
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: images.length,
|
||||
itemBuilder: (context, index) {
|
||||
final image = images[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showImageViewer(context, images, index),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
height: 120,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: image['url']!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: AppColors.grey100,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: AppColors.grey100,
|
||||
child: const Icon(Icons.error),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _launch360View(BuildContext context, String url) async {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Không thể mở link 360°')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showImageViewer(
|
||||
BuildContext context,
|
||||
List<Map<String, String>> images,
|
||||
int initialIndex,
|
||||
) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.black87,
|
||||
builder: (context) => _ImageViewerDialog(
|
||||
images: images,
|
||||
initialIndex: initialIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _shareModel(BuildContext context, Map<String, dynamic> modelData) {
|
||||
Share.share(
|
||||
'Xem mô hình 360° ${modelData['title']}\n${modelData['url360']}',
|
||||
subject: modelData['title'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getMockData(String modelId) {
|
||||
// Mock data - in real app, fetch from repository
|
||||
return {
|
||||
'title': 'Căn hộ Studio',
|
||||
'area': '35m²',
|
||||
'location': 'Quận 7',
|
||||
'style': 'Hiện đại',
|
||||
'description':
|
||||
'Thiết kế hiện đại cho căn hộ studio 35m², tối ưu không gian sống với gạch men cao cấp và màu sắc hài hòa. Sử dụng gạch granite nhập khẩu cho khu vực phòng khách và gạch ceramic chống thấm cho khu vực ẩm ướt.',
|
||||
'url360': 'https://vr.house3d.com/web/panorama-player/H00179549',
|
||||
'images': [
|
||||
{
|
||||
'url':
|
||||
'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&h=600&fit=crop',
|
||||
'caption': 'Phối cảnh tổng thể căn hộ studio với thiết kế hiện đại',
|
||||
},
|
||||
{
|
||||
'url':
|
||||
'https://center.eurotile.vn/data/eurotileData/design/202009/23/4/main_img.jpg',
|
||||
'caption': 'Khu vực phòng khách với gạch granite cao cấp',
|
||||
},
|
||||
{
|
||||
'url':
|
||||
'https://center.eurotile.vn/data/eurotileData/design/202009/23/4/project_img_1.jpg?v=1',
|
||||
'caption': 'Phòng ngủ chính với gạch ceramic màu trung tính',
|
||||
},
|
||||
{
|
||||
'url':
|
||||
'https://center.eurotile.vn/data/eurotileData/design/202009/23/4/project_img_0.jpg?v=1',
|
||||
'caption': 'Khu vực bếp với gạch mosaic điểm nhấn',
|
||||
},
|
||||
{
|
||||
'url':
|
||||
'https://images.unsplash.com/photo-1620626011761-996317b8d101?w=800&h=600&fit=crop',
|
||||
'caption': 'Phòng tắm hiện đại với gạch chống thấm cao cấp',
|
||||
},
|
||||
{
|
||||
'url':
|
||||
'https://center.eurotile.vn/data/eurotileData/design/202009/23/4/project_img_3.jpg?v=1',
|
||||
'caption': 'Khu vực bàn ăn ấm cúng',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Image Viewer Dialog with Swipe Navigation
|
||||
class _ImageViewerDialog extends StatefulWidget {
|
||||
final List<Map<String, String>> images;
|
||||
final int initialIndex;
|
||||
|
||||
const _ImageViewerDialog({
|
||||
required this.images,
|
||||
required this.initialIndex,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ImageViewerDialog> createState() => _ImageViewerDialogState();
|
||||
}
|
||||
|
||||
class _ImageViewerDialogState extends State<_ImageViewerDialog> {
|
||||
late PageController _pageController;
|
||||
late int _currentIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentIndex = widget.initialIndex;
|
||||
_pageController = PageController(initialPage: widget.initialIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: EdgeInsets.zero,
|
||||
child: Container(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Main PageView
|
||||
Center(
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
},
|
||||
itemCount: widget.images.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Center(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: widget.images[index]['url']!,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (context, url) => const Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
errorWidget: (context, url, error) => const Icon(
|
||||
Icons.error,
|
||||
color: Colors.white,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Top bar with counter and close button
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${_currentIndex + 1} / ${widget.images.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Caption at bottom
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.images[_currentIndex]['caption']!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,7 @@ class _LibraryTab extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: const [
|
||||
_LibraryCard(
|
||||
modelId: 'studio-01',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?w=800&h=200&fit=crop',
|
||||
title: 'Căn hộ Studio',
|
||||
@@ -178,6 +179,7 @@ class _LibraryTab extends StatelessWidget {
|
||||
has360View: true,
|
||||
),
|
||||
_LibraryCard(
|
||||
modelId: 'villa-01',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1570129477492-45c003edd2be?w=800&h=200&fit=crop',
|
||||
title: 'Biệt thự Hiện đại',
|
||||
@@ -187,6 +189,7 @@ class _LibraryTab extends StatelessWidget {
|
||||
has360View: true,
|
||||
),
|
||||
_LibraryCard(
|
||||
modelId: 'townhouse-01',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1562663474-6cbb3eaa4d14?w=800&h=200&fit=crop',
|
||||
title: 'Nhà phố Tối giản',
|
||||
@@ -196,6 +199,7 @@ class _LibraryTab extends StatelessWidget {
|
||||
has360View: true,
|
||||
),
|
||||
_LibraryCard(
|
||||
modelId: 'apartment-01',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=800&h=200&fit=crop',
|
||||
title: 'Chung cư Cao cấp',
|
||||
@@ -212,6 +216,7 @@ class _LibraryTab extends StatelessWidget {
|
||||
/// Library Card Widget
|
||||
class _LibraryCard extends StatelessWidget {
|
||||
const _LibraryCard({
|
||||
required this.modelId,
|
||||
required this.imageUrl,
|
||||
required this.title,
|
||||
required this.date,
|
||||
@@ -219,6 +224,7 @@ class _LibraryCard extends StatelessWidget {
|
||||
this.has360View = false,
|
||||
});
|
||||
|
||||
final String modelId;
|
||||
final String imageUrl;
|
||||
final String title;
|
||||
final String date;
|
||||
@@ -233,13 +239,7 @@ class _LibraryCard extends StatelessWidget {
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Chức năng xem chi tiết sẽ được triển khai trong phiên bản tiếp theo',
|
||||
),
|
||||
),
|
||||
);
|
||||
context.push('/model-houses/$modelId');
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
|
||||
64
pubspec.lock
64
pubspec.lock
@@ -964,6 +964,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
open_file:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: open_file
|
||||
sha256: d17e2bddf5b278cb2ae18393d0496aa4f162142ba97d1a9e0c30d476adf99c0e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.5.10"
|
||||
open_file_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_android
|
||||
sha256: "58141fcaece2f453a9684509a7275f231ac0e3d6ceb9a5e6de310a7dff9084aa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
open_file_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_ios
|
||||
sha256: "02996f01e5f6863832068e97f8f3a5ef9b613516db6897f373b43b79849e4d07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
open_file_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_linux
|
||||
sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.5"
|
||||
open_file_mac:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_mac
|
||||
sha256: "1440b1e37ceb0642208cfeb2c659c6cda27b25187a90635c9d1acb7d0584d324"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
open_file_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_platform_interface
|
||||
sha256: "101b424ca359632699a7e1213e83d025722ab668b9fd1412338221bf9b0e5757"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
open_file_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_web
|
||||
sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.4"
|
||||
open_file_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: open_file_windows
|
||||
sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.3"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -74,6 +74,7 @@ dependencies:
|
||||
file_picker: ^8.0.0
|
||||
url_launcher: ^6.3.0
|
||||
path_provider: ^2.1.3
|
||||
open_file: ^3.5.10
|
||||
shared_preferences: ^2.2.3
|
||||
flutter_secure_storage: ^9.2.4
|
||||
|
||||
|
||||
Reference in New Issue
Block a user