773 lines
18 KiB
Markdown
773 lines
18 KiB
Markdown
# Flutter Code Examples & Patterns
|
|
|
|
This document contains all Dart code examples and patterns referenced in `CLAUDE.md`. Use these as templates when implementing features in the Worker app.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
- [Best Practices](#best-practices)
|
|
- [UI/UX Components](#uiux-components)
|
|
- [State Management](#state-management)
|
|
- [Performance Optimization](#performance-optimization)
|
|
- [Offline Strategy](#offline-strategy)
|
|
- [Localization](#localization)
|
|
- [Deployment](#deployment)
|
|
|
|
---
|
|
|
|
## Best Practices
|
|
|
|
### Hive Box Type Management
|
|
|
|
**✅ CORRECT - Use Box<dynamic> with type filtering**
|
|
```dart
|
|
Box<dynamic> get _box {
|
|
return Hive.box<dynamic>(HiveBoxNames.favoriteBox);
|
|
}
|
|
|
|
Future<List<FavoriteModel>> getAllFavorites(String userId) async {
|
|
try {
|
|
final favorites = _box.values
|
|
.whereType<FavoriteModel>() // Type-safe filtering
|
|
.where((fav) => fav.userId == userId)
|
|
.toList();
|
|
return favorites;
|
|
} catch (e) {
|
|
debugPrint('[DataSource] Error: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<List<FavoriteModel>> getAllFavorites() async {
|
|
return _box.values
|
|
.whereType<FavoriteModel>() // Type-safe!
|
|
.where((fav) => fav.userId == userId)
|
|
.toList();
|
|
}
|
|
```
|
|
|
|
**❌ INCORRECT - Will cause HiveError**
|
|
```dart
|
|
Box<FavoriteModel> get _box {
|
|
return Hive.box<FavoriteModel>(HiveBoxNames.favoriteBox);
|
|
}
|
|
```
|
|
|
|
**Reason**: Hive boxes are opened as `Box<dynamic>` in the central HiveService. Re-opening with a specific type causes `HiveError: The box is already open and of type Box<dynamic>`.
|
|
|
|
### AppBar Standardization
|
|
|
|
**Standard AppBar Pattern** (reference: `products_page.dart`):
|
|
```dart
|
|
AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
|
onPressed: () => context.pop(),
|
|
),
|
|
title: const Text('Page Title', style: TextStyle(color: Colors.black)),
|
|
elevation: AppBarSpecs.elevation,
|
|
backgroundColor: AppColors.white,
|
|
foregroundColor: AppColors.grey900,
|
|
centerTitle: false,
|
|
actions: [
|
|
// Custom actions here
|
|
const SizedBox(width: AppSpacing.sm), // Always end with spacing
|
|
],
|
|
)
|
|
```
|
|
|
|
**For SliverAppBar** (in CustomScrollView):
|
|
```dart
|
|
SliverAppBar(
|
|
pinned: true,
|
|
backgroundColor: AppColors.white,
|
|
foregroundColor: AppColors.grey900,
|
|
elevation: AppBarSpecs.elevation,
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
|
onPressed: () => context.pop(),
|
|
),
|
|
title: const Text('Page Title', style: TextStyle(color: Colors.black)),
|
|
centerTitle: false,
|
|
actions: [
|
|
// Custom actions
|
|
const SizedBox(width: AppSpacing.sm),
|
|
],
|
|
)
|
|
```
|
|
|
|
**Standard Pattern (Recent Implementation)**:
|
|
```dart
|
|
AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
|
onPressed: () => context.pop(),
|
|
),
|
|
title: const Text('Title', style: TextStyle(color: Colors.black)),
|
|
elevation: AppBarSpecs.elevation,
|
|
backgroundColor: AppColors.white,
|
|
foregroundColor: AppColors.grey900,
|
|
centerTitle: false,
|
|
actions: [..., const SizedBox(width: AppSpacing.sm)],
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## UI/UX Components
|
|
|
|
### Color Palette
|
|
|
|
```dart
|
|
// colors.dart
|
|
class AppColors {
|
|
// Primary
|
|
static const primaryBlue = Color(0xFF005B9A);
|
|
static const lightBlue = Color(0xFF38B6FF);
|
|
static const accentCyan = Color(0xFF35C6F4);
|
|
|
|
// Status
|
|
static const success = Color(0xFF28a745);
|
|
static const warning = Color(0xFFffc107);
|
|
static const danger = Color(0xFFdc3545);
|
|
static const info = Color(0xFF17a2b8);
|
|
|
|
// Neutrals
|
|
static const grey50 = Color(0xFFf8f9fa);
|
|
static const grey100 = Color(0xFFe9ecef);
|
|
static const grey500 = Color(0xFF6c757d);
|
|
static const grey900 = Color(0xFF343a40);
|
|
|
|
// Tier Gradients
|
|
static const diamondGradient = LinearGradient(
|
|
colors: [Color(0xFF4A00E0), Color(0xFF8E2DE2)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
);
|
|
|
|
static const platinumGradient = LinearGradient(
|
|
colors: [Color(0xFF7F8C8D), Color(0xFFBDC3C7)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
);
|
|
|
|
static const goldGradient = LinearGradient(
|
|
colors: [Color(0xFFf7b733), Color(0xFFfc4a1a)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
);
|
|
}
|
|
```
|
|
|
|
### Typography
|
|
|
|
```dart
|
|
// typography.dart
|
|
class AppTypography {
|
|
static const fontFamily = 'Roboto';
|
|
|
|
static const displayLarge = TextStyle(
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.bold,
|
|
fontFamily: fontFamily,
|
|
);
|
|
|
|
static const headlineLarge = TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w600,
|
|
fontFamily: fontFamily,
|
|
);
|
|
|
|
static const titleLarge = TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w500,
|
|
fontFamily: fontFamily,
|
|
);
|
|
|
|
static const bodyLarge = TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.normal,
|
|
fontFamily: fontFamily,
|
|
);
|
|
|
|
static const labelSmall = TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.normal,
|
|
fontFamily: fontFamily,
|
|
);
|
|
}
|
|
```
|
|
|
|
### Member Card Design
|
|
|
|
```dart
|
|
class MemberCardSpecs {
|
|
static const double width = double.infinity;
|
|
static const double height = 200;
|
|
static const double borderRadius = 16;
|
|
static const double elevation = 8;
|
|
static const EdgeInsets padding = EdgeInsets.all(20);
|
|
|
|
// QR Code
|
|
static const double qrSize = 80;
|
|
static const double qrBackgroundSize = 90;
|
|
|
|
// Points Display
|
|
static const double pointsFontSize = 28;
|
|
static const FontWeight pointsFontWeight = FontWeight.bold;
|
|
}
|
|
```
|
|
|
|
### Status Badges
|
|
|
|
```dart
|
|
class StatusBadge extends StatelessWidget {
|
|
final String status;
|
|
final Color color;
|
|
|
|
static Color getColorForStatus(OrderStatus status) {
|
|
switch (status) {
|
|
case OrderStatus.pending:
|
|
return AppColors.info;
|
|
case OrderStatus.processing:
|
|
return AppColors.warning;
|
|
case OrderStatus.shipping:
|
|
return AppColors.lightBlue;
|
|
case OrderStatus.completed:
|
|
return AppColors.success;
|
|
case OrderStatus.cancelled:
|
|
return AppColors.danger;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Bottom Navigation
|
|
|
|
```dart
|
|
class BottomNavSpecs {
|
|
static const double height = 72;
|
|
static const double iconSize = 24;
|
|
static const double selectedIconSize = 28;
|
|
static const double labelFontSize = 12;
|
|
static const Color selectedColor = AppColors.primaryBlue;
|
|
static const Color unselectedColor = AppColors.grey500;
|
|
}
|
|
```
|
|
|
|
### Floating Action Button
|
|
|
|
```dart
|
|
class FABSpecs {
|
|
static const double size = 56;
|
|
static const double elevation = 6;
|
|
static const Color backgroundColor = AppColors.accentCyan;
|
|
static const Color iconColor = Colors.white;
|
|
static const double iconSize = 24;
|
|
static const Offset position = Offset(16, 16); // from bottom-right
|
|
}
|
|
```
|
|
|
|
### AppBar Specifications
|
|
|
|
```dart
|
|
class AppBarSpecs {
|
|
// From ui_constants.dart
|
|
static const double elevation = 0.5;
|
|
|
|
// Standard pattern for all pages
|
|
static AppBar standard({
|
|
required String title,
|
|
required VoidCallback onBack,
|
|
List<Widget>? actions,
|
|
}) {
|
|
return AppBar(
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back, color: Colors.black),
|
|
onPressed: onBack,
|
|
),
|
|
title: Text(title, style: const TextStyle(color: Colors.black)),
|
|
elevation: elevation,
|
|
backgroundColor: AppColors.white,
|
|
foregroundColor: AppColors.grey900,
|
|
centerTitle: false,
|
|
actions: [
|
|
...?actions,
|
|
const SizedBox(width: AppSpacing.sm),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## State Management
|
|
|
|
### Authentication Providers
|
|
|
|
```dart
|
|
final authProvider = AsyncNotifierProvider<AuthNotifier, AuthState>
|
|
final otpTimerProvider = StateNotifierProvider<OTPTimerNotifier, int>
|
|
```
|
|
|
|
### Home Providers
|
|
|
|
```dart
|
|
final memberCardProvider = Provider<MemberCard>((ref) {
|
|
final user = ref.watch(authProvider).user;
|
|
return MemberCard(
|
|
tier: user.memberTier,
|
|
name: user.name,
|
|
memberId: user.id,
|
|
points: user.points,
|
|
qrCode: generateQRCode(user.id),
|
|
);
|
|
});
|
|
```
|
|
|
|
### Loyalty Providers
|
|
|
|
```dart
|
|
final loyaltyPointsProvider = AsyncNotifierProvider<LoyaltyPointsNotifier, LoyaltyPoints>
|
|
```
|
|
|
|
**Rewards Page Providers**:
|
|
```dart
|
|
// Providers in lib/features/loyalty/presentation/providers/
|
|
@riverpod
|
|
class LoyaltyPoints extends _$LoyaltyPoints {
|
|
// Manages 9,750 available points, 1,200 expiring
|
|
}
|
|
|
|
@riverpod
|
|
class Gifts extends _$Gifts {
|
|
// 6 mock gifts matching HTML design
|
|
}
|
|
|
|
@riverpod
|
|
List<GiftCatalog> filteredGifts(ref) {
|
|
// Filters by selected category
|
|
}
|
|
|
|
final selectedGiftCategoryProvider = StateNotifierProvider...
|
|
final hasEnoughPointsProvider = Provider.family<bool, int>...
|
|
```
|
|
|
|
### Referral Provider
|
|
|
|
```dart
|
|
final referralProvider = AsyncNotifierProvider<ReferralNotifier, Referral>
|
|
```
|
|
|
|
### Products Providers
|
|
|
|
```dart
|
|
final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>
|
|
final productSearchProvider = StateProvider<String>
|
|
final selectedCategoryProvider = StateProvider<String?>
|
|
```
|
|
|
|
### Cart Providers
|
|
|
|
```dart
|
|
final cartProvider = NotifierProvider<CartNotifier, List<CartItem>>
|
|
final cartTotalProvider = Provider<double>
|
|
```
|
|
|
|
**Dynamic Cart Badge**:
|
|
```dart
|
|
// Added provider in cart_provider.dart
|
|
@riverpod
|
|
int cartItemCount(CartItemCountRef ref) {
|
|
final cartState = ref.watch(cartProvider);
|
|
return cartState.items.fold(0, (sum, item) => sum + item.quantity);
|
|
}
|
|
|
|
// Used in home_page.dart and products_page.dart
|
|
final cartItemCount = ref.watch(cartItemCountProvider);
|
|
QuickAction(
|
|
badge: cartItemCount > 0 ? '$cartItemCount' : null,
|
|
)
|
|
```
|
|
|
|
### Orders Providers
|
|
|
|
```dart
|
|
final ordersProvider = AsyncNotifierProvider<OrdersNotifier, List<Order>>
|
|
final orderFilterProvider = StateProvider<OrderStatus?>
|
|
final paymentsProvider = AsyncNotifierProvider<PaymentsNotifier, List<Payment>>
|
|
```
|
|
|
|
### Projects Providers
|
|
|
|
```dart
|
|
final projectsProvider = AsyncNotifierProvider<ProjectsNotifier, List<Project>>
|
|
final projectFormProvider = StateNotifierProvider<ProjectFormNotifier, ProjectFormState>
|
|
```
|
|
|
|
### Chat Providers
|
|
|
|
```dart
|
|
final chatProvider = AsyncNotifierProvider<ChatNotifier, ChatRoom>
|
|
final messagesProvider = StreamProvider<List<Message>>
|
|
final typingIndicatorProvider = StateProvider<bool>
|
|
```
|
|
|
|
### Authentication State Implementation
|
|
|
|
```dart
|
|
@riverpod
|
|
class Auth extends _$Auth {
|
|
@override
|
|
Future<AuthState> build() async {
|
|
final token = await _getStoredToken();
|
|
if (token != null) {
|
|
final user = await _getUserFromToken(token);
|
|
return AuthState.authenticated(user);
|
|
}
|
|
return const AuthState.unauthenticated();
|
|
}
|
|
|
|
Future<void> loginWithPhone(String phone) async {
|
|
state = const AsyncValue.loading();
|
|
state = await AsyncValue.guard(() async {
|
|
await ref.read(authRepositoryProvider).requestOTP(phone);
|
|
return AuthState.otpSent(phone);
|
|
});
|
|
}
|
|
|
|
Future<void> verifyOTP(String phone, String otp) async {
|
|
state = const AsyncValue.loading();
|
|
state = await AsyncValue.guard(() async {
|
|
final response = await ref.read(authRepositoryProvider).verifyOTP(phone, otp);
|
|
await _storeToken(response.token);
|
|
return AuthState.authenticated(response.user);
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Optimization
|
|
|
|
### Image Caching
|
|
|
|
```dart
|
|
// Use cached_network_image for all remote images
|
|
CachedNetworkImage(
|
|
imageUrl: product.images.first,
|
|
placeholder: (context, url) => const ShimmerPlaceholder(),
|
|
errorWidget: (context, url, error) => const Icon(Icons.error),
|
|
fit: BoxFit.cover,
|
|
memCacheWidth: 400, // Optimize memory usage
|
|
fadeInDuration: const Duration(milliseconds: 300),
|
|
)
|
|
```
|
|
|
|
### List Performance
|
|
|
|
```dart
|
|
// Use ListView.builder with RepaintBoundary for long lists
|
|
ListView.builder(
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
return RepaintBoundary(
|
|
child: ProductCard(product: items[index]),
|
|
);
|
|
},
|
|
cacheExtent: 1000, // Pre-render items
|
|
)
|
|
|
|
// Use AutomaticKeepAliveClientMixin for expensive widgets
|
|
class ProductCard extends StatefulWidget {
|
|
@override
|
|
State<ProductCard> createState() => _ProductCardState();
|
|
}
|
|
|
|
class _ProductCardState extends State<ProductCard>
|
|
with AutomaticKeepAliveClientMixin {
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
return Card(...);
|
|
}
|
|
}
|
|
```
|
|
|
|
### State Optimization
|
|
|
|
```dart
|
|
// Use .select() to avoid unnecessary rebuilds
|
|
final userName = ref.watch(authProvider.select((state) => state.user?.name));
|
|
|
|
// Use family modifiers for parameterized providers
|
|
@riverpod
|
|
Future<Product> product(ProductRef ref, String id) async {
|
|
return await ref.read(productRepositoryProvider).getProduct(id);
|
|
}
|
|
|
|
// Keep providers outside build method
|
|
final productsProvider = ...;
|
|
|
|
class ProductsPage extends ConsumerWidget {
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final products = ref.watch(productsProvider);
|
|
return ...;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Offline Strategy
|
|
|
|
### Data Sync Flow
|
|
|
|
```dart
|
|
@riverpod
|
|
class DataSync extends _$DataSync {
|
|
@override
|
|
Future<SyncStatus> build() async {
|
|
// Listen to connectivity changes
|
|
ref.listen(connectivityProvider, (previous, next) {
|
|
if (next == ConnectivityStatus.connected) {
|
|
syncData();
|
|
}
|
|
});
|
|
|
|
return SyncStatus.idle;
|
|
}
|
|
|
|
Future<void> syncData() async {
|
|
state = const AsyncValue.loading();
|
|
|
|
state = await AsyncValue.guard(() async {
|
|
// Sync in order of dependency
|
|
await _syncUserData();
|
|
await _syncProducts();
|
|
await _syncOrders();
|
|
await _syncProjects();
|
|
await _syncLoyaltyData();
|
|
|
|
await ref.read(settingsRepositoryProvider).updateLastSyncTime();
|
|
|
|
return SyncStatus.success;
|
|
});
|
|
}
|
|
|
|
Future<void> _syncUserData() async {
|
|
final user = await ref.read(authRepositoryProvider).getCurrentUser();
|
|
await ref.read(authLocalDataSourceProvider).saveUser(user);
|
|
}
|
|
|
|
Future<void> _syncProducts() async {
|
|
final products = await ref.read(productRepositoryProvider).getAllProducts();
|
|
await ref.read(productLocalDataSourceProvider).saveProducts(products);
|
|
}
|
|
|
|
// ... other sync methods
|
|
}
|
|
```
|
|
|
|
### Offline Queue
|
|
|
|
```dart
|
|
// Queue failed requests for retry when online
|
|
class OfflineQueue {
|
|
final HiveInterface hive;
|
|
late Box<Map> _queueBox;
|
|
|
|
Future<void> init() async {
|
|
_queueBox = await hive.openBox('offline_queue');
|
|
}
|
|
|
|
Future<void> addToQueue(ApiRequest request) async {
|
|
await _queueBox.add({
|
|
'endpoint': request.endpoint,
|
|
'method': request.method,
|
|
'body': request.body,
|
|
'timestamp': DateTime.now().toIso8601String(),
|
|
});
|
|
}
|
|
|
|
Future<void> processQueue() async {
|
|
final requests = _queueBox.values.toList();
|
|
|
|
for (var i = 0; i < requests.length; i++) {
|
|
try {
|
|
await _executeRequest(requests[i]);
|
|
await _queueBox.deleteAt(i);
|
|
} catch (e) {
|
|
// Keep in queue for next retry
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Localization
|
|
|
|
### Setup
|
|
|
|
```dart
|
|
// l10n.yaml
|
|
arb-dir: lib/l10n
|
|
template-arb-file: app_en.arb
|
|
output-localization-file: app_localizations.dart
|
|
|
|
// lib/l10n/app_vi.arb (Vietnamese)
|
|
{
|
|
"@@locale": "vi",
|
|
"appTitle": "Worker App",
|
|
"login": "Đăng nhập",
|
|
"phone": "Số điện thoại",
|
|
"enterPhone": "Nhập số điện thoại",
|
|
"continue": "Tiếp tục",
|
|
"verifyOTP": "Xác thực OTP",
|
|
"enterOTP": "Nhập mã OTP 6 số",
|
|
"resendOTP": "Gửi lại mã",
|
|
"home": "Trang chủ",
|
|
"products": "Sản phẩm",
|
|
"loyalty": "Hội viên",
|
|
"account": "Tài khoản",
|
|
"points": "Điểm",
|
|
"cart": "Giỏ hàng",
|
|
"checkout": "Thanh toán",
|
|
"orders": "Đơn hàng",
|
|
"projects": "Công trình",
|
|
"quotes": "Báo giá",
|
|
"myGifts": "Quà của tôi",
|
|
"referral": "Giới thiệu bạn bè",
|
|
"pointsHistory": "Lịch sử điểm"
|
|
}
|
|
|
|
// lib/l10n/app_en.arb (English)
|
|
{
|
|
"@@locale": "en",
|
|
"appTitle": "Worker App",
|
|
"login": "Login",
|
|
"phone": "Phone Number",
|
|
"enterPhone": "Enter phone number",
|
|
"continue": "Continue",
|
|
...
|
|
}
|
|
```
|
|
|
|
### Usage
|
|
|
|
```dart
|
|
class LoginPage extends ConsumerWidget {
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(l10n.login),
|
|
),
|
|
body: Column(
|
|
children: [
|
|
TextField(
|
|
decoration: InputDecoration(
|
|
labelText: l10n.phone,
|
|
hintText: l10n.enterPhone,
|
|
),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {},
|
|
child: Text(l10n.continue),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Deployment
|
|
|
|
### Android
|
|
|
|
```gradle
|
|
// android/app/build.gradle
|
|
android {
|
|
compileSdkVersion 34
|
|
|
|
defaultConfig {
|
|
applicationId "com.eurotile.worker"
|
|
minSdkVersion 21
|
|
targetSdkVersion 34
|
|
versionCode 1
|
|
versionName "1.0.0"
|
|
}
|
|
|
|
signingConfigs {
|
|
release {
|
|
storeFile file(RELEASE_STORE_FILE)
|
|
storePassword RELEASE_STORE_PASSWORD
|
|
keyAlias RELEASE_KEY_ALIAS
|
|
keyPassword RELEASE_KEY_PASSWORD
|
|
}
|
|
}
|
|
|
|
buildTypes {
|
|
release {
|
|
signingConfig signingConfigs.release
|
|
minifyEnabled true
|
|
shrinkResources true
|
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### iOS
|
|
|
|
```ruby
|
|
# ios/Podfile
|
|
platform :ios, '13.0'
|
|
|
|
post_install do |installer|
|
|
installer.pods_project.targets.each do |target|
|
|
flutter_additional_ios_build_settings(target)
|
|
target.build_configurations.each do |config|
|
|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
|
|
end
|
|
end
|
|
end
|
|
```
|
|
|
|
---
|
|
|
|
## Quick Reference
|
|
|
|
### Key Requirements for All Code
|
|
|
|
- ✅ Black back arrow with explicit color
|
|
- ✅ Black text title with TextStyle
|
|
- ✅ Left-aligned title (`centerTitle: false`)
|
|
- ✅ White background (`AppColors.white`)
|
|
- ✅ Use `AppBarSpecs.elevation` (not hardcoded values)
|
|
- ✅ Always add `SizedBox(width: AppSpacing.sm)` after actions
|
|
- ✅ For SliverAppBar, add `pinned: true` property
|
|
- ✅ Use `Box<dynamic>` for Hive boxes with `.whereType<T>()` filtering
|
|
- ✅ Clean architecture (data/domain/presentation)
|
|
- ✅ Riverpod state management
|
|
- ✅ Hive for local persistence
|
|
- ✅ Material 3 design system
|
|
- ✅ Vietnamese localization
|
|
- ✅ CachedNetworkImage for all remote images
|
|
- ✅ Proper error handling
|
|
- ✅ Loading states (CircularProgressIndicator)
|
|
- ✅ Empty states with helpful messages
|