add quotes
This commit is contained in:
@@ -27,6 +27,9 @@ class HiveBoxNames {
|
||||
/// Construction projects
|
||||
static const String projectBox = 'project_box';
|
||||
|
||||
/// Quote requests and quotations
|
||||
static const String quotes = 'quotes_box';
|
||||
|
||||
/// Loyalty program transactions and points history
|
||||
static const String loyaltyBox = 'loyalty_box';
|
||||
|
||||
@@ -61,6 +64,7 @@ class HiveBoxNames {
|
||||
cartBox,
|
||||
orderBox,
|
||||
projectBox,
|
||||
quotes,
|
||||
loyaltyBox,
|
||||
rewardsBox,
|
||||
settingsBox,
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:worker/features/orders/presentation/pages/payments_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/promotions/presentation/pages/promotion_detail_page.dart';
|
||||
import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
|
||||
|
||||
/// App Router
|
||||
///
|
||||
@@ -156,6 +157,16 @@ class AppRouter {
|
||||
},
|
||||
),
|
||||
|
||||
// Quotes Route
|
||||
GoRoute(
|
||||
path: RouteNames.quotes,
|
||||
name: RouteNames.quotes,
|
||||
pageBuilder: (context, state) => MaterialPage(
|
||||
key: state.pageKey,
|
||||
child: const QuotesPage(),
|
||||
),
|
||||
),
|
||||
|
||||
// TODO: Add more routes as features are implemented
|
||||
],
|
||||
|
||||
|
||||
@@ -182,8 +182,7 @@ class HomePage extends ConsumerWidget {
|
||||
QuickAction(
|
||||
icon: Icons.description,
|
||||
label: 'Yêu cầu báo giá',
|
||||
onTap: () =>
|
||||
_showComingSoon(context, 'Yêu cầu báo giá', l10n),
|
||||
onTap: () => context.push(RouteNames.quotes),
|
||||
),
|
||||
QuickAction(
|
||||
icon: Icons.inventory_2,
|
||||
|
||||
@@ -340,7 +340,7 @@ final class TotalInvoicesAmountProvider
|
||||
}
|
||||
|
||||
String _$totalInvoicesAmountHash() =>
|
||||
r'7800e2be935dfe91d382957539b151bbf4f936fe';
|
||||
r'f311abe063d4f9c2e25a13acac6202b1cce72754';
|
||||
|
||||
/// Total Unpaid Amount Provider
|
||||
///
|
||||
@@ -384,4 +384,4 @@ final class TotalUnpaidAmountProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$totalUnpaidAmountHash() => r'9a81800149d8809e1c3be065bc3c5357792c4aee';
|
||||
String _$totalUnpaidAmountHash() => r'8cde697cbede5d799208fe5eca0cc0822ca608a4';
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/// Data Source: Quotes Local Data Source
|
||||
///
|
||||
/// Handles local storage operations for quotes using Hive.
|
||||
library;
|
||||
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:worker/core/constants/storage_constants.dart';
|
||||
import 'package:worker/core/database/models/enums.dart';
|
||||
import 'package:worker/features/quotes/data/models/quote_model.dart';
|
||||
|
||||
/// Quotes Local Data Source
|
||||
///
|
||||
/// Provides methods to interact with locally stored quote data.
|
||||
class QuotesLocalDataSource {
|
||||
Box<QuoteModel>? _quotesBox;
|
||||
|
||||
/// Get Hive box for quotes
|
||||
Future<Box<QuoteModel>> get quotesBox async {
|
||||
_quotesBox ??= await Hive.openBox<QuoteModel>(HiveBoxNames.quotes);
|
||||
return _quotesBox!;
|
||||
}
|
||||
|
||||
/// Get all quotes
|
||||
Future<List<QuoteModel>> getAllQuotes() async {
|
||||
final box = await quotesBox;
|
||||
return box.values.toList();
|
||||
}
|
||||
|
||||
/// Get quote by ID
|
||||
Future<QuoteModel?> getQuoteById(String quoteId) async {
|
||||
final box = await quotesBox;
|
||||
return box.values.firstWhere(
|
||||
(quote) => quote.quoteId == quoteId,
|
||||
orElse: () => throw Exception('Quote not found'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Save quote
|
||||
Future<void> saveQuote(QuoteModel quote) async {
|
||||
final box = await quotesBox;
|
||||
await box.put(quote.quoteId, quote);
|
||||
}
|
||||
|
||||
/// Save multiple quotes
|
||||
Future<void> saveQuotes(List<QuoteModel> quotes) async {
|
||||
final box = await quotesBox;
|
||||
final Map<String, QuoteModel> quotesMap = {
|
||||
for (var quote in quotes) quote.quoteId: quote,
|
||||
};
|
||||
await box.putAll(quotesMap);
|
||||
}
|
||||
|
||||
/// Delete quote
|
||||
Future<void> deleteQuote(String quoteId) async {
|
||||
final box = await quotesBox;
|
||||
await box.delete(quoteId);
|
||||
}
|
||||
|
||||
/// Clear all quotes
|
||||
Future<void> clearQuotes() async {
|
||||
final box = await quotesBox;
|
||||
await box.clear();
|
||||
}
|
||||
|
||||
/// Get quotes by status
|
||||
Future<List<QuoteModel>> getQuotesByStatus(QuoteStatus status) async {
|
||||
final quotes = await getAllQuotes();
|
||||
return quotes.where((quote) => quote.status == status).toList();
|
||||
}
|
||||
|
||||
/// Search quotes
|
||||
Future<List<QuoteModel>> searchQuotes(String query) async {
|
||||
final quotes = await getAllQuotes();
|
||||
final lowerQuery = query.toLowerCase();
|
||||
|
||||
return quotes.where((quote) {
|
||||
final matchesNumber = quote.quoteNumber.toLowerCase().contains(lowerQuery);
|
||||
final matchesProject =
|
||||
quote.projectName?.toLowerCase().contains(lowerQuery) ?? false;
|
||||
return matchesNumber || matchesProject;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Seed mock data for development
|
||||
Future<void> seedMockQuotes() async {
|
||||
final mockQuotes = [
|
||||
QuoteModel(
|
||||
quoteId: 'quote_001',
|
||||
quoteNumber: 'YC001234',
|
||||
userId: 'user_001',
|
||||
status: QuoteStatus.viewed,
|
||||
totalAmount: 45000000,
|
||||
discountAmount: 2250000,
|
||||
finalAmount: 42750000,
|
||||
projectName: 'Villa Thủ Đức - Giai đoạn 2',
|
||||
notes: 'Khách hàng yêu cầu giảm giá 5%',
|
||||
validUntil: DateTime.now().add(const Duration(days: 30)),
|
||||
createdAt: DateTime(2023, 8, 5),
|
||||
),
|
||||
QuoteModel(
|
||||
quoteId: 'quote_002',
|
||||
quoteNumber: 'YC001233',
|
||||
userId: 'user_001',
|
||||
status: QuoteStatus.accepted,
|
||||
totalAmount: 125500000,
|
||||
discountAmount: 0,
|
||||
finalAmount: 125500000,
|
||||
projectName: 'Chung cư Landmark Center',
|
||||
notes: 'Tổng giá trị: 125.500.000 VND',
|
||||
validUntil: DateTime.now().add(const Duration(days: 45)),
|
||||
createdAt: DateTime(2023, 8, 3),
|
||||
),
|
||||
QuoteModel(
|
||||
quoteId: 'quote_003',
|
||||
quoteNumber: 'YC001232',
|
||||
userId: 'user_001',
|
||||
status: QuoteStatus.converted,
|
||||
totalAmount: 32500000,
|
||||
discountAmount: 500000,
|
||||
finalAmount: 32000000,
|
||||
projectName: 'Nhà phố Bình Thạnh',
|
||||
notes: 'Mã đơn hàng: #DH005432',
|
||||
convertedOrderId: 'order_005432',
|
||||
validUntil: DateTime.now().subtract(const Duration(days: 5)),
|
||||
createdAt: DateTime(2023, 8, 1),
|
||||
),
|
||||
QuoteModel(
|
||||
quoteId: 'quote_004',
|
||||
quoteNumber: 'YC001231',
|
||||
userId: 'user_001',
|
||||
status: QuoteStatus.sent,
|
||||
totalAmount: 78000000,
|
||||
discountAmount: 3000000,
|
||||
finalAmount: 75000000,
|
||||
projectName: 'Văn phòng Quận 7',
|
||||
notes: 'Chờ khách hàng phản hồi',
|
||||
validUntil: DateTime.now().add(const Duration(days: 20)),
|
||||
createdAt: DateTime(2023, 7, 31),
|
||||
),
|
||||
QuoteModel(
|
||||
quoteId: 'quote_005',
|
||||
quoteNumber: 'YC001230',
|
||||
userId: 'user_001',
|
||||
status: QuoteStatus.draft,
|
||||
totalAmount: 185000000,
|
||||
discountAmount: 5000000,
|
||||
finalAmount: 180000000,
|
||||
projectName: 'Resort Vũng Tàu',
|
||||
notes: 'Yêu cầu báo giá cho khu vực pool và spa',
|
||||
validUntil: DateTime.now().add(const Duration(days: 60)),
|
||||
createdAt: DateTime(2023, 7, 29),
|
||||
),
|
||||
QuoteModel(
|
||||
quoteId: 'quote_006',
|
||||
quoteNumber: 'YC001229',
|
||||
userId: 'user_001',
|
||||
status: QuoteStatus.cancelled,
|
||||
totalAmount: 56000000,
|
||||
discountAmount: 1000000,
|
||||
finalAmount: 55000000,
|
||||
projectName: 'Showroom Quận 1',
|
||||
notes: 'Khách hàng hủy dự án',
|
||||
validUntil: DateTime.now().subtract(const Duration(days: 10)),
|
||||
createdAt: DateTime(2023, 7, 25),
|
||||
),
|
||||
];
|
||||
|
||||
await saveQuotes(mockQuotes);
|
||||
}
|
||||
}
|
||||
513
lib/features/quotes/presentation/pages/quotes_page.dart
Normal file
513
lib/features/quotes/presentation/pages/quotes_page.dart
Normal file
@@ -0,0 +1,513 @@
|
||||
/// Page: Quotes Page
|
||||
///
|
||||
/// Displays list of quote requests with search and filter functionality.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/database/models/enums.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/quotes/data/models/quote_model.dart';
|
||||
import 'package:worker/features/quotes/presentation/providers/quotes_provider.dart';
|
||||
|
||||
/// Quotes Page
|
||||
///
|
||||
/// Features:
|
||||
/// - Search bar for quote numbers and project names
|
||||
/// - Filter section
|
||||
/// - List of quote request cards with status indicators
|
||||
/// - Pull-to-refresh
|
||||
class QuotesPage extends ConsumerStatefulWidget {
|
||||
const QuotesPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<QuotesPage> createState() => _QuotesPageState();
|
||||
}
|
||||
|
||||
class _QuotesPageState extends ConsumerState<QuotesPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController.addListener(_onSearchChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController..removeListener(_onSearchChanged)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged() {
|
||||
ref.read(quoteSearchQueryProvider.notifier).updateQuery(
|
||||
_searchController.text,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filteredQuotesAsync = ref.watch(filteredQuotesProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF4F6F8),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: const Text(
|
||||
'Yêu cầu báo giá',
|
||||
style: TextStyle(color: Colors.black),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, color: Colors.black),
|
||||
onPressed: () {
|
||||
// TODO: Navigate to quote create page
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Tạo yêu cầu báo giá mới')),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
elevation: AppBarSpecs.elevation,
|
||||
backgroundColor: AppColors.white,
|
||||
foregroundColor: AppColors.grey900,
|
||||
centerTitle: false,
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await ref.read(quotesProvider.notifier).refresh();
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// Search Bar and Filter Button
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _buildSearchAndFilterRow(),
|
||||
),
|
||||
),
|
||||
|
||||
// Quotes List
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
sliver: filteredQuotesAsync.when(
|
||||
data: (quotes) {
|
||||
if (quotes.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final quote = quotes[index];
|
||||
return _buildQuoteCard(quote);
|
||||
},
|
||||
childCount: quotes.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => _buildLoadingState(),
|
||||
error: (error, stack) => _buildErrorState(error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build search bar and filter button row
|
||||
Widget _buildSearchAndFilterRow() {
|
||||
return Row(
|
||||
children: [
|
||||
// Search Bar
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Tìm theo mã yêu cầu hoặc tên dự án',
|
||||
hintStyle: const TextStyle(
|
||||
color: AppColors.grey500,
|
||||
fontSize: 14,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, color: AppColors.grey500),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Filter Button
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
_showFilterDialog();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.filter_list,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
iconSize: 24,
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 48,
|
||||
minHeight: 48,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Build quote card
|
||||
Widget _buildQuoteCard(QuoteModel quote) {
|
||||
final dateFormatter = DateFormat('dd/MM/yyyy');
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// TODO: Navigate to quote detail
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Chi tiết báo giá ${quote.quoteNumber}')),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Quote ID and Date
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'#${quote.quoteNumber}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
dateFormatter.format(quote.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Project Name
|
||||
if (quote.projectName != null)
|
||||
Text(
|
||||
'Dự án: ${quote.projectName}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Additional Info (placeholder for now)
|
||||
const Text(
|
||||
'5 sản phẩm - Diện tích: 200m²',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Status Badge
|
||||
_buildStatusBadge(quote.status),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Notes
|
||||
if (quote.notes != null && quote.notes!.isNotEmpty)
|
||||
Text(
|
||||
quote.notes!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.grey500,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build status badge
|
||||
Widget _buildStatusBadge(QuoteStatus status) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(status).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _getStatusColor(status).withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_getStatusText(status),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getStatusColor(status),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get status color
|
||||
Color _getStatusColor(QuoteStatus status) {
|
||||
switch (status) {
|
||||
case QuoteStatus.draft:
|
||||
return AppColors.warning;
|
||||
case QuoteStatus.sent:
|
||||
return AppColors.info;
|
||||
case QuoteStatus.viewed:
|
||||
return const Color(0xFFF59E0B); // yellow/orange
|
||||
case QuoteStatus.accepted:
|
||||
return AppColors.primaryBlue;
|
||||
case QuoteStatus.rejected:
|
||||
return AppColors.danger;
|
||||
case QuoteStatus.expired:
|
||||
return AppColors.grey500;
|
||||
case QuoteStatus.converted:
|
||||
return AppColors.success;
|
||||
case QuoteStatus.cancelled:
|
||||
return AppColors.grey500;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get status text
|
||||
String _getStatusText(QuoteStatus status) {
|
||||
switch (status) {
|
||||
case QuoteStatus.draft:
|
||||
return 'Chờ duyệt';
|
||||
case QuoteStatus.sent:
|
||||
return 'Đã gửi';
|
||||
case QuoteStatus.viewed:
|
||||
return 'Đang đàm phán';
|
||||
case QuoteStatus.accepted:
|
||||
return 'Đã chốt';
|
||||
case QuoteStatus.rejected:
|
||||
return 'Từ chối';
|
||||
case QuoteStatus.expired:
|
||||
return 'Hết hạn';
|
||||
case QuoteStatus.converted:
|
||||
return 'Đã thành đơn hàng';
|
||||
case QuoteStatus.cancelled:
|
||||
return 'Đã hủy';
|
||||
}
|
||||
}
|
||||
|
||||
/// Show filter dialog
|
||||
void _showFilterDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final selectedStatus = ref.read(selectedQuoteStatusProvider);
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Lọc theo trạng thái'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildFilterOption(
|
||||
context,
|
||||
'Tất cả',
|
||||
null,
|
||||
selectedStatus == null,
|
||||
),
|
||||
...QuoteStatus.values.map((status) {
|
||||
return _buildFilterOption(
|
||||
context,
|
||||
_getStatusText(status),
|
||||
status,
|
||||
selectedStatus == status,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Đóng'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build filter option
|
||||
Widget _buildFilterOption(
|
||||
BuildContext context,
|
||||
String label,
|
||||
QuoteStatus? status,
|
||||
bool isSelected,
|
||||
) {
|
||||
return RadioListTile<QuoteStatus?>(
|
||||
title: Text(label),
|
||||
value: status,
|
||||
groupValue: ref.watch(selectedQuoteStatusProvider),
|
||||
onChanged: (value) {
|
||||
ref.read(selectedQuoteStatusProvider.notifier).selectStatus(value);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
activeColor: AppColors.primaryBlue,
|
||||
);
|
||||
}
|
||||
|
||||
/// Build empty state
|
||||
Widget _buildEmptyState() {
|
||||
return SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 80,
|
||||
color: AppColors.grey500.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Không có yêu cầu báo giá nào',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Kéo xuống để làm mới',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build loading state
|
||||
Widget _buildLoadingState() {
|
||||
return const SliverFillRemaining(
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build error state
|
||||
Widget _buildErrorState(Object error) {
|
||||
return SliverFillRemaining(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 80,
|
||||
color: AppColors.danger.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Có lỗi xảy ra',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.grey900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
138
lib/features/quotes/presentation/providers/quotes_provider.dart
Normal file
138
lib/features/quotes/presentation/providers/quotes_provider.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
/// Providers: Quotes
|
||||
///
|
||||
/// Riverpod providers for managing quotes state.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/core/database/models/enums.dart';
|
||||
import 'package:worker/features/quotes/data/datasources/quotes_local_datasource.dart';
|
||||
import 'package:worker/features/quotes/data/models/quote_model.dart';
|
||||
|
||||
part 'quotes_provider.g.dart';
|
||||
|
||||
/// Quotes Local Data Source Provider
|
||||
@riverpod
|
||||
QuotesLocalDataSource quotesLocalDataSource(Ref ref) {
|
||||
return QuotesLocalDataSource();
|
||||
}
|
||||
|
||||
/// Quotes Provider
|
||||
///
|
||||
/// Provides list of all quotes from local data source.
|
||||
@riverpod
|
||||
class Quotes extends _$Quotes {
|
||||
@override
|
||||
Future<List<QuoteModel>> build() async {
|
||||
final datasource = ref.read(quotesLocalDataSourceProvider);
|
||||
|
||||
// Seed mock data on first load
|
||||
final quotes = await datasource.getAllQuotes();
|
||||
if (quotes.isEmpty) {
|
||||
await datasource.seedMockQuotes();
|
||||
return await datasource.getAllQuotes();
|
||||
}
|
||||
|
||||
return quotes;
|
||||
}
|
||||
|
||||
/// Refresh quotes
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
return await ref.read(quotesLocalDataSourceProvider).getAllQuotes();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Quote Search Query Provider
|
||||
@riverpod
|
||||
class QuoteSearchQuery extends _$QuoteSearchQuery {
|
||||
@override
|
||||
String build() {
|
||||
return '';
|
||||
}
|
||||
|
||||
void updateQuery(String query) {
|
||||
state = query;
|
||||
}
|
||||
|
||||
void clearQuery() {
|
||||
state = '';
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected Quote Status Filter Provider
|
||||
@riverpod
|
||||
class SelectedQuoteStatus extends _$SelectedQuoteStatus {
|
||||
@override
|
||||
QuoteStatus? build() {
|
||||
return null; // null means show all statuses
|
||||
}
|
||||
|
||||
void selectStatus(QuoteStatus? status) {
|
||||
state = status;
|
||||
}
|
||||
|
||||
void clearFilter() {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered Quotes Provider
|
||||
///
|
||||
/// Filters quotes by search query and status.
|
||||
@riverpod
|
||||
Future<List<QuoteModel>> filteredQuotes(Ref ref) async {
|
||||
final quotesAsync = ref.watch(quotesProvider);
|
||||
final searchQuery = ref.watch(quoteSearchQueryProvider);
|
||||
final selectedStatus = ref.watch(selectedQuoteStatusProvider);
|
||||
|
||||
return quotesAsync.when(
|
||||
data: (quotes) {
|
||||
var filtered = quotes;
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final lowerQuery = searchQuery.toLowerCase();
|
||||
filtered = filtered.where((quote) {
|
||||
final matchesNumber = quote.quoteNumber.toLowerCase().contains(lowerQuery);
|
||||
final matchesProject =
|
||||
quote.projectName?.toLowerCase().contains(lowerQuery) ?? false;
|
||||
return matchesNumber || matchesProject;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (selectedStatus != null) {
|
||||
filtered = filtered.where((quote) => quote.status == selectedStatus).toList();
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
filtered.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
|
||||
return filtered;
|
||||
},
|
||||
loading: () => [],
|
||||
error: (error, stack) => [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Quotes Count by Status Provider
|
||||
@riverpod
|
||||
Future<Map<QuoteStatus, int>> quotesCountByStatus(Ref ref) async {
|
||||
final quotesAsync = ref.watch(quotesProvider);
|
||||
|
||||
return quotesAsync.when(
|
||||
data: (quotes) {
|
||||
final counts = <QuoteStatus, int>{};
|
||||
|
||||
for (final status in QuoteStatus.values) {
|
||||
counts[status] = quotes.where((quote) => quote.status == status).length;
|
||||
}
|
||||
|
||||
return counts;
|
||||
},
|
||||
loading: () => {},
|
||||
error: (error, stack) => {},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'quotes_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Quotes Local Data Source Provider
|
||||
|
||||
@ProviderFor(quotesLocalDataSource)
|
||||
const quotesLocalDataSourceProvider = QuotesLocalDataSourceProvider._();
|
||||
|
||||
/// Quotes Local Data Source Provider
|
||||
|
||||
final class QuotesLocalDataSourceProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
QuotesLocalDataSource,
|
||||
QuotesLocalDataSource,
|
||||
QuotesLocalDataSource
|
||||
>
|
||||
with $Provider<QuotesLocalDataSource> {
|
||||
/// Quotes Local Data Source Provider
|
||||
const QuotesLocalDataSourceProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'quotesLocalDataSourceProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$quotesLocalDataSourceHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<QuotesLocalDataSource> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
QuotesLocalDataSource create(Ref ref) {
|
||||
return quotesLocalDataSource(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(QuotesLocalDataSource value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<QuotesLocalDataSource>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$quotesLocalDataSourceHash() =>
|
||||
r'02a822db926d8d80460bcc27a08ea494dff6c441';
|
||||
|
||||
/// Quotes Provider
|
||||
///
|
||||
/// Provides list of all quotes from local data source.
|
||||
|
||||
@ProviderFor(Quotes)
|
||||
const quotesProvider = QuotesProvider._();
|
||||
|
||||
/// Quotes Provider
|
||||
///
|
||||
/// Provides list of all quotes from local data source.
|
||||
final class QuotesProvider
|
||||
extends $AsyncNotifierProvider<Quotes, List<QuoteModel>> {
|
||||
/// Quotes Provider
|
||||
///
|
||||
/// Provides list of all quotes from local data source.
|
||||
const QuotesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'quotesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$quotesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
Quotes create() => Quotes();
|
||||
}
|
||||
|
||||
String _$quotesHash() => r'f5011c354218f10da95af16269c956416fca3303';
|
||||
|
||||
/// Quotes Provider
|
||||
///
|
||||
/// Provides list of all quotes from local data source.
|
||||
|
||||
abstract class _$Quotes extends $AsyncNotifier<List<QuoteModel>> {
|
||||
FutureOr<List<QuoteModel>> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<List<QuoteModel>>, List<QuoteModel>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<List<QuoteModel>>, List<QuoteModel>>,
|
||||
AsyncValue<List<QuoteModel>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Quote Search Query Provider
|
||||
|
||||
@ProviderFor(QuoteSearchQuery)
|
||||
const quoteSearchQueryProvider = QuoteSearchQueryProvider._();
|
||||
|
||||
/// Quote Search Query Provider
|
||||
final class QuoteSearchQueryProvider
|
||||
extends $NotifierProvider<QuoteSearchQuery, String> {
|
||||
/// Quote Search Query Provider
|
||||
const QuoteSearchQueryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'quoteSearchQueryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$quoteSearchQueryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
QuoteSearchQuery create() => QuoteSearchQuery();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(String value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<String>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$quoteSearchQueryHash() => r'cc889177beb9905a80b223cba14612bb0c419103';
|
||||
|
||||
/// Quote Search Query Provider
|
||||
|
||||
abstract class _$QuoteSearchQuery extends $Notifier<String> {
|
||||
String build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<String, String>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<String, String>,
|
||||
String,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected Quote Status Filter Provider
|
||||
|
||||
@ProviderFor(SelectedQuoteStatus)
|
||||
const selectedQuoteStatusProvider = SelectedQuoteStatusProvider._();
|
||||
|
||||
/// Selected Quote Status Filter Provider
|
||||
final class SelectedQuoteStatusProvider
|
||||
extends $NotifierProvider<SelectedQuoteStatus, QuoteStatus?> {
|
||||
/// Selected Quote Status Filter Provider
|
||||
const SelectedQuoteStatusProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'selectedQuoteStatusProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$selectedQuoteStatusHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
SelectedQuoteStatus create() => SelectedQuoteStatus();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(QuoteStatus? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<QuoteStatus?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$selectedQuoteStatusHash() =>
|
||||
r'5c1ecf114bbb85a00174dad255c939288722658b';
|
||||
|
||||
/// Selected Quote Status Filter Provider
|
||||
|
||||
abstract class _$SelectedQuoteStatus extends $Notifier<QuoteStatus?> {
|
||||
QuoteStatus? build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<QuoteStatus?, QuoteStatus?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<QuoteStatus?, QuoteStatus?>,
|
||||
QuoteStatus?,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtered Quotes Provider
|
||||
///
|
||||
/// Filters quotes by search query and status.
|
||||
|
||||
@ProviderFor(filteredQuotes)
|
||||
const filteredQuotesProvider = FilteredQuotesProvider._();
|
||||
|
||||
/// Filtered Quotes Provider
|
||||
///
|
||||
/// Filters quotes by search query and status.
|
||||
|
||||
final class FilteredQuotesProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<QuoteModel>>,
|
||||
List<QuoteModel>,
|
||||
FutureOr<List<QuoteModel>>
|
||||
>
|
||||
with $FutureModifier<List<QuoteModel>>, $FutureProvider<List<QuoteModel>> {
|
||||
/// Filtered Quotes Provider
|
||||
///
|
||||
/// Filters quotes by search query and status.
|
||||
const FilteredQuotesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'filteredQuotesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$filteredQuotesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<QuoteModel>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<QuoteModel>> create(Ref ref) {
|
||||
return filteredQuotes(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$filteredQuotesHash() => r'77076cfa483cb81cc56972bca6a3c1e97861165c';
|
||||
|
||||
/// Quotes Count by Status Provider
|
||||
|
||||
@ProviderFor(quotesCountByStatus)
|
||||
const quotesCountByStatusProvider = QuotesCountByStatusProvider._();
|
||||
|
||||
/// Quotes Count by Status Provider
|
||||
|
||||
final class QuotesCountByStatusProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<Map<QuoteStatus, int>>,
|
||||
Map<QuoteStatus, int>,
|
||||
FutureOr<Map<QuoteStatus, int>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<Map<QuoteStatus, int>>,
|
||||
$FutureProvider<Map<QuoteStatus, int>> {
|
||||
/// Quotes Count by Status Provider
|
||||
const QuotesCountByStatusProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'quotesCountByStatusProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$quotesCountByStatusHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<Map<QuoteStatus, int>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<Map<QuoteStatus, int>> create(Ref ref) {
|
||||
return quotesCountByStatus(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$quotesCountByStatusHash() =>
|
||||
r'474b62ad0ccf890df1c33c64a17f9a0f428f676e';
|
||||
Reference in New Issue
Block a user