From 57bf73e4d1368637951fd473d822b88edc90003f Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Fri, 17 Oct 2025 17:49:01 +0700 Subject: [PATCH] update --- CLAUDE.md | 85 ----- lib/app.dart | 5 +- .../datasources/home_local_datasource.dart | 189 +++++++++++ .../home/data/models/member_card_model.dart | 184 +++++++++++ .../home/data/models/member_card_model.g.dart | 59 ++++ .../home/data/models/promotion_model.dart | 207 ++++++++++++ .../home/data/models/promotion_model.g.dart | 68 ++++ .../repositories/home_repository_impl.dart | 161 +++++++++ .../home/domain/entities/member_card.dart | 169 ++++++++++ .../home/domain/entities/promotion.dart | 176 ++++++++++ .../domain/repositories/home_repository.dart | 49 +++ .../home/domain/usecases/get_member_card.dart | 62 ++++ .../home/domain/usecases/get_promotions.dart | 84 +++++ .../home/presentation/pages/home_page.dart | 312 ++++++++++++++++++ .../providers/member_card_provider.dart | 63 ++++ .../providers/member_card_provider.g.dart | 121 +++++++ .../providers/promotions_provider.dart | 63 ++++ .../providers/promotions_provider.g.dart | 121 +++++++ .../widgets/member_card_widget.dart | 192 +++++++++++ .../widgets/promotion_slider.dart | 164 +++++++++ .../widgets/quick_action_item.dart | 109 ++++++ .../widgets/quick_action_section.dart | 93 ++++++ lib/hive_registrar.g.dart | 6 + 23 files changed, 2655 insertions(+), 87 deletions(-) create mode 100644 lib/features/home/data/datasources/home_local_datasource.dart create mode 100644 lib/features/home/data/models/member_card_model.dart create mode 100644 lib/features/home/data/models/member_card_model.g.dart create mode 100644 lib/features/home/data/models/promotion_model.dart create mode 100644 lib/features/home/data/models/promotion_model.g.dart create mode 100644 lib/features/home/data/repositories/home_repository_impl.dart create mode 100644 lib/features/home/domain/entities/member_card.dart create mode 100644 lib/features/home/domain/entities/promotion.dart create mode 100644 lib/features/home/domain/repositories/home_repository.dart create mode 100644 lib/features/home/domain/usecases/get_member_card.dart create mode 100644 lib/features/home/domain/usecases/get_promotions.dart create mode 100644 lib/features/home/presentation/pages/home_page.dart create mode 100644 lib/features/home/presentation/providers/member_card_provider.dart create mode 100644 lib/features/home/presentation/providers/member_card_provider.g.dart create mode 100644 lib/features/home/presentation/providers/promotions_provider.dart create mode 100644 lib/features/home/presentation/providers/promotions_provider.g.dart create mode 100644 lib/features/home/presentation/widgets/member_card_widget.dart create mode 100644 lib/features/home/presentation/widgets/promotion_slider.dart create mode 100644 lib/features/home/presentation/widgets/quick_action_item.dart create mode 100644 lib/features/home/presentation/widgets/quick_action_section.dart diff --git a/CLAUDE.md b/CLAUDE.md index 488c7e1..a6b98c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1926,94 +1926,9 @@ void main() { } ``` -### Widget Tests -```dart -void main() { - testWidgets('ProductCard displays product info', (tester) async { - final product = Product( - id: '1', - name: 'Test Product', - price: 100, - images: ['https://example.com/image.jpg'], - ); - await tester.pumpWidget( - MaterialApp( - home: ProductCard(product: product), - ), - ); - expect(find.text('Test Product'), findsOneWidget); - expect(find.text('100,000 ₫'), findsOneWidget); - expect(find.byType(CachedNetworkImage), findsOneWidget); - }); - testWidgets('OTPInputField auto-focuses next field', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: OTPInputField(onCompleted: (otp) {}), - ), - ), - ); - - // Enter first digit - await tester.enterText(find.byType(TextField).first, '1'); - await tester.pump(); - - // Verify focus moved to second field - expect( - tester.widget(find.byType(TextField).at(1)).focusNode?.hasFocus, - isTrue, - ); - }); -} -``` - -### Integration Tests -```dart -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets('Complete checkout flow', (tester) async { - app.main(); - await tester.pumpAndSettle(); - - // Navigate to products - await tester.tap(find.byIcon(Icons.shopping_bag)); - await tester.pumpAndSettle(); - - // Add product to cart - await tester.tap(find.byType(ProductCard).first); - await tester.pumpAndSettle(); - await tester.tap(find.text('Add to Cart')); - await tester.pumpAndSettle(); - - // Go to cart - await tester.tap(find.byIcon(Icons.shopping_cart)); - await tester.pumpAndSettle(); - - // Proceed to checkout - await tester.tap(find.text('Checkout')); - await tester.pumpAndSettle(); - - // Fill delivery info - await tester.enterText(find.byKey(Key('recipient_name')), 'John Doe'); - await tester.enterText(find.byKey(Key('phone')), '0912345678'); - await tester.enterText(find.byKey(Key('address')), '123 Main St'); - - // Select payment method - await tester.tap(find.text('Cash on Delivery')); - - // Place order - await tester.tap(find.text('Place Order')); - await tester.pumpAndSettle(); - - // Verify success - expect(find.text('Order Successful'), findsOneWidget); - }); -} -``` --- diff --git a/lib/app.dart b/lib/app.dart index 62ffce8..931f2e3 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,6 +3,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:worker/core/theme/app_theme.dart'; +import 'package:worker/features/home/presentation/pages/home_page.dart'; import 'package:worker/generated/l10n/app_localizations.dart'; /// Root application widget for Worker Mobile App @@ -70,8 +71,8 @@ class WorkerApp extends ConsumerWidget { // 2. Use Navigator 2.0 for imperative routing // 3. Use auto_route for type-safe routing // - // For now, we use a placeholder home screen - home: const _PlaceholderHomePage(), + // For now, we show the home screen directly + home: const HomePage(), // Alternative: Use onGenerateRoute for custom routing // onGenerateRoute: (settings) { diff --git a/lib/features/home/data/datasources/home_local_datasource.dart b/lib/features/home/data/datasources/home_local_datasource.dart new file mode 100644 index 0000000..1ea3c12 --- /dev/null +++ b/lib/features/home/data/datasources/home_local_datasource.dart @@ -0,0 +1,189 @@ +/// Data Source: Home Local Data Source +/// +/// Handles local database operations for home feature using Hive. +/// This is the single source of truth for cached home data. +/// +/// Responsibilities: +/// - Store and retrieve member card from local database +/// - Store and retrieve promotions from local database +/// - Handle data expiration and cache invalidation +library; + +import 'package:worker/core/errors/exceptions.dart'; +import 'package:worker/features/home/data/models/member_card_model.dart'; +import 'package:worker/features/home/data/models/promotion_model.dart'; + +/// Home Local Data Source +/// +/// Provides methods to interact with Hive database for home data. +/// +/// Cache strategy: +/// - Member card: Single entry, updated on login and refresh +/// - Promotions: List entry, updated periodically +abstract class HomeLocalDataSource { + /// Get cached member card + /// + /// Returns cached [MemberCardModel] if available. + /// Throws [CacheException] if no data found or data corrupted. + Future getMemberCard(); + + /// Cache member card + /// + /// Stores [MemberCardModel] in local database. + /// Overwrites existing data. + Future cacheMemberCard(MemberCardModel memberCard); + + /// Get cached promotions + /// + /// Returns list of cached [PromotionModel]. + /// Returns empty list if no cached data. + /// Throws [CacheException] if data corrupted. + Future> getPromotions(); + + /// Cache promotions + /// + /// Stores list of [PromotionModel] in local database. + /// Overwrites existing data. + Future cachePromotions(List promotions); + + /// Clear all cached home data + /// + /// Used when: + /// - User logs out + /// - Cache needs to be invalidated + Future clearCache(); + + /// Check if member card cache is valid + /// + /// Returns true if cache exists and not expired. + /// Cache is considered expired after 24 hours. + Future isMemberCardCacheValid(); + + /// Check if promotions cache is valid + /// + /// Returns true if cache exists and not expired. + /// Cache is considered expired after 1 hour. + Future isPromotionsCacheValid(); +} + +/// Mock Implementation of Home Local Data Source +/// +/// **TEMPORARY**: Uses hardcoded mock JSON data +/// **TODO**: Replace with real Hive implementation when API is available +/// +/// This mock implementation provides realistic data for development and testing. +/// Uses the exact same interface as the real implementation will. +class HomeLocalDataSourceImpl implements HomeLocalDataSource { + /// Mock JSON data for member card + static const Map _mockMemberCardJson = { + 'memberId': 'M001', + 'name': 'La Nguyen Quynh', + 'memberType': 'architect', + 'tier': 'diamond', + 'points': 9750, + 'validUntil': '2025-12-31T23:59:59.000Z', + 'qrData': '0983441099' + }; + + /// Mock JSON data for promotions + static const List> _mockPromotionsJson = [ + { + 'id': 'P001', + 'title': 'Mua công nhắc - Khuyến mãi cảng lớn', + 'description': 'Giảm đến 30% cho đơn hàng từ 10 triệu', + 'imageUrl': + 'https://images.unsplash.com/photo-1615971677499-5467cbab01c0?w=280&h=140&fit=crop', + 'startDate': '2025-01-01T00:00:00.000Z', + 'endDate': '2025-12-31T23:59:59.000Z', + 'discountPercentage': 30, + }, + { + 'id': 'P002', + 'title': 'Keo chà ron tặng kèm', + 'description': 'Mua gạch Eurotile tặng keo chà ron cao cấp', + 'imageUrl': + 'https://images.unsplash.com/photo-1542314831-068cd1dbfeeb?ixlib=rb-1.2.1&auto=format&fit=crop&w=800&q=80', + 'startDate': '2025-01-01T00:00:00.000Z', + 'endDate': '2025-12-31T23:59:59.000Z', + }, + { + 'id': 'P003', + 'title': 'Ưu đãi đặc biệt thành viên VIP', + 'description': 'Chiết khấu thêm 5% cho thành viên Diamond', + 'imageUrl': + 'https://images.unsplash.com/photo-1565538420870-da08ff96a207?w=280&h=140&fit=crop', + 'startDate': '2025-01-01T00:00:00.000Z', + 'endDate': '2025-12-31T23:59:59.000Z', + 'discountPercentage': 5, + } + ]; + + /// Constructor + const HomeLocalDataSourceImpl(); + + @override + Future getMemberCard() async { + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 300)); + + try { + // Parse mock JSON data + return MemberCardModel.fromJson(_mockMemberCardJson); + } catch (e) { + throw CacheException('Failed to get cached member card: $e'); + } + } + + @override + Future cacheMemberCard(MemberCardModel memberCard) async { + // Simulate write delay + await Future.delayed(const Duration(milliseconds: 100)); + // Mock implementation - does nothing + // TODO: Implement Hive write logic when API is available + } + + @override + Future> getPromotions() async { + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 500)); + + try { + // Parse mock JSON data + return _mockPromotionsJson + .map((json) => PromotionModel.fromJson(json)) + .toList(); + } catch (e) { + throw CacheException('Failed to get cached promotions: $e'); + } + } + + @override + Future cachePromotions(List promotions) async { + // Simulate write delay + await Future.delayed(const Duration(milliseconds: 100)); + // Mock implementation - does nothing + // TODO: Implement Hive write logic when API is available + } + + @override + Future clearCache() async { + // Simulate operation delay + await Future.delayed(const Duration(milliseconds: 50)); + // Mock implementation - does nothing + // TODO: Implement cache clearing when API is available + } + + @override + Future isMemberCardCacheValid() async { + // Mock implementation - always return true for development + // TODO: Implement real cache validation when API is available + return true; + } + + @override + Future isPromotionsCacheValid() async { + // Mock implementation - always return true for development + // TODO: Implement real cache validation when API is available + return true; + } +} diff --git a/lib/features/home/data/models/member_card_model.dart b/lib/features/home/data/models/member_card_model.dart new file mode 100644 index 0000000..dd55235 --- /dev/null +++ b/lib/features/home/data/models/member_card_model.dart @@ -0,0 +1,184 @@ +/// Data Model: Member Card +/// +/// Data Transfer Object for member card information. +/// This model handles serialization/deserialization for: +/// - JSON (API responses) +/// - Hive (local database) +/// +/// Extends the domain entity and adds data layer functionality. +library; + +import 'package:hive_ce/hive.dart'; +import 'package:worker/features/home/domain/entities/member_card.dart'; + +part 'member_card_model.g.dart'; + +/// Member Card Model +/// +/// Used for: +/// - API JSON serialization/deserialization +/// - Hive local database storage +/// - Converting to/from domain entity +/// +/// Hive Type ID: 10 (ensure this doesn't conflict with other models) +@HiveType(typeId: 10) +class MemberCardModel extends HiveObject { + /// Member ID + @HiveField(0) + final String memberId; + + /// Member name + @HiveField(1) + final String name; + + /// Member type (stored as string for serialization) + @HiveField(2) + final String memberType; + + /// Membership tier (stored as string for serialization) + @HiveField(3) + final String tier; + + /// Current points + @HiveField(4) + final int points; + + /// Card expiration date (stored as ISO8601 string) + @HiveField(5) + final String validUntil; + + /// QR code data + @HiveField(6) + final String qrData; + + MemberCardModel({ + required this.memberId, + required this.name, + required this.memberType, + required this.tier, + required this.points, + required this.validUntil, + required this.qrData, + }); + + /// From JSON constructor + factory MemberCardModel.fromJson(Map json) { + return MemberCardModel( + memberId: json['memberId'] as String, + name: json['name'] as String, + memberType: json['memberType'] as String, + tier: json['tier'] as String, + points: json['points'] as int, + validUntil: json['validUntil'] as String, + qrData: json['qrData'] as String, + ); + } + + /// To JSON method + Map toJson() { + return { + 'memberId': memberId, + 'name': name, + 'memberType': memberType, + 'tier': tier, + 'points': points, + 'validUntil': validUntil, + 'qrData': qrData, + }; + } + + /// Convert to domain entity + MemberCard toEntity() { + return MemberCard( + memberId: memberId, + name: name, + memberType: _parseMemberType(memberType), + tier: _parseMemberTier(tier), + points: points, + validUntil: DateTime.parse(validUntil), + qrData: qrData, + ); + } + + /// Create from domain entity + factory MemberCardModel.fromEntity(MemberCard entity) { + return MemberCardModel( + memberId: entity.memberId, + name: entity.name, + memberType: entity.memberType.name, + tier: entity.tier.name, + points: entity.points, + validUntil: entity.validUntil.toIso8601String(), + qrData: entity.qrData, + ); + } + + /// Parse member type from string + static MemberType _parseMemberType(String type) { + return MemberType.values.firstWhere( + (e) => e.name.toLowerCase() == type.toLowerCase(), + orElse: () => MemberType.contractor, // Default fallback + ); + } + + /// Parse member tier from string + static MemberTier _parseMemberTier(String tier) { + return MemberTier.values.firstWhere( + (e) => e.name.toLowerCase() == tier.toLowerCase(), + orElse: () => MemberTier.gold, // Default fallback + ); + } + + /// Copy with method for creating modified copies + MemberCardModel copyWith({ + String? memberId, + String? name, + String? memberType, + String? tier, + int? points, + String? validUntil, + String? qrData, + }) { + return MemberCardModel( + memberId: memberId ?? this.memberId, + name: name ?? this.name, + memberType: memberType ?? this.memberType, + tier: tier ?? this.tier, + points: points ?? this.points, + validUntil: validUntil ?? this.validUntil, + qrData: qrData ?? this.qrData, + ); + } + + @override + String toString() { + return 'MemberCardModel(memberId: $memberId, name: $name, memberType: $memberType, tier: $tier, points: $points)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is MemberCardModel && + other.memberId == memberId && + other.name == name && + other.memberType == memberType && + other.tier == tier && + other.points == points && + other.validUntil == validUntil && + other.qrData == qrData; + } + + @override + int get hashCode { + return Object.hash( + memberId, + name, + memberType, + tier, + points, + validUntil, + qrData, + ); + } +} diff --git a/lib/features/home/data/models/member_card_model.g.dart b/lib/features/home/data/models/member_card_model.g.dart new file mode 100644 index 0000000..12d959e --- /dev/null +++ b/lib/features/home/data/models/member_card_model.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'member_card_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class MemberCardModelAdapter extends TypeAdapter { + @override + final typeId = 10; + + @override + MemberCardModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return MemberCardModel( + memberId: fields[0] as String, + name: fields[1] as String, + memberType: fields[2] as String, + tier: fields[3] as String, + points: (fields[4] as num).toInt(), + validUntil: fields[5] as String, + qrData: fields[6] as String, + ); + } + + @override + void write(BinaryWriter writer, MemberCardModel obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.memberId) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.memberType) + ..writeByte(3) + ..write(obj.tier) + ..writeByte(4) + ..write(obj.points) + ..writeByte(5) + ..write(obj.validUntil) + ..writeByte(6) + ..write(obj.qrData); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MemberCardModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/features/home/data/models/promotion_model.dart b/lib/features/home/data/models/promotion_model.dart new file mode 100644 index 0000000..f670a41 --- /dev/null +++ b/lib/features/home/data/models/promotion_model.dart @@ -0,0 +1,207 @@ +/// Data Model: Promotion +/// +/// Data Transfer Object for promotion information. +/// This model handles serialization/deserialization for: +/// - JSON (API responses) +/// - Hive (local database) +/// +/// Extends the domain entity and adds data layer functionality. +library; + +import 'package:hive_ce/hive.dart'; +import 'package:worker/features/home/domain/entities/promotion.dart'; + +part 'promotion_model.g.dart'; + +/// Promotion Model +/// +/// Used for: +/// - API JSON serialization/deserialization +/// - Hive local database storage +/// - Converting to/from domain entity +/// +/// Hive Type ID: 11 (ensure this doesn't conflict with other models) +@HiveType(typeId: 11) +class PromotionModel extends HiveObject { + /// Promotion ID + @HiveField(0) + final String id; + + /// Promotion title + @HiveField(1) + final String title; + + /// Description + @HiveField(2) + final String description; + + /// Image URL + @HiveField(3) + final String imageUrl; + + /// Start date (ISO8601 string) + @HiveField(4) + final String startDate; + + /// End date (ISO8601 string) + @HiveField(5) + final String endDate; + + /// Discount percentage (nullable) + @HiveField(6) + final int? discountPercentage; + + /// Discount amount (nullable) + @HiveField(7) + final double? discountAmount; + + /// Terms and conditions (nullable) + @HiveField(8) + final String? terms; + + /// Details URL (nullable) + @HiveField(9) + final String? detailsUrl; + + PromotionModel({ + required this.id, + required this.title, + required this.description, + required this.imageUrl, + required this.startDate, + required this.endDate, + this.discountPercentage, + this.discountAmount, + this.terms, + this.detailsUrl, + }); + + /// From JSON constructor + factory PromotionModel.fromJson(Map json) { + return PromotionModel( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String, + imageUrl: json['imageUrl'] as String, + startDate: json['startDate'] as String, + endDate: json['endDate'] as String, + discountPercentage: json['discountPercentage'] as int?, + discountAmount: json['discountAmount'] as double?, + terms: json['terms'] as String?, + detailsUrl: json['detailsUrl'] as String?, + ); + } + + /// To JSON method + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'imageUrl': imageUrl, + 'startDate': startDate, + 'endDate': endDate, + 'discountPercentage': discountPercentage, + 'discountAmount': discountAmount, + 'terms': terms, + 'detailsUrl': detailsUrl, + }; + } + + /// Convert to domain entity + Promotion toEntity() { + return Promotion( + id: id, + title: title, + description: description, + imageUrl: imageUrl, + startDate: DateTime.parse(startDate), + endDate: DateTime.parse(endDate), + discountPercentage: discountPercentage, + discountAmount: discountAmount, + terms: terms, + detailsUrl: detailsUrl, + ); + } + + /// Create from domain entity + factory PromotionModel.fromEntity(Promotion entity) { + return PromotionModel( + id: entity.id, + title: entity.title, + description: entity.description, + imageUrl: entity.imageUrl, + startDate: entity.startDate.toIso8601String(), + endDate: entity.endDate.toIso8601String(), + discountPercentage: entity.discountPercentage, + discountAmount: entity.discountAmount, + terms: entity.terms, + detailsUrl: entity.detailsUrl, + ); + } + + /// Copy with method for creating modified copies + PromotionModel copyWith({ + String? id, + String? title, + String? description, + String? imageUrl, + String? startDate, + String? endDate, + int? discountPercentage, + double? discountAmount, + String? terms, + String? detailsUrl, + }) { + return PromotionModel( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + imageUrl: imageUrl ?? this.imageUrl, + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + discountPercentage: discountPercentage ?? this.discountPercentage, + discountAmount: discountAmount ?? this.discountAmount, + terms: terms ?? this.terms, + detailsUrl: detailsUrl ?? this.detailsUrl, + ); + } + + @override + String toString() { + return 'PromotionModel(id: $id, title: $title, startDate: $startDate, endDate: $endDate)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is PromotionModel && + other.id == id && + other.title == title && + other.description == description && + other.imageUrl == imageUrl && + other.startDate == startDate && + other.endDate == endDate && + other.discountPercentage == discountPercentage && + other.discountAmount == discountAmount && + other.terms == terms && + other.detailsUrl == detailsUrl; + } + + @override + int get hashCode { + return Object.hash( + id, + title, + description, + imageUrl, + startDate, + endDate, + discountPercentage, + discountAmount, + terms, + detailsUrl, + ); + } +} diff --git a/lib/features/home/data/models/promotion_model.g.dart b/lib/features/home/data/models/promotion_model.g.dart new file mode 100644 index 0000000..15bde24 --- /dev/null +++ b/lib/features/home/data/models/promotion_model.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'promotion_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class PromotionModelAdapter extends TypeAdapter { + @override + final typeId = 11; + + @override + PromotionModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PromotionModel( + id: fields[0] as String, + title: fields[1] as String, + description: fields[2] as String, + imageUrl: fields[3] as String, + startDate: fields[4] as String, + endDate: fields[5] as String, + discountPercentage: (fields[6] as num?)?.toInt(), + discountAmount: (fields[7] as num?)?.toDouble(), + terms: fields[8] as String?, + detailsUrl: fields[9] as String?, + ); + } + + @override + void write(BinaryWriter writer, PromotionModel obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.title) + ..writeByte(2) + ..write(obj.description) + ..writeByte(3) + ..write(obj.imageUrl) + ..writeByte(4) + ..write(obj.startDate) + ..writeByte(5) + ..write(obj.endDate) + ..writeByte(6) + ..write(obj.discountPercentage) + ..writeByte(7) + ..write(obj.discountAmount) + ..writeByte(8) + ..write(obj.terms) + ..writeByte(9) + ..write(obj.detailsUrl); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PromotionModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/features/home/data/repositories/home_repository_impl.dart b/lib/features/home/data/repositories/home_repository_impl.dart new file mode 100644 index 0000000..9fc4d92 --- /dev/null +++ b/lib/features/home/data/repositories/home_repository_impl.dart @@ -0,0 +1,161 @@ +/// Repository Implementation: Home Repository +/// +/// Concrete implementation of the HomeRepository interface. +/// Coordinates between local and remote data sources to provide home data. +/// +/// Implements offline-first strategy: +/// 1. Try to return cached data immediately +/// 2. Fetch fresh data from server in background +/// 3. Update cache with fresh data +library; + +import 'package:worker/core/errors/exceptions.dart'; +import 'package:worker/core/errors/failures.dart'; +import 'package:worker/features/home/data/datasources/home_local_datasource.dart'; +import 'package:worker/features/home/domain/entities/member_card.dart'; +import 'package:worker/features/home/domain/entities/promotion.dart'; +import 'package:worker/features/home/domain/repositories/home_repository.dart'; + +/// Home Repository Implementation +/// +/// Responsibilities: +/// - Coordinate between local cache and remote API +/// - Implement offline-first data strategy +/// - Handle errors and convert to domain failures +/// - Manage cache invalidation +class HomeRepositoryImpl implements HomeRepository { + /// Local data source (Hive) + final HomeLocalDataSource localDataSource; + + /// Remote data source (API) - TODO: Add when API is ready + // final HomeRemoteDataSource remoteDataSource; + + /// Constructor + HomeRepositoryImpl({ + required this.localDataSource, + // required this.remoteDataSource, // TODO: Add when API ready + }); + + @override + Future getMemberCard() async { + try { + // TODO: Implement offline-first strategy + // 1. Check if cache is valid + final isCacheValid = await localDataSource.isMemberCardCacheValid(); + + if (isCacheValid) { + // 2. Return cached data if valid + final cachedModel = await localDataSource.getMemberCard(); + return cachedModel.toEntity(); + } + + // 3. If cache invalid, fetch from remote (when API ready) + // For now, try to return cached data even if expired + try { + final cachedModel = await localDataSource.getMemberCard(); + return cachedModel.toEntity(); + } catch (e) { + // TODO: Fetch from remote API when available + // final remoteModel = await remoteDataSource.getMemberCard(); + // await localDataSource.cacheMemberCard(remoteModel); + // return remoteModel.toEntity(); + + throw const CacheException('No member card data available'); + } + } on CacheException catch (e) { + throw CacheFailure(message: e.message); + } catch (e) { + throw ServerFailure(message: 'Failed to get member card: $e'); + } + } + + @override + Future> getPromotions() async { + try { + // TODO: Implement offline-first strategy + // 1. Check if cache is valid + final isCacheValid = await localDataSource.isPromotionsCacheValid(); + + if (isCacheValid) { + // 2. Return cached data if valid + final cachedModels = await localDataSource.getPromotions(); + return cachedModels.map((model) => model.toEntity()).toList(); + } + + // 3. If cache invalid, fetch from remote (when API ready) + // For now, try to return cached data even if expired + try { + final cachedModels = await localDataSource.getPromotions(); + return cachedModels.map((model) => model.toEntity()).toList(); + } catch (e) { + // TODO: Fetch from remote API when available + // final remoteModels = await remoteDataSource.getPromotions(); + // await localDataSource.cachePromotions(remoteModels); + // return remoteModels.map((m) => m.toEntity()).toList(); + + // Return empty list if no data available + return []; + } + } on CacheException catch (e) { + throw CacheFailure(message: e.message); + } catch (e) { + throw ServerFailure(message: 'Failed to get promotions: $e'); + } + } + + @override + Future refreshMemberCard() async { + try { + // TODO: Implement force refresh from API + // This should always fetch from remote, bypassing cache + + // When API is ready: + // 1. Fetch from remote + // final remoteModel = await remoteDataSource.getMemberCard(); + + // 2. Update cache + // await localDataSource.cacheMemberCard(remoteModel); + + // 3. Return entity + // return remoteModel.toEntity(); + + // For now, just return cached data + final cachedModel = await localDataSource.getMemberCard(); + return cachedModel.toEntity(); + } on ServerException catch (e) { + throw ServerFailure(message: e.message); + } on NetworkException catch (e) { + throw NetworkFailure(message: e.message); + } catch (e) { + throw ServerFailure(message: 'Failed to refresh member card: $e'); + } + } + + @override + Future> refreshPromotions() async { + try { + // TODO: Implement force refresh from API + // This should always fetch from remote, bypassing cache + + // When API is ready: + // 1. Fetch from remote + // final remoteModels = await remoteDataSource.getPromotions(); + + // 2. Update cache + // await localDataSource.cachePromotions(remoteModels); + + // 3. Return entities + // return remoteModels.map((m) => m.toEntity()).toList(); + + // For now, just return cached data + final cachedModels = await localDataSource.getPromotions(); + return cachedModels.map((model) => model.toEntity()).toList(); + } on ServerException catch (e) { + throw ServerFailure(message: e.message); + } on NetworkException catch (e) { + throw NetworkFailure(message: e.message); + } catch (e) { + throw ServerFailure(message: 'Failed to refresh promotions: $e'); + } + } +} diff --git a/lib/features/home/domain/entities/member_card.dart b/lib/features/home/domain/entities/member_card.dart new file mode 100644 index 0000000..c75840f --- /dev/null +++ b/lib/features/home/domain/entities/member_card.dart @@ -0,0 +1,169 @@ +/// Domain Entity: Member Card +/// +/// Represents a member card with tier information, points, and QR code data. +/// This entity is used across the home feature to display membership information. +/// +/// Tiers: Diamond, Platinum, Gold +/// +/// This is a pure domain entity with no external dependencies. +library; + +/// Member tier enum +enum MemberTier { + /// Diamond tier - highest tier + diamond, + + /// Platinum tier - middle tier + platinum, + + /// Gold tier - entry tier + gold; + + /// Get display name for tier + String get displayName { + switch (this) { + case MemberTier.diamond: + return 'DIAMOND'; + case MemberTier.platinum: + return 'PLATINUM'; + case MemberTier.gold: + return 'GOLD'; + } + } +} + +/// Member type enum +enum MemberType { + /// Architect member type + architect, + + /// Contractor member type + contractor, + + /// Distributor member type + distributor, + + /// Broker member type + broker; + + /// Get display name for member type + String get displayName { + switch (this) { + case MemberType.architect: + return 'ARCHITECT MEMBERSHIP'; + case MemberType.contractor: + return 'CONTRACTOR MEMBERSHIP'; + case MemberType.distributor: + return 'DISTRIBUTOR MEMBERSHIP'; + case MemberType.broker: + return 'BROKER MEMBERSHIP'; + } + } +} + +/// Member Card Entity +/// +/// Contains all information needed to display a member card: +/// - Personal info (name, member ID) +/// - Membership details (tier, type, valid until) +/// - Loyalty points +/// - QR code data for scanning +class MemberCard { + /// Unique member ID + final String memberId; + + /// Member's full name + final String name; + + /// Member type (Architect, Contractor, etc.) + final MemberType memberType; + + /// Current membership tier + final MemberTier tier; + + /// Current loyalty points balance + final int points; + + /// Card expiration date + final DateTime validUntil; + + /// QR code data (typically member ID or encoded data) + final String qrData; + + /// Constructor + const MemberCard({ + required this.memberId, + required this.name, + required this.memberType, + required this.tier, + required this.points, + required this.validUntil, + required this.qrData, + }); + + /// Check if card is expired + bool get isExpired => DateTime.now().isAfter(validUntil); + + /// Check if card is expiring soon (within 30 days) + bool get isExpiringSoon { + final daysUntilExpiry = validUntil.difference(DateTime.now()).inDays; + return daysUntilExpiry > 0 && daysUntilExpiry <= 30; + } + + /// Copy with method for immutability + MemberCard copyWith({ + String? memberId, + String? name, + MemberType? memberType, + MemberTier? tier, + int? points, + DateTime? validUntil, + String? qrData, + }) { + return MemberCard( + memberId: memberId ?? this.memberId, + name: name ?? this.name, + memberType: memberType ?? this.memberType, + tier: tier ?? this.tier, + points: points ?? this.points, + validUntil: validUntil ?? this.validUntil, + qrData: qrData ?? this.qrData, + ); + } + + /// Equality operator + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is MemberCard && + other.memberId == memberId && + other.name == name && + other.memberType == memberType && + other.tier == tier && + other.points == points && + other.validUntil == validUntil && + other.qrData == qrData; + } + + /// Hash code + @override + int get hashCode { + return Object.hash( + memberId, + name, + memberType, + tier, + points, + validUntil, + qrData, + ); + } + + /// String representation + @override + String toString() { + return 'MemberCard(memberId: $memberId, name: $name, memberType: $memberType, ' + 'tier: $tier, points: $points, validUntil: $validUntil, qrData: $qrData)'; + } +} diff --git a/lib/features/home/domain/entities/promotion.dart b/lib/features/home/domain/entities/promotion.dart new file mode 100644 index 0000000..615162a --- /dev/null +++ b/lib/features/home/domain/entities/promotion.dart @@ -0,0 +1,176 @@ +/// Domain Entity: Promotion +/// +/// Represents a promotional offer or campaign displayed on the home screen. +/// This entity contains all information needed to display promotion banners. +/// +/// This is a pure domain entity with no external dependencies. +library; + +/// Promotion status enum +enum PromotionStatus { + /// Currently active promotion + active, + + /// Promotion starting in the future + upcoming, + + /// Expired promotion + expired; +} + +/// Promotion Entity +/// +/// Contains all information needed to display a promotion: +/// - Basic info (title, description) +/// - Visual assets (image URL) +/// - Validity period +/// - Optional discount information +class Promotion { + /// Unique promotion ID + final String id; + + /// Promotion title + final String title; + + /// Detailed description + final String description; + + /// Banner/cover image URL + final String imageUrl; + + /// Promotion start date + final DateTime startDate; + + /// Promotion end date + final DateTime endDate; + + /// Optional discount percentage (e.g., 30 for 30%) + final int? discountPercentage; + + /// Optional discount amount + final double? discountAmount; + + /// Optional terms and conditions + final String? terms; + + /// Optional link to detailed page + final String? detailsUrl; + + /// Constructor + const Promotion({ + required this.id, + required this.title, + required this.description, + required this.imageUrl, + required this.startDate, + required this.endDate, + this.discountPercentage, + this.discountAmount, + this.terms, + this.detailsUrl, + }); + + /// Get current promotion status + PromotionStatus get status { + final now = DateTime.now(); + + if (now.isBefore(startDate)) { + return PromotionStatus.upcoming; + } else if (now.isAfter(endDate)) { + return PromotionStatus.expired; + } else { + return PromotionStatus.active; + } + } + + /// Check if promotion is currently active + bool get isActive => status == PromotionStatus.active; + + /// Get days remaining until expiry (null if expired or upcoming) + int? get daysRemaining { + if (status != PromotionStatus.active) return null; + + return endDate.difference(DateTime.now()).inDays; + } + + /// Format discount display text + String? get discountText { + if (discountPercentage != null) { + return 'Giảm $discountPercentage%'; + } else if (discountAmount != null) { + return 'Giảm ${discountAmount?.toStringAsFixed(0) ?? '0'}₫'; + } + return null; + } + + /// Copy with method for immutability + Promotion copyWith({ + String? id, + String? title, + String? description, + String? imageUrl, + DateTime? startDate, + DateTime? endDate, + int? discountPercentage, + double? discountAmount, + String? terms, + String? detailsUrl, + }) { + return Promotion( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + imageUrl: imageUrl ?? this.imageUrl, + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + discountPercentage: discountPercentage ?? this.discountPercentage, + discountAmount: discountAmount ?? this.discountAmount, + terms: terms ?? this.terms, + detailsUrl: detailsUrl ?? this.detailsUrl, + ); + } + + /// Equality operator + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Promotion && + other.id == id && + other.title == title && + other.description == description && + other.imageUrl == imageUrl && + other.startDate == startDate && + other.endDate == endDate && + other.discountPercentage == discountPercentage && + other.discountAmount == discountAmount && + other.terms == terms && + other.detailsUrl == detailsUrl; + } + + /// Hash code + @override + int get hashCode { + return Object.hash( + id, + title, + description, + imageUrl, + startDate, + endDate, + discountPercentage, + discountAmount, + terms, + detailsUrl, + ); + } + + /// String representation + @override + String toString() { + return 'Promotion(id: $id, title: $title, description: $description, ' + 'imageUrl: $imageUrl, startDate: $startDate, endDate: $endDate, ' + 'discountPercentage: $discountPercentage, discountAmount: $discountAmount, ' + 'terms: $terms, detailsUrl: $detailsUrl)'; + } +} diff --git a/lib/features/home/domain/repositories/home_repository.dart b/lib/features/home/domain/repositories/home_repository.dart new file mode 100644 index 0000000..25f69be --- /dev/null +++ b/lib/features/home/domain/repositories/home_repository.dart @@ -0,0 +1,49 @@ +/// Domain Repository Interface: Home Repository +/// +/// Defines the contract for home-related data operations. +/// This is an abstract interface following the Repository Pattern. +/// +/// The actual implementation will be in the data layer. +/// This allows for dependency inversion and easier testing. +library; + +import 'package:worker/features/home/domain/entities/member_card.dart'; +import 'package:worker/features/home/domain/entities/promotion.dart'; + +/// Home Repository Interface +/// +/// Provides methods to: +/// - Get member card information +/// - Fetch active promotions +/// +/// Implementation will be in data/repositories/home_repository_impl.dart +abstract class HomeRepository { + /// Get the current user's member card + /// + /// Returns [MemberCard] with user's membership information. + /// Throws exception if user not authenticated or data unavailable. + /// + /// This should fetch from local cache first (Hive), then sync with server. + Future getMemberCard(); + + /// Get list of active promotions + /// + /// Returns list of [Promotion] objects that are currently active. + /// Returns empty list if no promotions available. + /// + /// This should fetch from local cache first, then sync with server. + /// Promotions should be ordered by priority/start date. + Future> getPromotions(); + + /// Refresh member card data from server + /// + /// Force refresh member card information from remote source. + /// Updates local cache after successful fetch. + Future refreshMemberCard(); + + /// Refresh promotions from server + /// + /// Force refresh promotions from remote source. + /// Updates local cache after successful fetch. + Future> refreshPromotions(); +} diff --git a/lib/features/home/domain/usecases/get_member_card.dart b/lib/features/home/domain/usecases/get_member_card.dart new file mode 100644 index 0000000..66edda6 --- /dev/null +++ b/lib/features/home/domain/usecases/get_member_card.dart @@ -0,0 +1,62 @@ +/// Use Case: Get Member Card +/// +/// Retrieves the current user's member card information. +/// This use case encapsulates the business logic for fetching member card data. +/// +/// Follows the Single Responsibility Principle - one use case, one operation. +library; + +import 'package:worker/features/home/domain/entities/member_card.dart'; +import 'package:worker/features/home/domain/repositories/home_repository.dart'; + +/// Get Member Card Use Case +/// +/// Fetches the current authenticated user's member card. +/// +/// Usage: +/// ```dart +/// final memberCard = await getMemberCard(); +/// ``` +/// +/// This use case: +/// - Retrieves member card from repository (which handles caching) +/// - Can add business logic if needed (e.g., validation, transformation) +/// - Returns MemberCard entity +class GetMemberCard { + /// Home repository instance + final HomeRepository repository; + + /// Constructor + const GetMemberCard(this.repository); + + /// Execute the use case + /// + /// Returns [MemberCard] with user's membership information. + /// + /// Throws: + /// - [UnauthorizedException] if user not authenticated + /// - [NetworkException] if network error occurs + /// - [CacheException] if local data corrupted + Future call() async { + // TODO: Add business logic here if needed + // For example: + // - Validate user authentication + // - Check if card is expired and handle accordingly + // - Transform data if needed + // - Log analytics events + + return await repository.getMemberCard(); + } + + /// Execute with force refresh + /// + /// Forces a refresh from the server instead of using cached data. + /// + /// Use this when: + /// - User explicitly pulls to refresh + /// - Cached data is known to be stale + /// - After points redemption or other updates + Future refresh() async { + return await repository.refreshMemberCard(); + } +} diff --git a/lib/features/home/domain/usecases/get_promotions.dart b/lib/features/home/domain/usecases/get_promotions.dart new file mode 100644 index 0000000..54b9357 --- /dev/null +++ b/lib/features/home/domain/usecases/get_promotions.dart @@ -0,0 +1,84 @@ +/// Use Case: Get Promotions +/// +/// Retrieves the list of active promotional offers. +/// This use case encapsulates the business logic for fetching promotions. +/// +/// Follows the Single Responsibility Principle - one use case, one operation. +library; + +import 'package:worker/features/home/domain/entities/promotion.dart'; +import 'package:worker/features/home/domain/repositories/home_repository.dart'; + +/// Get Promotions Use Case +/// +/// Fetches active promotional offers to display on home screen. +/// +/// Usage: +/// ```dart +/// final promotions = await getPromotions(); +/// ``` +/// +/// This use case: +/// - Retrieves promotions from repository (which handles caching) +/// - Filters only active promotions +/// - Sorts by priority/date +/// - Returns list of Promotion entities +class GetPromotions { + /// Home repository instance + final HomeRepository repository; + + /// Constructor + const GetPromotions(this.repository); + + /// Execute the use case + /// + /// Returns list of active [Promotion] objects. + /// Returns empty list if no promotions available. + /// + /// Throws: + /// - [NetworkException] if network error occurs + /// - [CacheException] if local data corrupted + Future> call() async { + // Fetch promotions from repository + final promotions = await repository.getPromotions(); + + // TODO: Add business logic here if needed + // For example: + // - Filter only active promotions + // - Sort by priority or start date + // - Filter by user tier (Diamond exclusive promotions) + // - Add personalization logic + // - Log analytics events + + // Filter only active promotions + final activePromotions = promotions + .where((promotion) => promotion.isActive) + .toList(); + + // Sort by start date (newest first) + activePromotions.sort((a, b) => b.startDate.compareTo(a.startDate)); + + return activePromotions; + } + + /// Execute with force refresh + /// + /// Forces a refresh from the server instead of using cached data. + /// + /// Use this when: + /// - User explicitly pulls to refresh + /// - Cached data is known to be stale + /// - Need to show latest promotions immediately + Future> refresh() async { + final promotions = await repository.refreshPromotions(); + + // Apply same filtering logic + final activePromotions = promotions + .where((promotion) => promotion.isActive) + .toList(); + + activePromotions.sort((a, b) => b.startDate.compareTo(a.startDate)); + + return activePromotions; + } +} diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart new file mode 100644 index 0000000..c394c02 --- /dev/null +++ b/lib/features/home/presentation/pages/home_page.dart @@ -0,0 +1,312 @@ +/// Page: Home Page +/// +/// Main dashboard screen of the Worker app. +/// Displays member card, promotions, and quick action sections. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/home/presentation/providers/member_card_provider.dart'; +import 'package:worker/features/home/presentation/providers/promotions_provider.dart'; +import 'package:worker/features/home/presentation/widgets/member_card_widget.dart'; +import 'package:worker/features/home/presentation/widgets/promotion_slider.dart'; +import 'package:worker/features/home/presentation/widgets/quick_action_section.dart'; + +/// Home Page +/// +/// Main entry point of the app after login. +/// Shows: +/// - Member card (Diamond/Platinum/Gold) +/// - Promotional banners +/// - Quick action sections +/// - Bottom navigation +/// - Floating action button (Chat) +class HomePage extends ConsumerWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Watch member card state + final memberCardAsync = ref.watch(memberCardProvider); + + // Watch promotions state + final promotionsAsync = ref.watch(promotionsProvider); + + return Scaffold( + backgroundColor: AppColors.grey50, + body: RefreshIndicator( + onRefresh: () async { + // Refresh both member card and promotions + await Future.wait([ + ref.read(memberCardProvider.notifier).refresh(), + ref.read(promotionsProvider.notifier).refresh(), + ]); + }, + child: CustomScrollView( + slivers: [ + // App Bar + const SliverAppBar( + floating: true, + snap: true, + backgroundColor: AppColors.primaryBlue, + title: Text('Trang ch�'), + centerTitle: true, + ), + + // Member Card Section + SliverToBoxAdapter( + child: memberCardAsync.when( + data: (memberCard) => MemberCardWidget(memberCard: memberCard), + loading: () => Container( + margin: const EdgeInsets.all(16), + height: 200, + decoration: BoxDecoration( + color: AppColors.grey100, + borderRadius: BorderRadius.circular(16), + ), + child: const Center( + child: CircularProgressIndicator(), + ), + ), + error: (error, stack) => Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.danger.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + color: AppColors.danger, + size: 48, + ), + const SizedBox(height: 8), + Text( + 'Kh�ng th� t�i th� th�nh vi�n', + style: TextStyle( + color: AppColors.danger, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + error.toString(), + style: TextStyle( + color: AppColors.grey500, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + + // Promotions Section + SliverToBoxAdapter( + child: promotionsAsync.when( + data: (promotions) => promotions.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: PromotionSlider( + promotions: promotions, + onPromotionTap: (promotion) { + // TODO: Navigate to promotion details + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Xem chi ti�t: ${promotion.title}'), + ), + ); + }, + ), + ) + : const SizedBox.shrink(), + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (error, stack) => const SizedBox.shrink(), + ), + ), + + // Quick Action Sections + SliverToBoxAdapter( + child: Column( + children: [ + const SizedBox(height: 8), + // Products & Cart Section + QuickActionSection( + title: 'S�n ph�m & Gi� h�ng', + actions: [ + QuickAction( + icon: Icons.grid_view, + label: 'S�n ph�m', + onTap: () => _showComingSoon(context, 'S�n ph�m'), + ), + QuickAction( + icon: Icons.shopping_cart, + label: 'Gi� h�ng', + badge: '3', + onTap: () => _showComingSoon(context, 'Gi� h�ng'), + ), + QuickAction( + icon: Icons.favorite, + label: 'Y�u th�ch', + onTap: () => _showComingSoon(context, 'Y�u th�ch'), + ), + ], + ), + + // Loyalty Section + QuickActionSection( + title: 'Kh�ch h�ng th�n thi�t', + actions: [ + QuickAction( + icon: Icons.card_giftcard, + label: '�i qu�', + onTap: () => _showComingSoon(context, '�i qu�'), + ), + QuickAction( + icon: Icons.history, + label: 'L�ch s� i�m', + onTap: () => _showComingSoon(context, 'L�ch s� i�m'), + ), + QuickAction( + icon: Icons.person_add, + label: 'Gi�i thi�u b�n', + onTap: () => _showComingSoon(context, 'Gi�i thi�u b�n'), + ), + ], + ), + + // Quote Requests Section + QuickActionSection( + title: 'Y�u c�u b�o gi� & b�o gi�', + actions: [ + QuickAction( + icon: Icons.description, + label: 'Y�u c�u b�o gi�', + onTap: () => _showComingSoon(context, 'Y�u c�u b�o gi�'), + ), + QuickAction( + icon: Icons.receipt_long, + label: 'B�o gi�', + onTap: () => _showComingSoon(context, 'B�o gi�'), + ), + ], + ), + + // Orders & Payments Section + QuickActionSection( + title: '�n h�ng & thanh to�n', + actions: [ + QuickAction( + icon: Icons.inventory_2, + label: '�n h�ng', + onTap: () => _showComingSoon(context, '�n h�ng'), + ), + QuickAction( + icon: Icons.payment, + label: 'Thanh to�n', + onTap: () => _showComingSoon(context, 'Thanh to�n'), + ), + ], + ), + + // Sample Houses & News Section + QuickActionSection( + title: 'Nh� m�u, d� �n & tin t�c', + actions: [ + QuickAction( + icon: Icons.home_work, + label: 'Nh� m�u', + onTap: () => _showComingSoon(context, 'Nh� m�u'), + ), + QuickAction( + icon: Icons.business, + label: 'ng k� d� �n', + onTap: () => _showComingSoon(context, 'ng k� d� �n'), + ), + QuickAction( + icon: Icons.article, + label: 'Tin t�c', + onTap: () => _showComingSoon(context, 'Tin t�c'), + ), + ], + ), + + // Bottom Padding (for FAB clearance) + const SizedBox(height: 80), + ], + ), + ), + ], + ), + ), + + // Floating Action Button (Chat) + floatingActionButton: FloatingActionButton( + onPressed: () => _showComingSoon(context, 'Chat'), + backgroundColor: AppColors.accentCyan, + child: const Icon(Icons.chat_bubble), + ), + + // Bottom Navigation Bar + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + currentIndex: 0, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: 'Trang ch�', + ), + BottomNavigationBarItem( + icon: Icon(Icons.loyalty), + label: 'H�i vi�n', + ), + BottomNavigationBarItem( + icon: Icon(Icons.local_offer), + label: 'Khuy�n m�i', + ), + BottomNavigationBarItem( + icon: Badge( + label: Text('5'), + child: Icon(Icons.notifications), + ), + label: 'Th�ng b�o', + ), + BottomNavigationBarItem( + icon: Icon(Icons.account_circle), + label: 'C�i �t', + ), + ], + onTap: (index) { + // TODO: Implement navigation + final labels = [ + 'Trang ch�', + 'H�i vi�n', + 'Khuy�n m�i', + 'Th�ng b�o', + 'C�i �t' + ]; + _showComingSoon(context, labels[index]); + }, + ), + ); + } + + /// Show coming soon message + void _showComingSoon(BuildContext context, String feature) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$feature - S�p ra m�t'), + duration: const Duration(seconds: 1), + ), + ); + } +} diff --git a/lib/features/home/presentation/providers/member_card_provider.dart b/lib/features/home/presentation/providers/member_card_provider.dart new file mode 100644 index 0000000..b703d01 --- /dev/null +++ b/lib/features/home/presentation/providers/member_card_provider.dart @@ -0,0 +1,63 @@ +/// Provider: Member Card Provider +/// +/// Manages the state of the member card data using Riverpod. +/// Provides access to member card information throughout the app. +/// +/// Uses AsyncNotifierProvider for automatic loading, error, and data states. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/features/home/data/datasources/home_local_datasource.dart'; +import 'package:worker/features/home/data/repositories/home_repository_impl.dart'; +import 'package:worker/features/home/domain/entities/member_card.dart'; +import 'package:worker/features/home/domain/usecases/get_member_card.dart'; + +part 'member_card_provider.g.dart'; + +/// Member Card Provider +/// +/// Fetches and caches the current user's member card. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// // In a ConsumerWidget +/// final memberCardAsync = ref.watch(memberCardProvider); +/// +/// memberCardAsync.when( +/// data: (memberCard) => MemberCardWidget(memberCard: memberCard), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` +@riverpod +class MemberCardNotifier extends _$MemberCardNotifier { + @override + Future build() async { + // Initialize dependencies + final localDataSource = const HomeLocalDataSourceImpl(); + final repository = HomeRepositoryImpl(localDataSource: localDataSource); + final useCase = GetMemberCard(repository); + + // Fetch member card + return await useCase(); + } + + /// Refresh member card data + /// + /// Forces a refresh from the server (when API is available). + /// Updates the cached state with fresh data. + Future refresh() async { + // Set loading state + state = const AsyncValue.loading(); + + // Fetch fresh data + state = await AsyncValue.guard(() async { + final localDataSource = const HomeLocalDataSourceImpl(); + final repository = HomeRepositoryImpl(localDataSource: localDataSource); + final useCase = GetMemberCard(repository); + + return await useCase.refresh(); + }); + } +} diff --git a/lib/features/home/presentation/providers/member_card_provider.g.dart b/lib/features/home/presentation/providers/member_card_provider.g.dart new file mode 100644 index 0000000..f071827 --- /dev/null +++ b/lib/features/home/presentation/providers/member_card_provider.g.dart @@ -0,0 +1,121 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'member_card_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Member Card Provider +/// +/// Fetches and caches the current user's member card. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// // In a ConsumerWidget +/// final memberCardAsync = ref.watch(memberCardProvider); +/// +/// memberCardAsync.when( +/// data: (memberCard) => MemberCardWidget(memberCard: memberCard), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +@ProviderFor(MemberCardNotifier) +const memberCardProvider = MemberCardNotifierProvider._(); + +/// Member Card Provider +/// +/// Fetches and caches the current user's member card. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// // In a ConsumerWidget +/// final memberCardAsync = ref.watch(memberCardProvider); +/// +/// memberCardAsync.when( +/// data: (memberCard) => MemberCardWidget(memberCard: memberCard), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` +final class MemberCardNotifierProvider + extends $AsyncNotifierProvider { + /// Member Card Provider + /// + /// Fetches and caches the current user's member card. + /// Automatically handles loading, error, and data states. + /// + /// Usage: + /// ```dart + /// // In a ConsumerWidget + /// final memberCardAsync = ref.watch(memberCardProvider); + /// + /// memberCardAsync.when( + /// data: (memberCard) => MemberCardWidget(memberCard: memberCard), + /// loading: () => CircularProgressIndicator(), + /// error: (error, stack) => ErrorWidget(error), + /// ); + /// ``` + const MemberCardNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'memberCardProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$memberCardNotifierHash(); + + @$internal + @override + MemberCardNotifier create() => MemberCardNotifier(); +} + +String _$memberCardNotifierHash() => + r'6b9447a8b91b85fac3cc7ed58222743a7a829e22'; + +/// Member Card Provider +/// +/// Fetches and caches the current user's member card. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// // In a ConsumerWidget +/// final memberCardAsync = ref.watch(memberCardProvider); +/// +/// memberCardAsync.when( +/// data: (memberCard) => MemberCardWidget(memberCard: memberCard), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +abstract class _$MemberCardNotifier extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref, MemberCard>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, MemberCard>, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/features/home/presentation/providers/promotions_provider.dart b/lib/features/home/presentation/providers/promotions_provider.dart new file mode 100644 index 0000000..1bb4b09 --- /dev/null +++ b/lib/features/home/presentation/providers/promotions_provider.dart @@ -0,0 +1,63 @@ +/// Provider: Promotions Provider +/// +/// Manages the state of promotions data using Riverpod. +/// Provides access to active promotions throughout the app. +/// +/// Uses AsyncNotifierProvider for automatic loading, error, and data states. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/features/home/data/datasources/home_local_datasource.dart'; +import 'package:worker/features/home/data/repositories/home_repository_impl.dart'; +import 'package:worker/features/home/domain/entities/promotion.dart'; +import 'package:worker/features/home/domain/usecases/get_promotions.dart'; + +part 'promotions_provider.g.dart'; + +/// Promotions Provider +/// +/// Fetches and caches the list of active promotions. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// // In a ConsumerWidget +/// final promotionsAsync = ref.watch(promotionsProvider); +/// +/// promotionsAsync.when( +/// data: (promotions) => PromotionSlider(promotions: promotions), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` +@riverpod +class PromotionsNotifier extends _$PromotionsNotifier { + @override + Future> build() async { + // Initialize dependencies + final localDataSource = const HomeLocalDataSourceImpl(); + final repository = HomeRepositoryImpl(localDataSource: localDataSource); + final useCase = GetPromotions(repository); + + // Fetch promotions (only active ones) + return await useCase(); + } + + /// Refresh promotions data + /// + /// Forces a refresh from the server (when API is available). + /// Updates the cached state with fresh data. + Future refresh() async { + // Set loading state + state = const AsyncValue.loading(); + + // Fetch fresh data + state = await AsyncValue.guard(() async { + final localDataSource = const HomeLocalDataSourceImpl(); + final repository = HomeRepositoryImpl(localDataSource: localDataSource); + final useCase = GetPromotions(repository); + + return await useCase.refresh(); + }); + } +} diff --git a/lib/features/home/presentation/providers/promotions_provider.g.dart b/lib/features/home/presentation/providers/promotions_provider.g.dart new file mode 100644 index 0000000..42f6587 --- /dev/null +++ b/lib/features/home/presentation/providers/promotions_provider.g.dart @@ -0,0 +1,121 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'promotions_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Promotions Provider +/// +/// Fetches and caches the list of active promotions. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// // In a ConsumerWidget +/// final promotionsAsync = ref.watch(promotionsProvider); +/// +/// promotionsAsync.when( +/// data: (promotions) => PromotionSlider(promotions: promotions), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +@ProviderFor(PromotionsNotifier) +const promotionsProvider = PromotionsNotifierProvider._(); + +/// Promotions Provider +/// +/// Fetches and caches the list of active promotions. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// // In a ConsumerWidget +/// final promotionsAsync = ref.watch(promotionsProvider); +/// +/// promotionsAsync.when( +/// data: (promotions) => PromotionSlider(promotions: promotions), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` +final class PromotionsNotifierProvider + extends $AsyncNotifierProvider> { + /// Promotions Provider + /// + /// Fetches and caches the list of active promotions. + /// Automatically handles loading, error, and data states. + /// + /// Usage: + /// ```dart + /// // In a ConsumerWidget + /// final promotionsAsync = ref.watch(promotionsProvider); + /// + /// promotionsAsync.when( + /// data: (promotions) => PromotionSlider(promotions: promotions), + /// loading: () => CircularProgressIndicator(), + /// error: (error, stack) => ErrorWidget(error), + /// ); + /// ``` + const PromotionsNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'promotionsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$promotionsNotifierHash(); + + @$internal + @override + PromotionsNotifier create() => PromotionsNotifier(); +} + +String _$promotionsNotifierHash() => + r'3cd866c74ba11c6519e9b63521e1757ef117c7a9'; + +/// Promotions Provider +/// +/// Fetches and caches the list of active promotions. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// // In a ConsumerWidget +/// final promotionsAsync = ref.watch(promotionsProvider); +/// +/// promotionsAsync.when( +/// data: (promotions) => PromotionSlider(promotions: promotions), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +abstract class _$PromotionsNotifier 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); + } +} diff --git a/lib/features/home/presentation/widgets/member_card_widget.dart b/lib/features/home/presentation/widgets/member_card_widget.dart new file mode 100644 index 0000000..32e737b --- /dev/null +++ b/lib/features/home/presentation/widgets/member_card_widget.dart @@ -0,0 +1,192 @@ +/// Widget: Member Card Widget +/// +/// Displays a user's membership card with tier-specific styling. +/// Shows member information, points, QR code, and tier badge. +/// +/// Supports three tiers: Diamond, Platinum, Gold +library; + +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/home/domain/entities/member_card.dart'; + +/// Member Card Widget +/// +/// Renders a beautiful gradient card displaying member information. +/// The gradient and styling changes based on the membership tier. +class MemberCardWidget extends StatelessWidget { + /// Member card data + final MemberCard memberCard; + + const MemberCardWidget({ + super.key, + required this.memberCard, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(16), + height: 200, + decoration: BoxDecoration( + gradient: _getGradientForTier(memberCard.tier), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Row: Branding and Valid Until + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Branding + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'EUROTILE', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 4), + Text( + memberCard.memberType.displayName, + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 11, + fontWeight: FontWeight.w500, + letterSpacing: 0.8, + ), + ), + ], + ), + // Valid Until + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Valid through', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 11, + ), + ), + const SizedBox(height: 2), + Text( + _formatDate(memberCard.validUntil), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + + const Spacer(), + + // Footer Row: Member Info and QR Code + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + // Member Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + memberCard.name, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'CLASS: ${memberCard.tier.displayName}', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + 'Points: ${_formatPoints(memberCard.points)}', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + // QR Code + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: QrImageView( + data: memberCard.qrData, + version: QrVersions.auto, + size: 60, + backgroundColor: Colors.white, + ), + ), + ], + ), + ], + ), + ), + ); + } + + /// Get gradient for tier + LinearGradient _getGradientForTier(MemberTier tier) { + switch (tier) { + case MemberTier.diamond: + return AppColors.diamondGradient; + case MemberTier.platinum: + return AppColors.platinumGradient; + case MemberTier.gold: + return AppColors.goldGradient; + } + } + + /// Format date to DD/MM/YYYY + String _formatDate(DateTime date) { + return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'; + } + + /// Format points with thousands separator + String _formatPoints(int points) { + return points.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]},', + ); + } +} diff --git a/lib/features/home/presentation/widgets/promotion_slider.dart b/lib/features/home/presentation/widgets/promotion_slider.dart new file mode 100644 index 0000000..8a0a45b --- /dev/null +++ b/lib/features/home/presentation/widgets/promotion_slider.dart @@ -0,0 +1,164 @@ +/// Widget: Promotion Slider +/// +/// Horizontal scrolling list of promotional banners. +/// Displays promotion images, titles, and descriptions. +library; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/home/domain/entities/promotion.dart'; + +/// Promotion Slider Widget +/// +/// Displays a horizontal scrollable list of promotion cards. +/// Each card shows an image, title, and brief description. +class PromotionSlider extends StatelessWidget { + /// List of promotions to display + final List promotions; + + /// Callback when a promotion is tapped + final void Function(Promotion promotion)? onPromotionTap; + + const PromotionSlider({ + super.key, + required this.promotions, + this.onPromotionTap, + }); + + @override + Widget build(BuildContext context) { + if (promotions.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Chương trình ưu đãi', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + SizedBox( + height: 200, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: promotions.length, + itemBuilder: (context, index) { + return _PromotionCard( + promotion: promotions[index], + onTap: onPromotionTap != null + ? () => onPromotionTap!(promotions[index]) + : null, + ); + }, + ), + ), + ], + ); + } +} + +/// Individual Promotion Card +class _PromotionCard extends StatelessWidget { + final Promotion promotion; + final VoidCallback? onTap; + + const _PromotionCard({ + required this.promotion, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 280, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Promotion Image + ClipRRect( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(12)), + child: CachedNetworkImage( + imageUrl: promotion.imageUrl, + height: 140, + width: double.infinity, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + height: 140, + color: AppColors.grey100, + child: const Center( + child: CircularProgressIndicator(), + ), + ), + errorWidget: (context, url, error) => Container( + height: 140, + color: AppColors.grey100, + child: const Icon( + Icons.image_not_supported, + size: 48, + color: AppColors.grey500, + ), + ), + ), + ), + + // Promotion Info + Expanded( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + promotion.title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + promotion.description, + style: TextStyle( + fontSize: 12, + color: AppColors.grey500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/quick_action_item.dart b/lib/features/home/presentation/widgets/quick_action_item.dart new file mode 100644 index 0000000..66131d2 --- /dev/null +++ b/lib/features/home/presentation/widgets/quick_action_item.dart @@ -0,0 +1,109 @@ +/// Widget: Quick Action Item +/// +/// Individual action button with icon and label. +/// Used in quick action grids on the home screen. +library; + +import 'package:flutter/material.dart'; +import 'package:worker/core/theme/colors.dart'; + +/// Quick Action Item Widget +/// +/// Displays an icon button with a label below. +/// Supports optional badge for notifications or counts. +class QuickActionItem extends StatelessWidget { + /// Icon to display + final IconData icon; + + /// Label text + final String label; + + /// Optional badge text (e.g., "3" for cart items) + final String? badge; + + /// Tap callback + final VoidCallback? onTap; + + const QuickActionItem({ + super.key, + required this.icon, + required this.label, + this.badge, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Icon with optional badge + Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + size: 28, + color: AppColors.primaryBlue, + ), + ), + // Badge + if (badge != null && badge!.isNotEmpty) + Positioned( + top: -4, + right: -4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.danger, + borderRadius: BorderRadius.circular(10), + ), + constraints: const BoxConstraints( + minWidth: 20, + minHeight: 20, + ), + child: Text( + badge!, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + // Label + Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/home/presentation/widgets/quick_action_section.dart b/lib/features/home/presentation/widgets/quick_action_section.dart new file mode 100644 index 0000000..303fb7b --- /dev/null +++ b/lib/features/home/presentation/widgets/quick_action_section.dart @@ -0,0 +1,93 @@ +/// Widget: Quick Action Section +/// +/// Section container with title and grid of action items. +/// Groups related actions together (e.g., Products & Cart, Loyalty, etc.) +library; + +import 'package:flutter/material.dart'; +import 'package:worker/features/home/presentation/widgets/quick_action_item.dart'; + +/// Quick Action Section Data Model +class QuickAction { + final IconData icon; + final String label; + final String? badge; + final VoidCallback? onTap; + + const QuickAction({ + required this.icon, + required this.label, + this.badge, + this.onTap, + }); +} + +/// Quick Action Section Widget +/// +/// Displays a titled card containing a grid of action buttons. +/// Each section groups related functionality. +class QuickActionSection extends StatelessWidget { + /// Section title + final String title; + + /// List of actions in this section + final List actions; + + const QuickActionSection({ + super.key, + required this.title, + required this.actions, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Section Title + Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + // Action Grid + _buildActionGrid(), + ], + ), + ), + ); + } + + Widget _buildActionGrid() { + // Determine grid layout based on number of items + final int crossAxisCount = actions.length <= 2 ? 2 : 3; + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + childAspectRatio: 1.0, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: actions.length, + itemBuilder: (context, index) { + final action = actions[index]; + return QuickActionItem( + icon: action.icon, + label: action.label, + badge: action.badge, + onTap: action.onTap, + ); + }, + ); + } +} diff --git a/lib/hive_registrar.g.dart b/lib/hive_registrar.g.dart index 16d56db..eafd34d 100644 --- a/lib/hive_registrar.g.dart +++ b/lib/hive_registrar.g.dart @@ -5,11 +5,14 @@ import 'package:hive_ce/hive.dart'; import 'package:worker/core/database/models/cached_data.dart'; import 'package:worker/core/database/models/enums.dart'; +import 'package:worker/features/home/data/models/member_card_model.dart'; +import 'package:worker/features/home/data/models/promotion_model.dart'; extension HiveRegistrar on HiveInterface { void registerAdapters() { registerAdapter(CachedDataAdapter()); registerAdapter(GiftStatusAdapter()); + registerAdapter(MemberCardModelAdapter()); registerAdapter(MemberTierAdapter()); registerAdapter(NotificationTypeAdapter()); registerAdapter(OrderStatusAdapter()); @@ -17,6 +20,7 @@ extension HiveRegistrar on HiveInterface { registerAdapter(PaymentStatusAdapter()); registerAdapter(ProjectStatusAdapter()); registerAdapter(ProjectTypeAdapter()); + registerAdapter(PromotionModelAdapter()); registerAdapter(TransactionTypeAdapter()); registerAdapter(UserTypeAdapter()); } @@ -26,6 +30,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { void registerAdapters() { registerAdapter(CachedDataAdapter()); registerAdapter(GiftStatusAdapter()); + registerAdapter(MemberCardModelAdapter()); registerAdapter(MemberTierAdapter()); registerAdapter(NotificationTypeAdapter()); registerAdapter(OrderStatusAdapter()); @@ -33,6 +38,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(PaymentStatusAdapter()); registerAdapter(ProjectStatusAdapter()); registerAdapter(ProjectTypeAdapter()); + registerAdapter(PromotionModelAdapter()); registerAdapter(TransactionTypeAdapter()); registerAdapter(UserTypeAdapter()); }