Compare commits

...

4 Commits

Author SHA1 Message Date
Phuoc Nguyen
88ac2f2f07 price policy 2025-11-26 14:44:17 +07:00
Phuoc Nguyen
a07f165f0c point record 2025-11-26 11:48:02 +07:00
Phuoc Nguyen
3741239d83 dang ki du an 2025-11-26 11:21:35 +07:00
Phuoc Nguyen
7ef12fa83a update submission 2025-11-26 10:06:19 +07:00
36 changed files with 4428 additions and 959 deletions

22
docs/price.sh Normal file
View 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"
}
]
}

View File

@@ -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 = [];

View File

@@ -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

View File

@@ -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';

View File

@@ -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),
),
],
),

View File

@@ -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(),
);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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';
}
}
}

View File

@@ -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);
}

View 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();
}
}

View 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;
}
}
}

View File

@@ -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;
});
}

View File

@@ -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';

View File

@@ -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,12 +590,14 @@ class PaymentDetailPage extends ConsumerWidget {
color: AppColors.success.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const FaIcon(
child: const Center(
child: FaIcon(
FontAwesomeIcons.check,
color: AppColors.success,
size: 18,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
@@ -643,7 +631,7 @@ class PaymentDetailPage extends ConsumerWidget {
),
),
Text(
currencyFormatter.format(amountPaid),
amountPaid.toVNCurrency,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,

View File

@@ -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,105 +49,32 @@ 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,
),
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(
body: sortedInvoices.isEmpty
? _buildEmptyState(ref)
: 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(
child: ListView.builder(
padding: const EdgeInsets.all(16),
sliver: filteredInvoices.isEmpty
? _buildEmptyState(tab['label']!)
: SliverList(
delegate: SliverChildBuilderDelegate((
context,
index,
) {
final invoice = filteredInvoices[index];
return InvoiceCard(
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}',
);
context.push('/payments/${invoice.invoiceId}');
},
onPaymentTap: () {
context.push(
'/payments/${invoice.invoiceId}',
context.push('/payments/${invoice.invoiceId}');
},
),
);
},
);
}, childCount: filteredInvoices.length),
),
),
],
);
}).toList(),
),
),
);
@@ -257,16 +83,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: 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,42 +151,29 @@ 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(
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(
icon,
FontAwesomeIcons.receipt,
size: 80,
color: AppColors.grey500.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
const Text(
'Không có hóa đơn nào',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.grey500,
@@ -368,6 +187,9 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
],
),
),
),
],
),
);
}
}

View File

@@ -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',
),
];
}

View File

@@ -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');
}
}
}

View File

@@ -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(),
};
}
}

View File

@@ -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;
}
}
}

View File

@@ -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';
}
}
}

View File

@@ -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,7 +60,19 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
),
const SizedBox(width: AppSpacing.sm),
],
bottom: TabBar(
),
body: Column(
children: [
// 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,
@@ -64,6 +81,7 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
color: AppColors.primaryBlue,
borderRadius: BorderRadius.circular(8),
),
dividerColor: Colors.transparent,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -78,7 +96,10 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
],
),
),
body: TabBarView(
),
// TabBarView
Expanded(
child: TabBarView(
controller: _tabController,
children: [
// Policy tab
@@ -87,6 +108,9 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
_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,8 +187,31 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
);
}
void _handleDownload(PriceDocument document) {
// In real app, this would trigger actual download
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;
}
}
// File not downloaded yet, show loading snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Đang tải: ${document.title}'),
@@ -171,11 +221,61 @@ class _PricePolicyPageState extends ConsumerState<PricePolicyPage>
),
);
// Simulate download
// TODO: Implement actual file download functionality
// - Use url_launcher or dio to download file
// - Show progress indicator
// - Save to device storage
// 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() {

View File

@@ -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);
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);
});
}
}

View File

@@ -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);
}
}

View File

@@ -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),
),
);
}

View File

@@ -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
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}

View 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();
}
}

View File

@@ -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'),
),
],
),
);
}
}

View 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;
}
}
}

View File

@@ -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;
});
}

View File

@@ -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';

View File

@@ -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,
),
),
),
],
),
),
);
}
}

View File

@@ -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(

View File

@@ -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:

View File

@@ -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