diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 4a11dd8..422514a 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -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'; @@ -47,6 +48,7 @@ import 'package:worker/features/promotions/presentation/pages/promotion_detail_p 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 @@ -273,6 +275,14 @@ final routerProvider = Provider((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, @@ -467,6 +477,19 @@ final routerProvider = Provider((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, @@ -558,6 +581,7 @@ class RouteNames { static const String loyalty = '/loyalty'; 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'; @@ -603,6 +627,7 @@ class RouteNames { // Model Houses & Design Requests Routes static const String modelHouses = '/model-houses'; + static const String modelHouseDetail = '/model-houses/:id'; static const String designRequestCreate = '/model-houses/design-request/create'; static const String designRequestDetail = '/model-houses/design-request/:id'; diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 97aa753..d8ce2b1 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -197,8 +197,7 @@ class _HomePageState extends ConsumerState { 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, diff --git a/lib/features/loyalty/data/datasources/points_record_remote_datasource.dart b/lib/features/loyalty/data/datasources/points_record_remote_datasource.dart new file mode 100644 index 0000000..43c7888 --- /dev/null +++ b/lib/features/loyalty/data/datasources/points_record_remote_datasource.dart @@ -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> getPointsRecords(); + + /// Get single points record by ID + Future getPointsRecordById(String recordId); + + /// Submit new points record + Future submitPointsRecord(PointsRecord record); +} + +/// Points Record Remote Data Source Implementation (Mock) +class PointsRecordRemoteDataSourceImpl + implements PointsRecordRemoteDataSource { + @override + Future> getPointsRecords() async { + await Future.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 getPointsRecordById(String recordId) async { + await Future.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 submitPointsRecord(PointsRecord record) async { + await Future.delayed(const Duration(milliseconds: 800)); + + // Simulate successful submission + return record.copyWith( + recordId: 'PRR${DateTime.now().millisecondsSinceEpoch}', + status: PointsStatus.pending, + submittedAt: DateTime.now(), + ); + } +} diff --git a/lib/features/loyalty/data/repositories/points_record_repository_impl.dart b/lib/features/loyalty/data/repositories/points_record_repository_impl.dart new file mode 100644 index 0000000..e8b08b7 --- /dev/null +++ b/lib/features/loyalty/data/repositories/points_record_repository_impl.dart @@ -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> getPointsRecords() async { + try { + return await _remoteDataSource.getPointsRecords(); + } catch (e) { + rethrow; + } + } + + @override + Future getPointsRecordById(String recordId) async { + try { + return await _remoteDataSource.getPointsRecordById(recordId); + } catch (e) { + rethrow; + } + } + + @override + Future submitPointsRecord(PointsRecord record) async { + try { + return await _remoteDataSource.submitPointsRecord(record); + } catch (e) { + rethrow; + } + } +} diff --git a/lib/features/loyalty/domain/entities/points_record.dart b/lib/features/loyalty/domain/entities/points_record.dart index ff92d9e..9923cec 100644 --- a/lib/features/loyalty/domain/entities/points_record.dart +++ b/lib/features/loyalty/domain/entities/points_record.dart @@ -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'; } } } diff --git a/lib/features/loyalty/domain/repositories/points_record_repository.dart b/lib/features/loyalty/domain/repositories/points_record_repository.dart new file mode 100644 index 0000000..b6123b3 --- /dev/null +++ b/lib/features/loyalty/domain/repositories/points_record_repository.dart @@ -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> getPointsRecords(); + + /// Get single points record by ID + Future getPointsRecordById(String recordId); + + /// Submit new points record + Future submitPointsRecord(PointsRecord record); +} diff --git a/lib/features/loyalty/domain/usecases/get_points_records.dart b/lib/features/loyalty/domain/usecases/get_points_records.dart new file mode 100644 index 0000000..ce50b8f --- /dev/null +++ b/lib/features/loyalty/domain/usecases/get_points_records.dart @@ -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> call() async { + return await _repository.getPointsRecords(); + } +} diff --git a/lib/features/loyalty/presentation/pages/points_records_page.dart b/lib/features/loyalty/presentation/pages/points_records_page.dart new file mode 100644 index 0000000..966d043 --- /dev/null +++ b/lib/features/loyalty/presentation/pages/points_records_page.dart @@ -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; + } + } +} diff --git a/lib/features/loyalty/presentation/providers/points_records_provider.dart b/lib/features/loyalty/presentation/providers/points_records_provider.dart new file mode 100644 index 0000000..693a209 --- /dev/null +++ b/lib/features/loyalty/presentation/providers/points_records_provider.dart @@ -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> build() async { + final useCase = ref.watch(getPointsRecordsProvider); + return await useCase(); + } + + /// Refresh points records + Future 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> 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; + }); +} diff --git a/lib/features/loyalty/presentation/providers/points_records_provider.g.dart b/lib/features/loyalty/presentation/providers/points_records_provider.g.dart new file mode 100644 index 0000000..9dd39c8 --- /dev/null +++ b/lib/features/loyalty/presentation/providers/points_records_provider.g.dart @@ -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 { + /// 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 $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(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 { + /// 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 $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(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 { + /// 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 $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(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> { + /// 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> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier>, List>, + AsyncValue>, + 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>, + AsyncValue>, + AsyncValue> + > + with $Provider>> { + /// 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>> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + AsyncValue> create(Ref ref) { + return filteredPointsRecords(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AsyncValue> value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>>( + value, + ), + ); + } +} + +String _$filteredPointsRecordsHash() => + r'afb0691b799f053b5c7fff2f8b64065917b5cd33'; diff --git a/lib/features/showrooms/presentation/pages/model_house_detail_page.dart b/lib/features/showrooms/presentation/pages/model_house_detail_page.dart new file mode 100644 index 0000000..85741fe --- /dev/null +++ b/lib/features/showrooms/presentation/pages/model_house_detail_page.dart @@ -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 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>) + .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 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 modelData, + ) { + final images = modelData['images'] as List>; + + 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 _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> images, + int initialIndex, + ) { + showDialog( + context: context, + barrierColor: Colors.black87, + builder: (context) => _ImageViewerDialog( + images: images, + initialIndex: initialIndex, + ), + ); + } + + void _shareModel(BuildContext context, Map modelData) { + Share.share( + 'Xem mô hình 360° ${modelData['title']}\n${modelData['url360']}', + subject: modelData['title'] as String, + ); + } + + Map _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> 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, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/showrooms/presentation/pages/model_houses_page.dart b/lib/features/showrooms/presentation/pages/model_houses_page.dart index 3e64bbf..6a6c50c 100644 --- a/lib/features/showrooms/presentation/pages/model_houses_page.dart +++ b/lib/features/showrooms/presentation/pages/model_houses_page.dart @@ -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(