dang ki du an

This commit is contained in:
Phuoc Nguyen
2025-11-26 11:21:35 +07:00
parent 7ef12fa83a
commit 3741239d83
5 changed files with 913 additions and 313 deletions

View File

@@ -41,6 +41,7 @@ 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/product_detail_page.dart';
import 'package:worker/features/products/presentation/pages/products_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/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/projects/presentation/pages/submissions_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotion_detail_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/quotes/presentation/pages/quotes_page.dart';
@@ -363,6 +364,14 @@ final routerProvider = Provider<GoRouter>((ref) {
MaterialPage(key: state.pageKey, child: const SubmissionsPage()), 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 // Quotes Route
GoRoute( GoRoute(
path: RouteNames.quotes, path: RouteNames.quotes,
@@ -564,6 +573,7 @@ class RouteNames {
static const String projectDetail = '/projects/:id'; static const String projectDetail = '/projects/:id';
static const String projectCreate = '/projects/create'; static const String projectCreate = '/projects/create';
static const String submissions = '/submissions'; static const String submissions = '/submissions';
static const String submissionCreate = '/submissions/create';
static const String quotes = '/quotes'; static const String quotes = '/quotes';
static const String quoteDetail = '/quotes/:id'; static const String quoteDetail = '/quotes/:id';
static const String quoteCreate = '/quotes/create'; static const String quoteCreate = '/quotes/create';

View File

@@ -11,6 +11,7 @@ import 'package:intl/intl.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/database/models/enums.dart'; import 'package:worker/core/database/models/enums.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/core/utils/extensions.dart';
import 'package:worker/features/orders/presentation/providers/invoices_provider.dart'; import 'package:worker/features/orders/presentation/providers/invoices_provider.dart';
/// Payment Detail Page /// Payment Detail Page
@@ -65,16 +66,12 @@ class PaymentDetailPage extends ConsumerWidget {
orElse: () => invoices.first, orElse: () => invoices.first,
); );
final currencyFormatter = NumberFormat.currency(
locale: 'vi_VN',
symbol: 'đ',
decimalDigits: 0,
);
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 12,
children: [ children: [
// Invoice Header Card // Invoice Header Card
_buildInvoiceHeader( _buildInvoiceHeader(
@@ -82,13 +79,11 @@ class PaymentDetailPage extends ConsumerWidget {
invoice.orderId, invoice.orderId,
invoice.issueDate, invoice.issueDate,
invoice.status, invoice.status,
currencyFormatter,
invoice.totalAmount, invoice.totalAmount,
invoice.amountPaid, invoice.amountPaid,
invoice.amountRemaining, invoice.amountRemaining,
), ),
const SizedBox(height: 16),
// Dates and Customer Info Card // Dates and Customer Info Card
_buildCustomerInfo( _buildCustomerInfo(
@@ -97,26 +92,21 @@ class PaymentDetailPage extends ConsumerWidget {
invoice.isOverdue, invoice.isOverdue,
), ),
const SizedBox(height: 16),
// Product List Card // Product List Card
_buildProductList(), _buildProductList(),
const SizedBox(height: 16),
// Payment History Card // Payment History Card
_buildPaymentHistory( _buildPaymentHistory(
invoice.amountPaid, invoice.amountPaid,
invoice.issueDate, invoice.issueDate
currencyFormatter,
), ),
const SizedBox(height: 16),
// Download Section Card // Download Section Card
_buildDownloadSection(invoice.invoiceNumber), _buildDownloadSection(invoice.invoiceNumber),
const SizedBox(height: 16),
// Support Button // Support Button
Container( Container(
@@ -148,7 +138,6 @@ class PaymentDetailPage extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 12),
// Payment Button // Payment Button
Container( Container(
@@ -195,7 +184,6 @@ class PaymentDetailPage extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 16),
], ],
), ),
); );
@@ -236,7 +224,6 @@ class PaymentDetailPage extends ConsumerWidget {
String? orderId, String? orderId,
DateTime issueDate, DateTime issueDate,
InvoiceStatus status, InvoiceStatus status,
NumberFormat currencyFormatter,
double totalAmount, double totalAmount,
double amountPaid, double amountPaid,
double amountRemaining, double amountRemaining,
@@ -289,12 +276,12 @@ class PaymentDetailPage extends ConsumerWidget {
children: [ children: [
_buildSummaryRow( _buildSummaryRow(
'Tổng tiền hóa đơn:', 'Tổng tiền hóa đơn:',
currencyFormatter.format(totalAmount), totalAmount.toVNCurrency,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildSummaryRow( _buildSummaryRow(
'Đã thanh toán:', 'Đã thanh toán:',
currencyFormatter.format(amountPaid), amountPaid.toVNCurrency,
), ),
const Padding( const Padding(
padding: EdgeInsets.symmetric(vertical: 12), padding: EdgeInsets.symmetric(vertical: 12),
@@ -302,7 +289,7 @@ class PaymentDetailPage extends ConsumerWidget {
), ),
_buildSummaryRow( _buildSummaryRow(
'Còn lại:', 'Còn lại:',
currencyFormatter.format(amountRemaining), amountRemaining.toVNCurrency,
isHighlighted: true, isHighlighted: true,
valueColor: amountRemaining > 0 valueColor: amountRemaining > 0
? AppColors.danger ? AppColors.danger
@@ -560,7 +547,6 @@ class PaymentDetailPage extends ConsumerWidget {
Widget _buildPaymentHistory( Widget _buildPaymentHistory(
double amountPaid, double amountPaid,
DateTime paymentDate, DateTime paymentDate,
NumberFormat currencyFormatter,
) { ) {
final hasHistory = amountPaid > 0; final hasHistory = amountPaid > 0;
@@ -572,11 +558,11 @@ class PaymentDetailPage extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( const Row(
children: [ children: [
FaIcon(FontAwesomeIcons.clockRotateLeft, color: AppColors.primaryBlue, size: 18), FaIcon(FontAwesomeIcons.clockRotateLeft, color: AppColors.primaryBlue, size: 18),
const SizedBox(width: 8), SizedBox(width: 8),
const Text( Text(
'Lịch sử thanh toán', 'Lịch sử thanh toán',
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
@@ -604,10 +590,12 @@ class PaymentDetailPage extends ConsumerWidget {
color: AppColors.success.withValues(alpha: 0.1), color: AppColors.success.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const FaIcon( child: const Center(
FontAwesomeIcons.check, child: FaIcon(
color: AppColors.success, FontAwesomeIcons.check,
size: 18, color: AppColors.success,
size: 18,
),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -643,7 +631,7 @@ class PaymentDetailPage extends ConsumerWidget {
), ),
), ),
Text( Text(
currencyFormatter.format(amountPaid), amountPaid.toVNCurrency,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,

View File

@@ -1,6 +1,6 @@
/// Page: Payments Page /// Page: Payments Page
/// ///
/// Displays list of invoices/payments with tab filters. /// Displays list of invoices/payments.
library; library;
import 'package:flutter/material.dart'; 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:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/database/models/enums.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/orders/data/models/invoice_model.dart'; import 'package:worker/features/orders/data/models/invoice_model.dart';
import 'package:worker/features/orders/presentation/providers/invoices_provider.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 /// Payments Page
/// ///
/// Features: /// Features:
/// - Tab bar for invoice status filtering
/// - List of invoice cards /// - List of invoice cards
/// - Pull-to-refresh /// - Pull-to-refresh
/// - Empty states for each tab /// - Empty state
class PaymentsPage extends ConsumerStatefulWidget { class PaymentsPage extends ConsumerWidget {
const PaymentsPage({super.key}); const PaymentsPage({super.key});
@override @override
ConsumerState<PaymentsPage> createState() => _PaymentsPageState(); Widget build(BuildContext context, WidgetRef ref) {
}
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) {
final invoicesAsync = ref.watch(invoicesProvider); final invoicesAsync = ref.watch(invoicesProvider);
return invoicesAsync.when( return invoicesAsync.when(
data: (allInvoices) { data: (invoices) {
final counts = _getCounts(allInvoices); // Sort by issue date (newest first)
final sortedInvoices = List<InvoiceModel>.from(invoices)
..sort((a, b) => b.issueDate.compareTo(a.issueDate));
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF4F6F8), backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar( appBar: AppBar(
leading: IconButton( 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(), onPressed: () => context.pop(),
), ),
title: const Text( title: const Text(
@@ -150,123 +49,53 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
), ),
elevation: AppBarSpecs.elevation, elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white, backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
centerTitle: false, centerTitle: false,
bottom: TabBar( ),
controller: _tabController, body: sortedInvoices.isEmpty
isScrollable: true, ? _buildEmptyState(ref)
tabAlignment: TabAlignment.start, : RefreshIndicator(
labelColor: AppColors.primaryBlue, onRefresh: () async {
unselectedLabelColor: AppColors.grey500, await ref.read(invoicesProvider.notifier).refresh();
labelStyle: const TextStyle( },
fontSize: 14, child: ListView.builder(
fontWeight: FontWeight.w600, padding: const EdgeInsets.all(16),
), itemCount: sortedInvoices.length,
unselectedLabelStyle: const TextStyle( itemBuilder: (context, index) {
fontSize: 14, final invoice = sortedInvoices[index];
fontWeight: FontWeight.w400, return Padding(
), padding: const EdgeInsets.only(bottom: 12),
indicatorColor: AppColors.primaryBlue, child: InvoiceCard(
indicatorWeight: 3, invoice: invoice,
tabs: _tabs.map((tab) { onTap: () {
final count = counts[tab['key']] ?? 0; context.push('/payments/${invoice.invoiceId}');
return Tab( },
child: Row( onPaymentTap: () {
mainAxisSize: MainAxisSize.min, context.push('/payments/${invoice.invoiceId}');
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(
onRefresh: () async {
print('refresh');
await ref.read(invoicesProvider.notifier).refresh();
},
child: TabBarView(
controller: _tabController,
children: _tabs.map((tab) {
final filteredInvoices = _filterInvoices(
allInvoices,
tab['key']!,
);
return CustomScrollView(
slivers: [
// Invoices List
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: filteredInvoices.isEmpty
? _buildEmptyState(tab['label']!)
: SliverList(
delegate: SliverChildBuilderDelegate((
context,
index,
) {
final invoice = filteredInvoices[index];
return InvoiceCard(
invoice: invoice,
onTap: () {
context.push(
'/payments/${invoice.invoiceId}',
);
},
onPaymentTap: () {
context.push(
'/payments/${invoice.invoiceId}',
);
},
);
}, childCount: filteredInvoices.length),
),
),
],
);
}).toList(),
),
),
); );
}, },
loading: () => Scaffold( loading: () => Scaffold(
backgroundColor: const Color(0xFFF4F6F8), backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar( appBar: AppBar(
leading: IconButton( 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(), onPressed: () => context.pop(),
), ),
title: const Text( title: const Text(
'Danh sách hoá đơn', 'Thanh toán',
style: TextStyle(color: Colors.black), style: TextStyle(color: Colors.black),
), ),
elevation: AppBarSpecs.elevation, elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white, backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
centerTitle: false, centerTitle: false,
), ),
body: const Center(child: CircularProgressIndicator()), body: const Center(child: CircularProgressIndicator()),
@@ -275,16 +104,19 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
backgroundColor: const Color(0xFFF4F6F8), backgroundColor: const Color(0xFFF4F6F8),
appBar: AppBar( appBar: AppBar(
leading: IconButton( 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(), onPressed: () => context.pop(),
), ),
title: const Text( title: const Text(
'Danh sách hoá đơn', 'Thanh toán',
style: TextStyle(color: Colors.black), style: TextStyle(color: Colors.black),
), ),
elevation: AppBarSpecs.elevation, elevation: AppBarSpecs.elevation,
backgroundColor: AppColors.white, backgroundColor: AppColors.white,
foregroundColor: AppColors.grey900,
centerTitle: false, centerTitle: false,
), ),
body: Center( body: Center(
@@ -319,54 +151,44 @@ class _PaymentsPageState extends ConsumerState<PaymentsPage>
} }
/// Build empty state /// Build empty state
Widget _buildEmptyState(String tabLabel) { Widget _buildEmptyState(WidgetRef ref) {
String message; return RefreshIndicator(
IconData icon; onRefresh: () async {
await ref.read(invoicesProvider.notifier).refresh();
switch (tabLabel) { },
case 'Chưa thanh toán': child: ListView(
message = 'Không có hóa đơn chưa thanh toán'; padding: const EdgeInsets.symmetric(horizontal: 16),
icon = FontAwesomeIcons.receipt; children: [
break; SizedBox(
case 'Quá hạn': height: 500,
message = 'Không có hóa đơn quá hạn'; child: Center(
icon = FontAwesomeIcons.triangleExclamation; child: Column(
break; mainAxisAlignment: MainAxisAlignment.center,
case 'Đã thanh toán': children: [
message = 'Không có hóa đơn đã thanh toán'; FaIcon(
icon = FontAwesomeIcons.circleCheck; FontAwesomeIcons.receipt,
break; size: 80,
default: color: AppColors.grey500.withValues(alpha: 0.5),
message = 'Không có hóa đơn nào'; ),
icon = FontAwesomeIcons.receipt; const SizedBox(height: 16),
} const Text(
'Không có hóa đơn nào',
return SliverFillRemaining( style: TextStyle(
child: Center( fontSize: 18,
child: Column( fontWeight: FontWeight.w600,
mainAxisAlignment: MainAxisAlignment.center, color: AppColors.grey500,
children: [ ),
FaIcon( ),
icon, const SizedBox(height: 8),
size: 80, const Text(
color: AppColors.grey500.withValues(alpha: 0.5), 'Kéo xuống để làm mới',
), style: TextStyle(fontSize: 14, color: AppColors.grey500),
const SizedBox(height: 16), ),
Text( ],
message,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.grey500,
), ),
), ),
const SizedBox(height: 8), ),
const Text( ],
'Kéo xuống để làm mới',
style: TextStyle(fontSize: 14, color: AppColors.grey500),
),
],
),
), ),
); );
} }

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

@@ -6,8 +6,10 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:worker/core/constants/ui_constants.dart'; import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/projects/domain/entities/project_submission.dart'; import 'package:worker/features/projects/domain/entities/project_submission.dart';
import 'package:worker/features/projects/presentation/providers/submissions_provider.dart'; import 'package:worker/features/projects/presentation/providers/submissions_provider.dart';
@@ -36,12 +38,7 @@ class SubmissionsPage extends ConsumerWidget {
actions: [ actions: [
IconButton( IconButton(
icon: const FaIcon(FontAwesomeIcons.plus, color: Colors.black, size: 20), icon: const FaIcon(FontAwesomeIcons.plus, color: Colors.black, size: 20),
onPressed: () { onPressed: () => context.push(RouteNames.submissionCreate),
// TODO: Navigate to create submission page
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tạo dự án mới - Đang phát triển')),
);
},
), ),
const SizedBox(width: AppSpacing.sm), const SizedBox(width: AppSpacing.sm),
], ],
@@ -119,17 +116,17 @@ class SubmissionsPage extends ConsumerWidget {
children: [ children: [
SizedBox( SizedBox(
height: MediaQuery.of(context).size.height * 0.5, height: MediaQuery.of(context).size.height * 0.5,
child: Center( child: const Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const FaIcon( FaIcon(
FontAwesomeIcons.folderOpen, FontAwesomeIcons.folderOpen,
size: 64, size: 64,
color: AppColors.grey500, color: AppColors.grey500,
), ),
const SizedBox(height: 16), SizedBox(height: 16),
const Text( Text(
'Không có dự án nào', 'Không có dự án nào',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
@@ -137,8 +134,8 @@ class SubmissionsPage extends ConsumerWidget {
color: AppColors.grey900, color: AppColors.grey900,
), ),
), ),
const SizedBox(height: 8), SizedBox(height: 8),
const Text( Text(
'Không tìm thấy dự án phù hợp', 'Không tìm thấy dự án phù hợp',
style: TextStyle(color: AppColors.grey500), style: TextStyle(color: AppColors.grey500),
), ),
@@ -264,14 +261,6 @@ class SubmissionsPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: _getStatusColor(submission.status),
width: 4,
),
),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [