update
This commit is contained in:
85
CLAUDE.md
85
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<TextField>(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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:worker/core/theme/app_theme.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';
|
import 'package:worker/generated/l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Root application widget for Worker Mobile App
|
/// Root application widget for Worker Mobile App
|
||||||
@@ -70,8 +71,8 @@ class WorkerApp extends ConsumerWidget {
|
|||||||
// 2. Use Navigator 2.0 for imperative routing
|
// 2. Use Navigator 2.0 for imperative routing
|
||||||
// 3. Use auto_route for type-safe routing
|
// 3. Use auto_route for type-safe routing
|
||||||
//
|
//
|
||||||
// For now, we use a placeholder home screen
|
// For now, we show the home screen directly
|
||||||
home: const _PlaceholderHomePage(),
|
home: const HomePage(),
|
||||||
|
|
||||||
// Alternative: Use onGenerateRoute for custom routing
|
// Alternative: Use onGenerateRoute for custom routing
|
||||||
// onGenerateRoute: (settings) {
|
// onGenerateRoute: (settings) {
|
||||||
|
|||||||
189
lib/features/home/data/datasources/home_local_datasource.dart
Normal file
189
lib/features/home/data/datasources/home_local_datasource.dart
Normal file
@@ -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<MemberCardModel> getMemberCard();
|
||||||
|
|
||||||
|
/// Cache member card
|
||||||
|
///
|
||||||
|
/// Stores [MemberCardModel] in local database.
|
||||||
|
/// Overwrites existing data.
|
||||||
|
Future<void> cacheMemberCard(MemberCardModel memberCard);
|
||||||
|
|
||||||
|
/// Get cached promotions
|
||||||
|
///
|
||||||
|
/// Returns list of cached [PromotionModel].
|
||||||
|
/// Returns empty list if no cached data.
|
||||||
|
/// Throws [CacheException] if data corrupted.
|
||||||
|
Future<List<PromotionModel>> getPromotions();
|
||||||
|
|
||||||
|
/// Cache promotions
|
||||||
|
///
|
||||||
|
/// Stores list of [PromotionModel] in local database.
|
||||||
|
/// Overwrites existing data.
|
||||||
|
Future<void> cachePromotions(List<PromotionModel> promotions);
|
||||||
|
|
||||||
|
/// Clear all cached home data
|
||||||
|
///
|
||||||
|
/// Used when:
|
||||||
|
/// - User logs out
|
||||||
|
/// - Cache needs to be invalidated
|
||||||
|
Future<void> clearCache();
|
||||||
|
|
||||||
|
/// Check if member card cache is valid
|
||||||
|
///
|
||||||
|
/// Returns true if cache exists and not expired.
|
||||||
|
/// Cache is considered expired after 24 hours.
|
||||||
|
Future<bool> isMemberCardCacheValid();
|
||||||
|
|
||||||
|
/// Check if promotions cache is valid
|
||||||
|
///
|
||||||
|
/// Returns true if cache exists and not expired.
|
||||||
|
/// Cache is considered expired after 1 hour.
|
||||||
|
Future<bool> 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<String, dynamic> _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<Map<String, dynamic>> _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<MemberCardModel> 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<void> 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<List<PromotionModel>> 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<void> cachePromotions(List<PromotionModel> 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<void> 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<bool> isMemberCardCacheValid() async {
|
||||||
|
// Mock implementation - always return true for development
|
||||||
|
// TODO: Implement real cache validation when API is available
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isPromotionsCacheValid() async {
|
||||||
|
// Mock implementation - always return true for development
|
||||||
|
// TODO: Implement real cache validation when API is available
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
184
lib/features/home/data/models/member_card_model.dart
Normal file
184
lib/features/home/data/models/member_card_model.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
lib/features/home/data/models/member_card_model.g.dart
Normal file
59
lib/features/home/data/models/member_card_model.g.dart
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'member_card_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class MemberCardModelAdapter extends TypeAdapter<MemberCardModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 10;
|
||||||
|
|
||||||
|
@override
|
||||||
|
MemberCardModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
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;
|
||||||
|
}
|
||||||
207
lib/features/home/data/models/promotion_model.dart
Normal file
207
lib/features/home/data/models/promotion_model.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lib/features/home/data/models/promotion_model.g.dart
Normal file
68
lib/features/home/data/models/promotion_model.g.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'promotion_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// TypeAdapterGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
class PromotionModelAdapter extends TypeAdapter<PromotionModel> {
|
||||||
|
@override
|
||||||
|
final typeId = 11;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PromotionModel read(BinaryReader reader) {
|
||||||
|
final numOfFields = reader.readByte();
|
||||||
|
final fields = <int, dynamic>{
|
||||||
|
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;
|
||||||
|
}
|
||||||
161
lib/features/home/data/repositories/home_repository_impl.dart
Normal file
161
lib/features/home/data/repositories/home_repository_impl.dart
Normal file
@@ -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<MemberCard> 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<List<Promotion>> 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<MemberCard> 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<List<Promotion>> 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
lib/features/home/domain/entities/member_card.dart
Normal file
169
lib/features/home/domain/entities/member_card.dart
Normal file
@@ -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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
176
lib/features/home/domain/entities/promotion.dart
Normal file
176
lib/features/home/domain/entities/promotion.dart
Normal file
@@ -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)';
|
||||||
|
}
|
||||||
|
}
|
||||||
49
lib/features/home/domain/repositories/home_repository.dart
Normal file
49
lib/features/home/domain/repositories/home_repository.dart
Normal file
@@ -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<MemberCard> 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<List<Promotion>> getPromotions();
|
||||||
|
|
||||||
|
/// Refresh member card data from server
|
||||||
|
///
|
||||||
|
/// Force refresh member card information from remote source.
|
||||||
|
/// Updates local cache after successful fetch.
|
||||||
|
Future<MemberCard> refreshMemberCard();
|
||||||
|
|
||||||
|
/// Refresh promotions from server
|
||||||
|
///
|
||||||
|
/// Force refresh promotions from remote source.
|
||||||
|
/// Updates local cache after successful fetch.
|
||||||
|
Future<List<Promotion>> refreshPromotions();
|
||||||
|
}
|
||||||
62
lib/features/home/domain/usecases/get_member_card.dart
Normal file
62
lib/features/home/domain/usecases/get_member_card.dart
Normal file
@@ -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<MemberCard> 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<MemberCard> refresh() async {
|
||||||
|
return await repository.refreshMemberCard();
|
||||||
|
}
|
||||||
|
}
|
||||||
84
lib/features/home/domain/usecases/get_promotions.dart
Normal file
84
lib/features/home/domain/usecases/get_promotions.dart
Normal file
@@ -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<List<Promotion>> 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<List<Promotion>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
312
lib/features/home/presentation/pages/home_page.dart
Normal file
312
lib/features/home/presentation/pages/home_page.dart
Normal file
@@ -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<void>([
|
||||||
|
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<63>'),
|
||||||
|
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<EFBFBD>ng th<74> t<>i th<74> th<74>nh vi<76>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<74>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<EFBFBD>n ph<70>m & Gi<47> h<>ng',
|
||||||
|
actions: [
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.grid_view,
|
||||||
|
label: 'S<EFBFBD>n ph<70>m',
|
||||||
|
onTap: () => _showComingSoon(context, 'S<EFBFBD>n ph<70>m'),
|
||||||
|
),
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.shopping_cart,
|
||||||
|
label: 'Gi<EFBFBD> h<>ng',
|
||||||
|
badge: '3',
|
||||||
|
onTap: () => _showComingSoon(context, 'Gi<EFBFBD> h<>ng'),
|
||||||
|
),
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.favorite,
|
||||||
|
label: 'Y<EFBFBD>u th<74>ch',
|
||||||
|
onTap: () => _showComingSoon(context, 'Y<EFBFBD>u th<74>ch'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Loyalty Section
|
||||||
|
QuickActionSection(
|
||||||
|
title: 'Kh<EFBFBD>ch h<>ng th<74>n thi<68>t',
|
||||||
|
actions: [
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.card_giftcard,
|
||||||
|
label: '<10>i qu<71>',
|
||||||
|
onTap: () => _showComingSoon(context, '<10>i qu<71>'),
|
||||||
|
),
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.history,
|
||||||
|
label: 'L<EFBFBD>ch s<> i<>m',
|
||||||
|
onTap: () => _showComingSoon(context, 'L<EFBFBD>ch s<> i<>m'),
|
||||||
|
),
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.person_add,
|
||||||
|
label: 'Gi<EFBFBD>i thi<68>u b<>n',
|
||||||
|
onTap: () => _showComingSoon(context, 'Gi<EFBFBD>i thi<68>u b<>n'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Quote Requests Section
|
||||||
|
QuickActionSection(
|
||||||
|
title: 'Y<EFBFBD>u c<>u b<>o gi<67> & b<>o gi<67>',
|
||||||
|
actions: [
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.description,
|
||||||
|
label: 'Y<EFBFBD>u c<>u b<>o gi<67>',
|
||||||
|
onTap: () => _showComingSoon(context, 'Y<EFBFBD>u c<>u b<>o gi<67>'),
|
||||||
|
),
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.receipt_long,
|
||||||
|
label: 'B<EFBFBD>o gi<67>',
|
||||||
|
onTap: () => _showComingSoon(context, 'B<EFBFBD>o gi<67>'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Orders & Payments Section
|
||||||
|
QuickActionSection(
|
||||||
|
title: '<10>n h<>ng & thanh to<74>n',
|
||||||
|
actions: [
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.inventory_2,
|
||||||
|
label: '<10>n h<>ng',
|
||||||
|
onTap: () => _showComingSoon(context, '<10>n h<>ng'),
|
||||||
|
),
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.payment,
|
||||||
|
label: 'Thanh to<74>n',
|
||||||
|
onTap: () => _showComingSoon(context, 'Thanh to<74>n'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Sample Houses & News Section
|
||||||
|
QuickActionSection(
|
||||||
|
title: 'Nh<EFBFBD> m<>u, d<> <20>n & tin t<>c',
|
||||||
|
actions: [
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.home_work,
|
||||||
|
label: 'Nh<EFBFBD> m<>u',
|
||||||
|
onTap: () => _showComingSoon(context, 'Nh<EFBFBD> m<>u'),
|
||||||
|
),
|
||||||
|
QuickAction(
|
||||||
|
icon: Icons.business,
|
||||||
|
label: 'ng k<> d<> <20>n',
|
||||||
|
onTap: () => _showComingSoon(context, 'ng k<> d<> <20>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<63>',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.loyalty),
|
||||||
|
label: 'H<EFBFBD>i vi<76>n',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.local_offer),
|
||||||
|
label: 'Khuy<EFBFBD>n m<>i',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Badge(
|
||||||
|
label: Text('5'),
|
||||||
|
child: Icon(Icons.notifications),
|
||||||
|
),
|
||||||
|
label: 'Th<EFBFBD>ng b<>o',
|
||||||
|
),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.account_circle),
|
||||||
|
label: 'C<EFBFBD>i <11>t',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onTap: (index) {
|
||||||
|
// TODO: Implement navigation
|
||||||
|
final labels = [
|
||||||
|
'Trang ch<63>',
|
||||||
|
'H<EFBFBD>i vi<76>n',
|
||||||
|
'Khuy<EFBFBD>n m<>i',
|
||||||
|
'Th<EFBFBD>ng b<>o',
|
||||||
|
'C<EFBFBD>i <11>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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MemberCard> 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<void> 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MemberCardNotifier, MemberCard> {
|
||||||
|
/// 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<MemberCard> {
|
||||||
|
FutureOr<MemberCard> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final created = build();
|
||||||
|
final ref = this.ref as $Ref<AsyncValue<MemberCard>, MemberCard>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AsyncValue<MemberCard>, MemberCard>,
|
||||||
|
AsyncValue<MemberCard>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleValue(ref, created);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<Promotion>> 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<void> 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PromotionsNotifier, List<Promotion>> {
|
||||||
|
/// 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<List<Promotion>> {
|
||||||
|
FutureOr<List<Promotion>> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final created = build();
|
||||||
|
final ref = this.ref as $Ref<AsyncValue<List<Promotion>>, List<Promotion>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AsyncValue<List<Promotion>>, List<Promotion>>,
|
||||||
|
AsyncValue<List<Promotion>>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleValue(ref, created);
|
||||||
|
}
|
||||||
|
}
|
||||||
192
lib/features/home/presentation/widgets/member_card_widget.dart
Normal file
192
lib/features/home/presentation/widgets/member_card_widget.dart
Normal file
@@ -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]},',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
164
lib/features/home/presentation/widgets/promotion_slider.dart
Normal file
164
lib/features/home/presentation/widgets/promotion_slider.dart
Normal file
@@ -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<Promotion> 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
lib/features/home/presentation/widgets/quick_action_item.dart
Normal file
109
lib/features/home/presentation/widgets/quick_action_item.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<QuickAction> 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@
|
|||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
import 'package:worker/core/database/models/cached_data.dart';
|
import 'package:worker/core/database/models/cached_data.dart';
|
||||||
import 'package:worker/core/database/models/enums.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 {
|
extension HiveRegistrar on HiveInterface {
|
||||||
void registerAdapters() {
|
void registerAdapters() {
|
||||||
registerAdapter(CachedDataAdapter());
|
registerAdapter(CachedDataAdapter());
|
||||||
registerAdapter(GiftStatusAdapter());
|
registerAdapter(GiftStatusAdapter());
|
||||||
|
registerAdapter(MemberCardModelAdapter());
|
||||||
registerAdapter(MemberTierAdapter());
|
registerAdapter(MemberTierAdapter());
|
||||||
registerAdapter(NotificationTypeAdapter());
|
registerAdapter(NotificationTypeAdapter());
|
||||||
registerAdapter(OrderStatusAdapter());
|
registerAdapter(OrderStatusAdapter());
|
||||||
@@ -17,6 +20,7 @@ extension HiveRegistrar on HiveInterface {
|
|||||||
registerAdapter(PaymentStatusAdapter());
|
registerAdapter(PaymentStatusAdapter());
|
||||||
registerAdapter(ProjectStatusAdapter());
|
registerAdapter(ProjectStatusAdapter());
|
||||||
registerAdapter(ProjectTypeAdapter());
|
registerAdapter(ProjectTypeAdapter());
|
||||||
|
registerAdapter(PromotionModelAdapter());
|
||||||
registerAdapter(TransactionTypeAdapter());
|
registerAdapter(TransactionTypeAdapter());
|
||||||
registerAdapter(UserTypeAdapter());
|
registerAdapter(UserTypeAdapter());
|
||||||
}
|
}
|
||||||
@@ -26,6 +30,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
|
|||||||
void registerAdapters() {
|
void registerAdapters() {
|
||||||
registerAdapter(CachedDataAdapter());
|
registerAdapter(CachedDataAdapter());
|
||||||
registerAdapter(GiftStatusAdapter());
|
registerAdapter(GiftStatusAdapter());
|
||||||
|
registerAdapter(MemberCardModelAdapter());
|
||||||
registerAdapter(MemberTierAdapter());
|
registerAdapter(MemberTierAdapter());
|
||||||
registerAdapter(NotificationTypeAdapter());
|
registerAdapter(NotificationTypeAdapter());
|
||||||
registerAdapter(OrderStatusAdapter());
|
registerAdapter(OrderStatusAdapter());
|
||||||
@@ -33,6 +38,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
|
|||||||
registerAdapter(PaymentStatusAdapter());
|
registerAdapter(PaymentStatusAdapter());
|
||||||
registerAdapter(ProjectStatusAdapter());
|
registerAdapter(ProjectStatusAdapter());
|
||||||
registerAdapter(ProjectTypeAdapter());
|
registerAdapter(ProjectTypeAdapter());
|
||||||
|
registerAdapter(PromotionModelAdapter());
|
||||||
registerAdapter(TransactionTypeAdapter());
|
registerAdapter(TransactionTypeAdapter());
|
||||||
registerAdapter(UserTypeAdapter());
|
registerAdapter(UserTypeAdapter());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user