init cc
This commit is contained in:
35
lib/core/constants/app_constants.dart
Normal file
35
lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
/// Application-wide constants
|
||||
class AppConstants {
|
||||
// App Information
|
||||
static const String appName = 'Base Flutter';
|
||||
static const String appVersion = '1.0.0';
|
||||
|
||||
// API Configuration
|
||||
static const Duration connectionTimeout = Duration(seconds: 30);
|
||||
static const Duration receiveTimeout = Duration(seconds: 30);
|
||||
static const Duration sendTimeout = Duration(seconds: 30);
|
||||
|
||||
// Pagination
|
||||
static const int defaultPageSize = 20;
|
||||
static const int maxPageSize = 100;
|
||||
|
||||
// Cache Configuration
|
||||
static const Duration cacheExpiration = Duration(hours: 1);
|
||||
static const int maxCacheSize = 50 * 1024 * 1024; // 50MB
|
||||
|
||||
// Animation Durations
|
||||
static const Duration shortAnimation = Duration(milliseconds: 200);
|
||||
static const Duration mediumAnimation = Duration(milliseconds: 300);
|
||||
static const Duration longAnimation = Duration(milliseconds: 500);
|
||||
|
||||
// UI Constants
|
||||
static const double defaultPadding = 16.0;
|
||||
static const double smallPadding = 8.0;
|
||||
static const double largePadding = 24.0;
|
||||
static const double defaultRadius = 8.0;
|
||||
|
||||
// Error Messages
|
||||
static const String networkErrorMessage = 'Network connection error';
|
||||
static const String unknownErrorMessage = 'An unknown error occurred';
|
||||
static const String timeoutErrorMessage = 'Request timeout';
|
||||
}
|
||||
3
lib/core/constants/constants.dart
Normal file
3
lib/core/constants/constants.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
// Barrel export file for constants
|
||||
export 'app_constants.dart';
|
||||
export 'storage_constants.dart';
|
||||
24
lib/core/constants/storage_constants.dart
Normal file
24
lib/core/constants/storage_constants.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
/// Storage-related constants for Hive boxes and secure storage keys
|
||||
class StorageConstants {
|
||||
// Hive Box Names
|
||||
static const String appSettingsBox = 'app_settings';
|
||||
static const String userDataBox = 'user_data';
|
||||
static const String cacheBox = 'cache_box';
|
||||
|
||||
// Secure Storage Keys
|
||||
static const String authTokenKey = 'auth_token';
|
||||
static const String refreshTokenKey = 'refresh_token';
|
||||
static const String userCredentialsKey = 'user_credentials';
|
||||
static const String biometricKey = 'biometric_enabled';
|
||||
|
||||
// Cache Keys
|
||||
static const String userPreferencesKey = 'user_preferences';
|
||||
static const String appThemeKey = 'app_theme';
|
||||
static const String languageKey = 'selected_language';
|
||||
static const String firstRunKey = 'is_first_run';
|
||||
|
||||
// Settings Keys
|
||||
static const String isDarkModeKey = 'is_dark_mode';
|
||||
static const String notificationsEnabledKey = 'notifications_enabled';
|
||||
static const String autoSyncKey = 'auto_sync_enabled';
|
||||
}
|
||||
369
lib/core/database/README.md
Normal file
369
lib/core/database/README.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Hive Database Configuration
|
||||
|
||||
This directory contains the complete Hive database setup for local storage and caching in the Flutter app.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The Hive database system is organized into the following components:
|
||||
|
||||
```
|
||||
lib/core/database/
|
||||
├── hive_service.dart # Hive initialization and box management
|
||||
├── models/
|
||||
│ ├── app_settings.dart # App settings model with Hive adapter
|
||||
│ ├── app_settings.g.dart # Generated Hive adapter
|
||||
│ ├── cache_item.dart # Generic cache wrapper model
|
||||
│ ├── cache_item.g.dart # Generated Hive adapter
|
||||
│ ├── user_preferences.dart # User preferences model
|
||||
│ └── user_preferences.g.dart # Generated Hive adapter
|
||||
├── repositories/
|
||||
│ ├── settings_repository.dart # Settings repository implementation
|
||||
│ ├── cache_repository.dart # Cache repository implementation
|
||||
│ └── user_preferences_repository.dart # User preferences repository
|
||||
├── providers/
|
||||
│ └── database_providers.dart # Riverpod providers for database access
|
||||
└── examples/
|
||||
└── database_usage_example.dart # Usage examples
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 1. HiveService
|
||||
- **Initialization**: Sets up Hive for Flutter and registers type adapters
|
||||
- **Box Management**: Manages three main boxes (appSettingsBox, cacheBox, userDataBox)
|
||||
- **Migration Support**: Handles database schema migrations
|
||||
- **Error Handling**: Comprehensive error handling and logging
|
||||
- **Maintenance**: Database compaction and cleanup utilities
|
||||
|
||||
### 2. Models with Type Adapters
|
||||
|
||||
#### AppSettings (TypeId: 0)
|
||||
- Application-wide configuration
|
||||
- Theme mode, locale, notifications
|
||||
- Cache settings and auto-update preferences
|
||||
- Custom settings support
|
||||
- Expiration logic for settings refresh
|
||||
|
||||
#### CacheItem (TypeId: 1)
|
||||
- Generic cache wrapper for any data type
|
||||
- Expiration logic with TTL support
|
||||
- Metadata support for cache categorization
|
||||
- Version control for cache migrations
|
||||
- Statistics and performance monitoring
|
||||
|
||||
#### UserPreferences (TypeId: 2)
|
||||
- User-specific settings and data
|
||||
- Favorites management
|
||||
- Last accessed tracking
|
||||
- Preference categories with type safety
|
||||
- User activity statistics
|
||||
|
||||
### 3. Repository Pattern
|
||||
- **SettingsRepository**: Application settings management
|
||||
- **CacheRepository**: Generic caching with TTL and expiration
|
||||
- **UserPreferencesRepository**: User-specific data management
|
||||
|
||||
### 4. Riverpod Integration
|
||||
- State management with providers
|
||||
- Reactive updates when data changes
|
||||
- Type-safe access to cached data
|
||||
- Automatic cache invalidation
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Initialization
|
||||
```dart
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize Hive database service
|
||||
await HiveService.initialize();
|
||||
|
||||
runApp(const ProviderScope(child: MyApp()));
|
||||
}
|
||||
```
|
||||
|
||||
### Settings Management
|
||||
```dart
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
final notifier = ref.read(appSettingsProvider.notifier);
|
||||
|
||||
return SwitchListTile(
|
||||
title: const Text('Dark Mode'),
|
||||
value: settings.themeMode == 'dark',
|
||||
onChanged: (value) => notifier.updateThemeMode(value ? 'dark' : 'light'),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Caching Data
|
||||
```dart
|
||||
class DataService {
|
||||
final CacheRepository _cache;
|
||||
|
||||
Future<UserData> getUserData(String userId) async {
|
||||
// Try cache first
|
||||
final cachedData = _cache.get<UserData>('user_$userId');
|
||||
if (cachedData != null) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
final userData = await api.fetchUserData(userId);
|
||||
|
||||
// Cache with 1 hour expiration
|
||||
await _cache.put<UserData>(
|
||||
key: 'user_$userId',
|
||||
data: userData,
|
||||
expirationDuration: const Duration(hours: 1),
|
||||
metadata: {'type': 'user_data', 'userId': userId},
|
||||
);
|
||||
|
||||
return userData;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### User Preferences
|
||||
```dart
|
||||
class ProfileScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final preferences = ref.watch(userPreferencesProvider(null));
|
||||
final notifier = ref.read(userPreferencesProvider(null).notifier);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('Compact Mode'),
|
||||
value: preferences?.getPreference<bool>('compactMode', false) ?? false,
|
||||
onChanged: (value) => notifier.setPreference('compactMode', value),
|
||||
),
|
||||
// Favorites management
|
||||
IconButton(
|
||||
icon: Icon(preferences?.isFavorite('item123') == true
|
||||
? Icons.favorite : Icons.favorite_border),
|
||||
onPressed: () {
|
||||
if (preferences?.isFavorite('item123') == true) {
|
||||
notifier.removeFavorite('item123');
|
||||
} else {
|
||||
notifier.addFavorite('item123');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Database Boxes
|
||||
|
||||
### appSettingsBox
|
||||
- **Purpose**: Application-wide settings
|
||||
- **Key**: String-based keys (e.g., 'app_settings')
|
||||
- **Data**: AppSettings objects
|
||||
- **Usage**: Theme, language, cache strategy, feature flags
|
||||
|
||||
### cacheBox
|
||||
- **Purpose**: Generic caching with TTL
|
||||
- **Key**: String-based cache keys
|
||||
- **Data**: CacheItem objects wrapping any data type
|
||||
- **Usage**: API responses, user data, computed values
|
||||
|
||||
### userDataBox
|
||||
- **Purpose**: User-specific preferences and data
|
||||
- **Key**: User ID or 'current_user_preferences' for default
|
||||
- **Data**: UserPreferences objects
|
||||
- **Usage**: User settings, favorites, activity tracking
|
||||
|
||||
## Cache Strategies
|
||||
|
||||
### Write-Through Cache
|
||||
```dart
|
||||
Future<void> updateUserData(UserData data) async {
|
||||
// Update API
|
||||
await api.updateUser(data);
|
||||
|
||||
// Update cache
|
||||
await cacheRepository.put<UserData>(
|
||||
key: 'user_${data.id}',
|
||||
data: data,
|
||||
expirationDuration: const Duration(hours: 1),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Cache-Aside Pattern
|
||||
```dart
|
||||
Future<UserData> getUserData(String userId) async {
|
||||
// Check cache first
|
||||
var userData = cacheRepository.get<UserData>('user_$userId');
|
||||
|
||||
if (userData == null) {
|
||||
// Cache miss - fetch from API
|
||||
userData = await api.fetchUser(userId);
|
||||
|
||||
// Store in cache
|
||||
await cacheRepository.put<UserData>(
|
||||
key: 'user_$userId',
|
||||
data: userData,
|
||||
expirationDuration: const Duration(minutes: 30),
|
||||
);
|
||||
}
|
||||
|
||||
return userData;
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### 1. Lazy Loading
|
||||
- Boxes are opened only when needed
|
||||
- Data is loaded on-demand
|
||||
|
||||
### 2. Batch Operations
|
||||
```dart
|
||||
Future<void> cacheMultipleItems(Map<String, dynamic> items) async {
|
||||
final futures = items.entries.map((entry) =>
|
||||
cacheRepository.put<dynamic>(
|
||||
key: entry.key,
|
||||
data: entry.value,
|
||||
expirationDuration: const Duration(hours: 1),
|
||||
)
|
||||
);
|
||||
|
||||
await Future.wait(futures);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Cache Maintenance
|
||||
```dart
|
||||
// Automatic cleanup of expired items
|
||||
final result = await cacheRepository.performMaintenance();
|
||||
print('Cleaned ${result['expiredItemsRemoved']} expired items');
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Repository Level
|
||||
- All repository methods have comprehensive try-catch blocks
|
||||
- Errors are logged with stack traces
|
||||
- Graceful degradation (return defaults on errors)
|
||||
|
||||
### Provider Level
|
||||
- State management handles loading/error states
|
||||
- Automatic retry mechanisms
|
||||
- User-friendly error messages
|
||||
|
||||
### Service Level
|
||||
- Database initialization errors are handled gracefully
|
||||
- Box corruption recovery
|
||||
- Migration failure handling
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Version Control
|
||||
```dart
|
||||
class AppSettings {
|
||||
final int version; // Used for migrations
|
||||
|
||||
// Migration logic in repository
|
||||
AppSettings _migrateSettings(AppSettings oldSettings) {
|
||||
if (oldSettings.version < 2) {
|
||||
// Perform migration to version 2
|
||||
return oldSettings.copyWith(
|
||||
version: 2,
|
||||
// Add new fields with defaults
|
||||
newField: defaultValue,
|
||||
);
|
||||
}
|
||||
return oldSettings;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Data Backup
|
||||
```dart
|
||||
// Export data before migration
|
||||
final backup = settingsRepository.exportSettings();
|
||||
final userBackup = userPreferencesRepository.exportUserPreferences();
|
||||
|
||||
// Perform migration
|
||||
await HiveService.migrate();
|
||||
|
||||
// Restore if migration fails
|
||||
if (migrationFailed) {
|
||||
await settingsRepository.importSettings(backup);
|
||||
await userPreferencesRepository.importUserPreferences(userBackup);
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring and Analytics
|
||||
|
||||
### Database Statistics
|
||||
```dart
|
||||
final stats = ref.watch(databaseStatsProvider);
|
||||
print('Total items: ${stats['totalItems']}');
|
||||
print('Cache hit rate: ${stats['cache']['hitRate']}%');
|
||||
```
|
||||
|
||||
### Performance Metrics
|
||||
```dart
|
||||
final cacheStats = await cacheRepository.getStats();
|
||||
print('Cache performance:');
|
||||
print('- Hit rate: ${(cacheStats.hitRate * 100).toStringAsFixed(1)}%');
|
||||
print('- Valid items: ${cacheStats.validItems}');
|
||||
print('- Expired items: ${cacheStats.expiredItems}');
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Key Naming Convention
|
||||
- Use descriptive, hierarchical keys: `user_123_profile`
|
||||
- Include type information: `api_response_movies_popular`
|
||||
- Use consistent separators: underscores or colons
|
||||
|
||||
### 2. TTL Management
|
||||
- Short TTL for frequently changing data (5-30 minutes)
|
||||
- Medium TTL for stable data (1-24 hours)
|
||||
- Long TTL for static data (1-7 days)
|
||||
- Permanent cache only for user preferences
|
||||
|
||||
### 3. Data Validation
|
||||
- Validate data before caching
|
||||
- Check data integrity on retrieval
|
||||
- Handle corrupted data gracefully
|
||||
|
||||
### 4. Memory Management
|
||||
- Regular cleanup of expired items
|
||||
- Monitor cache size and performance
|
||||
- Use appropriate data structures
|
||||
|
||||
### 5. Security
|
||||
- Don't cache sensitive data without encryption
|
||||
- Clear caches on logout
|
||||
- Validate user access to cached data
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Test repository methods with mock boxes
|
||||
- Validate data serialization/deserialization
|
||||
- Test error handling scenarios
|
||||
|
||||
### Integration Tests
|
||||
- Test full database workflows
|
||||
- Validate Riverpod provider interactions
|
||||
- Test migration scenarios
|
||||
|
||||
### Performance Tests
|
||||
- Measure cache hit rates
|
||||
- Test with large datasets
|
||||
- Monitor memory usage
|
||||
|
||||
This Hive database setup provides a robust, scalable foundation for local data storage and caching in your Flutter application.
|
||||
412
lib/core/database/examples/database_usage_example.dart
Normal file
412
lib/core/database/examples/database_usage_example.dart
Normal file
@@ -0,0 +1,412 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/database_providers.dart';
|
||||
|
||||
/// Example widget showing how to use the Hive database system
|
||||
class DatabaseUsageExample extends ConsumerWidget {
|
||||
const DatabaseUsageExample({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Database Usage Example'),
|
||||
),
|
||||
body: const SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_AppSettingsSection(),
|
||||
SizedBox(height: 24),
|
||||
_CacheSection(),
|
||||
SizedBox(height: 24),
|
||||
_UserPreferencesSection(),
|
||||
SizedBox(height: 24),
|
||||
_DatabaseStatsSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// App Settings Section
|
||||
class _AppSettingsSection extends ConsumerWidget {
|
||||
const _AppSettingsSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
final notifier = ref.read(appSettingsProvider.notifier);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('App Settings', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Theme Mode
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Theme Mode'),
|
||||
DropdownButton<String>(
|
||||
value: settings.themeMode,
|
||||
onChanged: (value) => notifier.updateThemeMode(value!),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'system', child: Text('System')),
|
||||
DropdownMenuItem(value: 'light', child: Text('Light')),
|
||||
DropdownMenuItem(value: 'dark', child: Text('Dark')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Notifications
|
||||
SwitchListTile(
|
||||
title: const Text('Notifications'),
|
||||
value: settings.notificationsEnabled,
|
||||
onChanged: (value) => notifier.updateNotificationsEnabled(value),
|
||||
),
|
||||
|
||||
// Analytics
|
||||
SwitchListTile(
|
||||
title: const Text('Analytics'),
|
||||
value: settings.analyticsEnabled,
|
||||
onChanged: (value) => notifier.updateAnalyticsEnabled(value),
|
||||
),
|
||||
|
||||
// Cache Strategy
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('Cache Strategy'),
|
||||
DropdownButton<String>(
|
||||
value: settings.cacheStrategy,
|
||||
onChanged: (value) => notifier.updateCacheStrategy(value!),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'minimal', child: Text('Minimal')),
|
||||
DropdownMenuItem(value: 'normal', child: Text('Normal')),
|
||||
DropdownMenuItem(value: 'aggressive', child: Text('Aggressive')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () => notifier.resetToDefault(),
|
||||
child: const Text('Reset to Default'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache Section
|
||||
class _CacheSection extends ConsumerWidget {
|
||||
const _CacheSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheRepository = ref.watch(cacheRepositoryProvider);
|
||||
final cacheStatsAsyncValue = ref.watch(cacheStatsProvider);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Cache Management', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Cache Statistics
|
||||
cacheStatsAsyncValue.when(
|
||||
data: (stats) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Total Items: ${stats.totalItems}'),
|
||||
Text('Valid Items: ${stats.validItems}'),
|
||||
Text('Expired Items: ${stats.expiredItems}'),
|
||||
Text('Hit Rate: ${(stats.hitRate * 100).toStringAsFixed(1)}%'),
|
||||
],
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (error, _) => Text('Error: $error'),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Cache Actions
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
// Example: Cache some data
|
||||
await cacheRepository.put<String>(
|
||||
key: 'example_key',
|
||||
data: 'Hello, Cache!',
|
||||
expirationDuration: const Duration(minutes: 30),
|
||||
metadata: {'type': 'example', 'created_by': 'user'},
|
||||
);
|
||||
ref.invalidate(cacheStatsProvider);
|
||||
},
|
||||
child: const Text('Add Cache Item'),
|
||||
),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final data = cacheRepository.get<String>('example_key');
|
||||
if (data != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cached data: $data')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No cached data found')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Get Cache Item'),
|
||||
),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final expiredCount = await cacheRepository.clearExpired();
|
||||
ref.invalidate(cacheStatsProvider);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cleared $expiredCount expired items')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Clear Expired'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// User Preferences Section
|
||||
class _UserPreferencesSection extends ConsumerWidget {
|
||||
const _UserPreferencesSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userPreferences = ref.watch(userPreferencesProvider(null));
|
||||
final notifier = ref.read(userPreferencesProvider(null).notifier);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('User Preferences', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (userPreferences == null)
|
||||
Column(
|
||||
children: [
|
||||
const Text('No user preferences found'),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await notifier.createUserPreferences(
|
||||
userId: 'demo_user',
|
||||
displayName: 'Demo User',
|
||||
email: 'demo@example.com',
|
||||
preferences: {
|
||||
'compactMode': false,
|
||||
'sortBy': 'name',
|
||||
'gridColumns': 2,
|
||||
},
|
||||
);
|
||||
},
|
||||
child: const Text('Create Demo User'),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('User ID: ${userPreferences.userId}'),
|
||||
Text('Display Name: ${userPreferences.displayName}'),
|
||||
Text('Email: ${userPreferences.email ?? 'Not set'}'),
|
||||
Text('Favorites: ${userPreferences.favoriteItems.length}'),
|
||||
Text('Preferences: ${userPreferences.preferences.length}'),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Preference Controls
|
||||
SwitchListTile(
|
||||
title: const Text('Compact Mode'),
|
||||
value: userPreferences.getPreference<bool>('compactMode', false),
|
||||
onChanged: (value) => notifier.setPreference('compactMode', value),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Actions
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await notifier.addFavorite('item_${DateTime.now().millisecondsSinceEpoch}');
|
||||
},
|
||||
child: const Text('Add Random Favorite'),
|
||||
),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await notifier.updateLastAccessed('demo_item');
|
||||
},
|
||||
child: const Text('Update Last Accessed'),
|
||||
),
|
||||
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await notifier.clearPreferences();
|
||||
},
|
||||
child: const Text('Clear User Data'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Database Statistics Section
|
||||
class _DatabaseStatsSection extends ConsumerWidget {
|
||||
const _DatabaseStatsSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final databaseStats = ref.watch(databaseStatsProvider);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Database Statistics', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
Text('Total Items: ${databaseStats['totalItems']}'),
|
||||
|
||||
if (databaseStats['settings'] != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text('Settings:', style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
...((databaseStats['settings'] as Map<String, dynamic>).entries.map(
|
||||
(entry) => Text(' ${entry.key}: ${entry.value}'),
|
||||
)),
|
||||
],
|
||||
|
||||
if (databaseStats['cache'] != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text('Cache:', style: TextStyle(fontWeight: FontWeight.w500)),
|
||||
...((databaseStats['cache'] as Map<String, dynamic>).entries.map(
|
||||
(entry) => Text(' ${entry.key}: ${entry.value}'),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper functions for demonstrating cache operations
|
||||
class CacheExamples {
|
||||
static Future<void> demonstrateCaching(WidgetRef ref) async {
|
||||
final cacheRepository = ref.read(cacheRepositoryProvider);
|
||||
|
||||
// Store different types of data
|
||||
await cacheRepository.put<String>(
|
||||
key: 'user_token',
|
||||
data: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||
expirationDuration: const Duration(hours: 1),
|
||||
metadata: {'type': 'auth_token'},
|
||||
);
|
||||
|
||||
await cacheRepository.put<Map<String, dynamic>>(
|
||||
key: 'user_profile',
|
||||
data: {
|
||||
'id': 123,
|
||||
'name': 'John Doe',
|
||||
'email': 'john@example.com',
|
||||
},
|
||||
expirationDuration: const Duration(minutes: 30),
|
||||
metadata: {'type': 'user_data'},
|
||||
);
|
||||
|
||||
await cacheRepository.put<List<String>>(
|
||||
key: 'recent_searches',
|
||||
data: ['flutter', 'dart', 'hive'],
|
||||
expirationDuration: const Duration(days: 7),
|
||||
metadata: {'type': 'user_activity'},
|
||||
);
|
||||
|
||||
// Retrieve data
|
||||
final token = cacheRepository.get<String>('user_token');
|
||||
final profile = cacheRepository.get<Map<String, dynamic>>('user_profile');
|
||||
final searches = cacheRepository.get<List<String>>('recent_searches');
|
||||
|
||||
print('Token: $token');
|
||||
print('Profile: $profile');
|
||||
print('Searches: $searches');
|
||||
}
|
||||
|
||||
static Future<void> demonstrateUserPreferences(WidgetRef ref) async {
|
||||
final repository = ref.read(userPreferencesRepositoryProvider);
|
||||
|
||||
// Create user preferences
|
||||
final preferences = await repository.createUserPreferences(
|
||||
userId: 'user_123',
|
||||
displayName: 'Alice Smith',
|
||||
email: 'alice@example.com',
|
||||
preferences: {
|
||||
'theme': 'dark',
|
||||
'notifications': true,
|
||||
'language': 'en',
|
||||
'autoBackup': false,
|
||||
},
|
||||
);
|
||||
|
||||
print('Created preferences: $preferences');
|
||||
|
||||
// Update preferences
|
||||
await repository.setPreference('theme', 'light', 'user_123');
|
||||
await repository.addFavorite('movie_456', 'user_123');
|
||||
await repository.updateLastAccessed('series_789', 'user_123');
|
||||
|
||||
// Retrieve data
|
||||
final theme = repository.getPreference<String>('theme', 'system', 'user_123');
|
||||
final favorites = repository.getFavorites('user_123');
|
||||
final recentItems = repository.getRecentlyAccessed(userId: 'user_123');
|
||||
|
||||
print('Theme: $theme');
|
||||
print('Favorites: $favorites');
|
||||
print('Recent items: $recentItems');
|
||||
}
|
||||
}
|
||||
180
lib/core/database/hive_service.dart
Normal file
180
lib/core/database/hive_service.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'models/app_settings.dart';
|
||||
import 'models/cache_item.dart';
|
||||
import 'models/user_preferences.dart';
|
||||
|
||||
/// Hive database service for initialization and box management
|
||||
class HiveService {
|
||||
static const String _appSettingsBoxName = 'appSettingsBox';
|
||||
static const String _cacheBoxName = 'cacheBox';
|
||||
static const String _userDataBoxName = 'userDataBox';
|
||||
|
||||
// Private boxes - access through getters
|
||||
static Box<AppSettings>? _appSettingsBox;
|
||||
static Box<CacheItem>? _cacheBox;
|
||||
static Box<UserPreferences>? _userDataBox;
|
||||
|
||||
/// Initialize Hive database
|
||||
static Future<void> initialize() async {
|
||||
try {
|
||||
// Initialize Hive for Flutter
|
||||
await Hive.initFlutter();
|
||||
|
||||
// Register type adapters
|
||||
await _registerAdapters();
|
||||
|
||||
// Open boxes
|
||||
await _openBoxes();
|
||||
|
||||
debugPrint('✅ Hive initialized successfully');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('❌ Failed to initialize Hive: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Register all Hive type adapters
|
||||
static Future<void> _registerAdapters() async {
|
||||
// Register adapters only if not already registered
|
||||
if (!Hive.isAdapterRegistered(0)) {
|
||||
Hive.registerAdapter(AppSettingsAdapter());
|
||||
}
|
||||
if (!Hive.isAdapterRegistered(1)) {
|
||||
Hive.registerAdapter(CacheItemAdapter());
|
||||
}
|
||||
if (!Hive.isAdapterRegistered(2)) {
|
||||
Hive.registerAdapter(UserPreferencesAdapter());
|
||||
}
|
||||
}
|
||||
|
||||
/// Open all required Hive boxes
|
||||
static Future<void> _openBoxes() async {
|
||||
try {
|
||||
_appSettingsBox = await Hive.openBox<AppSettings>(_appSettingsBoxName);
|
||||
_cacheBox = await Hive.openBox<CacheItem>(_cacheBoxName);
|
||||
_userDataBox = await Hive.openBox<UserPreferences>(_userDataBoxName);
|
||||
} catch (e) {
|
||||
debugPrint('Error opening Hive boxes: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get app settings box
|
||||
static Box<AppSettings> get appSettingsBox {
|
||||
if (_appSettingsBox == null || !_appSettingsBox!.isOpen) {
|
||||
throw StateError('AppSettings box is not initialized. Call HiveService.initialize() first.');
|
||||
}
|
||||
return _appSettingsBox!;
|
||||
}
|
||||
|
||||
/// Get cache box
|
||||
static Box<CacheItem> get cacheBox {
|
||||
if (_cacheBox == null || !_cacheBox!.isOpen) {
|
||||
throw StateError('Cache box is not initialized. Call HiveService.initialize() first.');
|
||||
}
|
||||
return _cacheBox!;
|
||||
}
|
||||
|
||||
/// Get user data box
|
||||
static Box<UserPreferences> get userDataBox {
|
||||
if (_userDataBox == null || !_userDataBox!.isOpen) {
|
||||
throw StateError('UserData box is not initialized. Call HiveService.initialize() first.');
|
||||
}
|
||||
return _userDataBox!;
|
||||
}
|
||||
|
||||
/// Check if Hive is initialized
|
||||
static bool get isInitialized {
|
||||
return _appSettingsBox != null &&
|
||||
_cacheBox != null &&
|
||||
_userDataBox != null &&
|
||||
_appSettingsBox!.isOpen &&
|
||||
_cacheBox!.isOpen &&
|
||||
_userDataBox!.isOpen;
|
||||
}
|
||||
|
||||
/// Close all boxes (call this when app is terminated)
|
||||
static Future<void> closeAll() async {
|
||||
try {
|
||||
await _appSettingsBox?.close();
|
||||
await _cacheBox?.close();
|
||||
await _userDataBox?.close();
|
||||
debugPrint('✅ All Hive boxes closed successfully');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error closing Hive boxes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all data (use with caution - for testing or reset functionality)
|
||||
static Future<void> clearAll() async {
|
||||
try {
|
||||
await _appSettingsBox?.clear();
|
||||
await _cacheBox?.clear();
|
||||
await _userDataBox?.clear();
|
||||
debugPrint('✅ All Hive boxes cleared successfully');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error clearing Hive boxes: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact all boxes to optimize storage
|
||||
static Future<void> compactAll() async {
|
||||
try {
|
||||
await _appSettingsBox?.compact();
|
||||
await _cacheBox?.compact();
|
||||
await _userDataBox?.compact();
|
||||
debugPrint('✅ All Hive boxes compacted successfully');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error compacting Hive boxes: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get database statistics
|
||||
static Map<String, dynamic> getStats() {
|
||||
return {
|
||||
'isInitialized': isInitialized,
|
||||
'appSettingsCount': _appSettingsBox?.length ?? 0,
|
||||
'cacheItemsCount': _cacheBox?.length ?? 0,
|
||||
'userDataCount': _userDataBox?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Perform database migration if needed
|
||||
static Future<void> migrate() async {
|
||||
try {
|
||||
// Check current version and migrate if needed
|
||||
final currentVersion = _appSettingsBox?.get('db_version')?.version ?? 1;
|
||||
const latestVersion = 1;
|
||||
|
||||
if (currentVersion < latestVersion) {
|
||||
await _performMigration(currentVersion, latestVersion);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error during database migration: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform database migration from one version to another
|
||||
static Future<void> _performMigration(int fromVersion, int toVersion) async {
|
||||
debugPrint('🔄 Migrating database from version $fromVersion to $toVersion');
|
||||
|
||||
// Add migration logic here as needed
|
||||
// Example:
|
||||
// if (fromVersion < 2) {
|
||||
// await _migrateToVersion2();
|
||||
// }
|
||||
|
||||
// Update database version
|
||||
await _appSettingsBox?.put('db_version', AppSettings(
|
||||
version: toVersion,
|
||||
themeMode: 'system',
|
||||
locale: 'en',
|
||||
lastUpdated: DateTime.now(),
|
||||
));
|
||||
|
||||
debugPrint('✅ Database migration completed');
|
||||
}
|
||||
}
|
||||
212
lib/core/database/models/app_settings.dart
Normal file
212
lib/core/database/models/app_settings.dart
Normal file
@@ -0,0 +1,212 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'app_settings.g.dart';
|
||||
|
||||
/// App settings model for storing application-wide configuration
|
||||
@HiveType(typeId: 0)
|
||||
class AppSettings extends HiveObject {
|
||||
@HiveField(0)
|
||||
final int version;
|
||||
|
||||
@HiveField(1)
|
||||
final String themeMode; // 'light', 'dark', 'system'
|
||||
|
||||
@HiveField(2)
|
||||
final String locale; // Language code (e.g., 'en', 'es')
|
||||
|
||||
@HiveField(3)
|
||||
final bool notificationsEnabled;
|
||||
|
||||
@HiveField(4)
|
||||
final bool analyticsEnabled;
|
||||
|
||||
@HiveField(5)
|
||||
final String cacheStrategy; // 'aggressive', 'normal', 'minimal'
|
||||
|
||||
@HiveField(6)
|
||||
final int cacheExpirationHours;
|
||||
|
||||
@HiveField(7)
|
||||
final bool autoUpdateEnabled;
|
||||
|
||||
@HiveField(8)
|
||||
final DateTime lastUpdated;
|
||||
|
||||
@HiveField(9)
|
||||
final Map<String, dynamic>? customSettings;
|
||||
|
||||
AppSettings({
|
||||
this.version = 1,
|
||||
this.themeMode = 'system',
|
||||
this.locale = 'en',
|
||||
this.notificationsEnabled = true,
|
||||
this.analyticsEnabled = false,
|
||||
this.cacheStrategy = 'normal',
|
||||
this.cacheExpirationHours = 24,
|
||||
this.autoUpdateEnabled = true,
|
||||
required this.lastUpdated,
|
||||
this.customSettings,
|
||||
});
|
||||
|
||||
/// Create a copy with modified fields
|
||||
AppSettings copyWith({
|
||||
int? version,
|
||||
String? themeMode,
|
||||
String? locale,
|
||||
bool? notificationsEnabled,
|
||||
bool? analyticsEnabled,
|
||||
String? cacheStrategy,
|
||||
int? cacheExpirationHours,
|
||||
bool? autoUpdateEnabled,
|
||||
DateTime? lastUpdated,
|
||||
Map<String, dynamic>? customSettings,
|
||||
}) {
|
||||
return AppSettings(
|
||||
version: version ?? this.version,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
locale: locale ?? this.locale,
|
||||
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
||||
analyticsEnabled: analyticsEnabled ?? this.analyticsEnabled,
|
||||
cacheStrategy: cacheStrategy ?? this.cacheStrategy,
|
||||
cacheExpirationHours: cacheExpirationHours ?? this.cacheExpirationHours,
|
||||
autoUpdateEnabled: autoUpdateEnabled ?? this.autoUpdateEnabled,
|
||||
lastUpdated: lastUpdated ?? this.lastUpdated,
|
||||
customSettings: customSettings ?? this.customSettings,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create default app settings
|
||||
factory AppSettings.defaultSettings() {
|
||||
return AppSettings(
|
||||
version: 1,
|
||||
themeMode: 'system',
|
||||
locale: 'en',
|
||||
notificationsEnabled: true,
|
||||
analyticsEnabled: false,
|
||||
cacheStrategy: 'normal',
|
||||
cacheExpirationHours: 24,
|
||||
autoUpdateEnabled: true,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to Map for JSON serialization
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'version': version,
|
||||
'themeMode': themeMode,
|
||||
'locale': locale,
|
||||
'notificationsEnabled': notificationsEnabled,
|
||||
'analyticsEnabled': analyticsEnabled,
|
||||
'cacheStrategy': cacheStrategy,
|
||||
'cacheExpirationHours': cacheExpirationHours,
|
||||
'autoUpdateEnabled': autoUpdateEnabled,
|
||||
'lastUpdated': lastUpdated.toIso8601String(),
|
||||
'customSettings': customSettings,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from Map for JSON deserialization
|
||||
factory AppSettings.fromMap(Map<String, dynamic> map) {
|
||||
return AppSettings(
|
||||
version: map['version'] ?? 1,
|
||||
themeMode: map['themeMode'] ?? 'system',
|
||||
locale: map['locale'] ?? 'en',
|
||||
notificationsEnabled: map['notificationsEnabled'] ?? true,
|
||||
analyticsEnabled: map['analyticsEnabled'] ?? false,
|
||||
cacheStrategy: map['cacheStrategy'] ?? 'normal',
|
||||
cacheExpirationHours: map['cacheExpirationHours'] ?? 24,
|
||||
autoUpdateEnabled: map['autoUpdateEnabled'] ?? true,
|
||||
lastUpdated: DateTime.parse(map['lastUpdated'] ?? DateTime.now().toIso8601String()),
|
||||
customSettings: map['customSettings']?.cast<String, dynamic>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if settings are expired (for auto-refresh logic)
|
||||
bool isExpired({int maxAgeHours = 168}) { // Default 1 week
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(lastUpdated);
|
||||
return difference.inHours > maxAgeHours;
|
||||
}
|
||||
|
||||
/// Get custom setting value
|
||||
T? getCustomSetting<T>(String key) {
|
||||
return customSettings?[key] as T?;
|
||||
}
|
||||
|
||||
/// Set custom setting value
|
||||
AppSettings setCustomSetting(String key, dynamic value) {
|
||||
final updatedCustomSettings = Map<String, dynamic>.from(customSettings ?? {});
|
||||
updatedCustomSettings[key] = value;
|
||||
|
||||
return copyWith(
|
||||
customSettings: updatedCustomSettings,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove custom setting
|
||||
AppSettings removeCustomSetting(String key) {
|
||||
if (customSettings == null) return this;
|
||||
|
||||
final updatedCustomSettings = Map<String, dynamic>.from(customSettings!);
|
||||
updatedCustomSettings.remove(key);
|
||||
|
||||
return copyWith(
|
||||
customSettings: updatedCustomSettings.isNotEmpty ? updatedCustomSettings : null,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AppSettings{version: $version, themeMode: $themeMode, locale: $locale, '
|
||||
'notificationsEnabled: $notificationsEnabled, analyticsEnabled: $analyticsEnabled, '
|
||||
'cacheStrategy: $cacheStrategy, cacheExpirationHours: $cacheExpirationHours, '
|
||||
'autoUpdateEnabled: $autoUpdateEnabled, lastUpdated: $lastUpdated, '
|
||||
'customSettings: $customSettings}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is AppSettings &&
|
||||
other.version == version &&
|
||||
other.themeMode == themeMode &&
|
||||
other.locale == locale &&
|
||||
other.notificationsEnabled == notificationsEnabled &&
|
||||
other.analyticsEnabled == analyticsEnabled &&
|
||||
other.cacheStrategy == cacheStrategy &&
|
||||
other.cacheExpirationHours == cacheExpirationHours &&
|
||||
other.autoUpdateEnabled == autoUpdateEnabled &&
|
||||
other.lastUpdated == lastUpdated &&
|
||||
_mapEquals(other.customSettings, customSettings);
|
||||
}
|
||||
|
||||
bool _mapEquals(Map<String, dynamic>? a, Map<String, dynamic>? b) {
|
||||
if (a == null) return b == null;
|
||||
if (b == null) return false;
|
||||
if (a.length != b.length) return false;
|
||||
for (final key in a.keys) {
|
||||
if (!b.containsKey(key) || a[key] != b[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
version,
|
||||
themeMode,
|
||||
locale,
|
||||
notificationsEnabled,
|
||||
analyticsEnabled,
|
||||
cacheStrategy,
|
||||
cacheExpirationHours,
|
||||
autoUpdateEnabled,
|
||||
lastUpdated,
|
||||
customSettings?.hashCode ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/core/database/models/app_settings.g.dart
Normal file
68
lib/core/database/models/app_settings.g.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
|
||||
@override
|
||||
AppSettings read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return AppSettings(
|
||||
version: fields[0] as int,
|
||||
themeMode: fields[1] as String,
|
||||
locale: fields[2] as String,
|
||||
notificationsEnabled: fields[3] as bool,
|
||||
analyticsEnabled: fields[4] as bool,
|
||||
cacheStrategy: fields[5] as String,
|
||||
cacheExpirationHours: fields[6] as int,
|
||||
autoUpdateEnabled: fields[7] as bool,
|
||||
lastUpdated: fields[8] as DateTime,
|
||||
customSettings: (fields[9] as Map?)?.cast<String, dynamic>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AppSettings obj) {
|
||||
writer
|
||||
..writeByte(10)
|
||||
..writeByte(0)
|
||||
..write(obj.version)
|
||||
..writeByte(1)
|
||||
..write(obj.themeMode)
|
||||
..writeByte(2)
|
||||
..write(obj.locale)
|
||||
..writeByte(3)
|
||||
..write(obj.notificationsEnabled)
|
||||
..writeByte(4)
|
||||
..write(obj.analyticsEnabled)
|
||||
..writeByte(5)
|
||||
..write(obj.cacheStrategy)
|
||||
..writeByte(6)
|
||||
..write(obj.cacheExpirationHours)
|
||||
..writeByte(7)
|
||||
..write(obj.autoUpdateEnabled)
|
||||
..writeByte(8)
|
||||
..write(obj.lastUpdated)
|
||||
..writeByte(9)
|
||||
..write(obj.customSettings);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AppSettingsAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
274
lib/core/database/models/cache_item.dart
Normal file
274
lib/core/database/models/cache_item.dart
Normal file
@@ -0,0 +1,274 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'cache_item.g.dart';
|
||||
|
||||
/// Generic cache wrapper for storing any data with expiration logic
|
||||
@HiveType(typeId: 1)
|
||||
class CacheItem extends HiveObject {
|
||||
@HiveField(0)
|
||||
@override
|
||||
final String key;
|
||||
|
||||
@HiveField(1)
|
||||
final dynamic data;
|
||||
|
||||
@HiveField(2)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(3)
|
||||
final DateTime expiresAt;
|
||||
|
||||
@HiveField(4)
|
||||
final String dataType; // Type identifier for runtime type safety
|
||||
|
||||
@HiveField(5)
|
||||
final Map<String, dynamic>? metadata; // Additional cache metadata
|
||||
|
||||
@HiveField(6)
|
||||
final int version; // Cache version for migration support
|
||||
|
||||
CacheItem({
|
||||
required this.key,
|
||||
required this.data,
|
||||
required this.createdAt,
|
||||
required this.expiresAt,
|
||||
required this.dataType,
|
||||
this.metadata,
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
/// Create a new cache item with expiration time
|
||||
factory CacheItem.create({
|
||||
required String key,
|
||||
required dynamic data,
|
||||
required Duration expirationDuration,
|
||||
Map<String, dynamic>? metadata,
|
||||
int version = 1,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
return CacheItem(
|
||||
key: key,
|
||||
data: data,
|
||||
createdAt: now,
|
||||
expiresAt: now.add(expirationDuration),
|
||||
dataType: data.runtimeType.toString(),
|
||||
metadata: metadata,
|
||||
version: version,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a cache item that never expires
|
||||
factory CacheItem.permanent({
|
||||
required String key,
|
||||
required dynamic data,
|
||||
Map<String, dynamic>? metadata,
|
||||
int version = 1,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
return CacheItem(
|
||||
key: key,
|
||||
data: data,
|
||||
createdAt: now,
|
||||
expiresAt: DateTime(9999), // Far future date
|
||||
dataType: data.runtimeType.toString(),
|
||||
metadata: metadata,
|
||||
version: version,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if the cache item is expired
|
||||
bool get isExpired {
|
||||
return DateTime.now().isAfter(expiresAt);
|
||||
}
|
||||
|
||||
/// Check if the cache item is still valid
|
||||
bool get isValid {
|
||||
return !isExpired;
|
||||
}
|
||||
|
||||
/// Get the age of the cache item
|
||||
Duration get age {
|
||||
return DateTime.now().difference(createdAt);
|
||||
}
|
||||
|
||||
/// Get time remaining until expiration
|
||||
Duration get timeUntilExpiry {
|
||||
final now = DateTime.now();
|
||||
if (now.isAfter(expiresAt)) {
|
||||
return Duration.zero;
|
||||
}
|
||||
return expiresAt.difference(now);
|
||||
}
|
||||
|
||||
/// Check if cache item will expire within the given duration
|
||||
bool willExpireWithin(Duration duration) {
|
||||
final checkTime = DateTime.now().add(duration);
|
||||
return expiresAt.isBefore(checkTime);
|
||||
}
|
||||
|
||||
/// Create a refreshed copy with new expiration time
|
||||
CacheItem refresh(Duration newExpirationDuration) {
|
||||
final now = DateTime.now();
|
||||
return CacheItem(
|
||||
key: key,
|
||||
data: data,
|
||||
createdAt: createdAt, // Keep original creation time
|
||||
expiresAt: now.add(newExpirationDuration),
|
||||
dataType: dataType,
|
||||
metadata: metadata,
|
||||
version: version,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy with updated data
|
||||
CacheItem updateData(dynamic newData, {Duration? newExpirationDuration}) {
|
||||
final now = DateTime.now();
|
||||
return CacheItem(
|
||||
key: key,
|
||||
data: newData,
|
||||
createdAt: now, // Update creation time for new data
|
||||
expiresAt: newExpirationDuration != null
|
||||
? now.add(newExpirationDuration)
|
||||
: expiresAt,
|
||||
dataType: dataType,
|
||||
metadata: metadata,
|
||||
version: version,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy with updated metadata
|
||||
CacheItem updateMetadata(Map<String, dynamic> newMetadata) {
|
||||
return CacheItem(
|
||||
key: key,
|
||||
data: data,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
dataType: dataType,
|
||||
metadata: {...(metadata ?? {}), ...newMetadata},
|
||||
version: version,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get metadata value
|
||||
V? getMetadata<V>(String key) {
|
||||
return metadata?[key] as V?;
|
||||
}
|
||||
|
||||
/// Convert to Map for JSON serialization
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'key': key,
|
||||
'data': data,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'expiresAt': expiresAt.toIso8601String(),
|
||||
'dataType': dataType,
|
||||
'metadata': metadata,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from Map (useful for migration or external data)
|
||||
static CacheItem fromMap(Map<String, dynamic> map) {
|
||||
return CacheItem(
|
||||
key: map['key'] as String,
|
||||
data: map['data'],
|
||||
createdAt: DateTime.parse(map['createdAt'] as String),
|
||||
expiresAt: DateTime.parse(map['expiresAt'] as String),
|
||||
dataType: map['dataType'] as String,
|
||||
metadata: map['metadata']?.cast<String, dynamic>(),
|
||||
version: map['version'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CacheItem{key: $key, dataType: $dataType, createdAt: $createdAt, '
|
||||
'expiresAt: $expiresAt, isExpired: $isExpired, version: $version}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is CacheItem &&
|
||||
other.key == key &&
|
||||
other.data == data &&
|
||||
other.createdAt == createdAt &&
|
||||
other.expiresAt == expiresAt &&
|
||||
other.dataType == dataType &&
|
||||
_mapEquals(other.metadata, metadata) &&
|
||||
other.version == version;
|
||||
}
|
||||
|
||||
bool _mapEquals(Map<String, dynamic>? a, Map<String, dynamic>? b) {
|
||||
if (a == null) return b == null;
|
||||
if (b == null) return false;
|
||||
if (a.length != b.length) return false;
|
||||
for (final key in a.keys) {
|
||||
if (!b.containsKey(key) || a[key] != b[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
key,
|
||||
data,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
dataType,
|
||||
metadata?.hashCode ?? 0,
|
||||
version,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache statistics for monitoring cache performance
|
||||
class CacheStats {
|
||||
final int totalItems;
|
||||
final int validItems;
|
||||
final int expiredItems;
|
||||
final DateTime oldestItem;
|
||||
final DateTime newestItem;
|
||||
final Map<String, int> typeCount;
|
||||
|
||||
const CacheStats({
|
||||
required this.totalItems,
|
||||
required this.validItems,
|
||||
required this.expiredItems,
|
||||
required this.oldestItem,
|
||||
required this.newestItem,
|
||||
required this.typeCount,
|
||||
});
|
||||
|
||||
double get hitRate {
|
||||
if (totalItems == 0) return 0.0;
|
||||
return validItems / totalItems;
|
||||
}
|
||||
|
||||
double get expirationRate {
|
||||
if (totalItems == 0) return 0.0;
|
||||
return expiredItems / totalItems;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'totalItems': totalItems,
|
||||
'validItems': validItems,
|
||||
'expiredItems': expiredItems,
|
||||
'oldestItem': oldestItem.toIso8601String(),
|
||||
'newestItem': newestItem.toIso8601String(),
|
||||
'typeCount': typeCount,
|
||||
'hitRate': hitRate,
|
||||
'expirationRate': expirationRate,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CacheStats{total: $totalItems, valid: $validItems, expired: $expiredItems, '
|
||||
'hitRate: ${(hitRate * 100).toStringAsFixed(1)}%, '
|
||||
'types: ${typeCount.keys.join(', ')}}';
|
||||
}
|
||||
}
|
||||
59
lib/core/database/models/cache_item.g.dart
Normal file
59
lib/core/database/models/cache_item.g.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'cache_item.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class CacheItemAdapter extends TypeAdapter<CacheItem> {
|
||||
@override
|
||||
final int typeId = 1;
|
||||
|
||||
@override
|
||||
CacheItem read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return CacheItem(
|
||||
key: fields[0] as String,
|
||||
data: fields[1] as dynamic,
|
||||
createdAt: fields[2] as DateTime,
|
||||
expiresAt: fields[3] as DateTime,
|
||||
dataType: fields[4] as String,
|
||||
metadata: (fields[5] as Map?)?.cast<String, dynamic>(),
|
||||
version: fields[6] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, CacheItem obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(0)
|
||||
..write(obj.key)
|
||||
..writeByte(1)
|
||||
..write(obj.data)
|
||||
..writeByte(2)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(3)
|
||||
..write(obj.expiresAt)
|
||||
..writeByte(4)
|
||||
..write(obj.dataType)
|
||||
..writeByte(5)
|
||||
..write(obj.metadata)
|
||||
..writeByte(6)
|
||||
..write(obj.version);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CacheItemAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
379
lib/core/database/models/user_preferences.dart
Normal file
379
lib/core/database/models/user_preferences.dart
Normal file
@@ -0,0 +1,379 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'user_preferences.g.dart';
|
||||
|
||||
/// User preferences model for storing user-specific settings and data
|
||||
@HiveType(typeId: 2)
|
||||
class UserPreferences extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String userId;
|
||||
|
||||
@HiveField(1)
|
||||
final String displayName;
|
||||
|
||||
@HiveField(2)
|
||||
final String? email;
|
||||
|
||||
@HiveField(3)
|
||||
final String? avatarUrl;
|
||||
|
||||
@HiveField(4)
|
||||
final Map<String, dynamic> preferences;
|
||||
|
||||
@HiveField(5)
|
||||
final List<String> favoriteItems;
|
||||
|
||||
@HiveField(6)
|
||||
final Map<String, DateTime> lastAccessed;
|
||||
|
||||
@HiveField(7)
|
||||
final DateTime createdAt;
|
||||
|
||||
@HiveField(8)
|
||||
final DateTime lastUpdated;
|
||||
|
||||
@HiveField(9)
|
||||
final int version;
|
||||
|
||||
UserPreferences({
|
||||
required this.userId,
|
||||
required this.displayName,
|
||||
this.email,
|
||||
this.avatarUrl,
|
||||
this.preferences = const {},
|
||||
this.favoriteItems = const [],
|
||||
this.lastAccessed = const {},
|
||||
required this.createdAt,
|
||||
required this.lastUpdated,
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
/// Create a new user preferences instance
|
||||
factory UserPreferences.create({
|
||||
required String userId,
|
||||
required String displayName,
|
||||
String? email,
|
||||
String? avatarUrl,
|
||||
Map<String, dynamic>? preferences,
|
||||
List<String>? favoriteItems,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
return UserPreferences(
|
||||
userId: userId,
|
||||
displayName: displayName,
|
||||
email: email,
|
||||
avatarUrl: avatarUrl,
|
||||
preferences: preferences ?? {},
|
||||
favoriteItems: favoriteItems ?? [],
|
||||
lastAccessed: {},
|
||||
createdAt: now,
|
||||
lastUpdated: now,
|
||||
version: 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create default user preferences for anonymous user
|
||||
factory UserPreferences.anonymous() {
|
||||
final now = DateTime.now();
|
||||
return UserPreferences(
|
||||
userId: 'anonymous',
|
||||
displayName: 'Anonymous User',
|
||||
preferences: _getDefaultPreferences(),
|
||||
favoriteItems: [],
|
||||
lastAccessed: {},
|
||||
createdAt: now,
|
||||
lastUpdated: now,
|
||||
version: 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get default preferences
|
||||
static Map<String, dynamic> _getDefaultPreferences() {
|
||||
return {
|
||||
// UI Preferences
|
||||
'compactMode': false,
|
||||
'showThumbnails': true,
|
||||
'gridColumns': 2,
|
||||
'sortBy': 'name',
|
||||
'sortOrder': 'asc', // 'asc' or 'desc'
|
||||
|
||||
// Notification Preferences
|
||||
'pushNotifications': true,
|
||||
'emailNotifications': false,
|
||||
'notificationTypes': ['updates', 'recommendations'],
|
||||
|
||||
// Privacy Preferences
|
||||
'analyticsOptIn': false,
|
||||
'shareUsageData': false,
|
||||
'personalizedRecommendations': true,
|
||||
|
||||
// Content Preferences
|
||||
'autoPlay': false,
|
||||
'highQualityImages': true,
|
||||
'downloadQuality': 'medium', // 'low', 'medium', 'high'
|
||||
'cacheSize': 500, // MB
|
||||
|
||||
// Accessibility
|
||||
'textSize': 'normal', // 'small', 'normal', 'large'
|
||||
'highContrast': false,
|
||||
'reduceAnimations': false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create a copy with modified fields
|
||||
UserPreferences copyWith({
|
||||
String? userId,
|
||||
String? displayName,
|
||||
String? email,
|
||||
String? avatarUrl,
|
||||
Map<String, dynamic>? preferences,
|
||||
List<String>? favoriteItems,
|
||||
Map<String, DateTime>? lastAccessed,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastUpdated,
|
||||
int? version,
|
||||
}) {
|
||||
return UserPreferences(
|
||||
userId: userId ?? this.userId,
|
||||
displayName: displayName ?? this.displayName,
|
||||
email: email ?? this.email,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
preferences: preferences ?? this.preferences,
|
||||
favoriteItems: favoriteItems ?? this.favoriteItems,
|
||||
lastAccessed: lastAccessed ?? this.lastAccessed,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastUpdated: lastUpdated ?? DateTime.now(),
|
||||
version: version ?? this.version,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get a preference value with type safety
|
||||
T getPreference<T>(String key, T defaultValue) {
|
||||
final value = preferences[key];
|
||||
if (value is T) {
|
||||
return value;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/// Set a preference value
|
||||
UserPreferences setPreference(String key, dynamic value) {
|
||||
final updatedPreferences = Map<String, dynamic>.from(preferences);
|
||||
updatedPreferences[key] = value;
|
||||
|
||||
return copyWith(
|
||||
preferences: updatedPreferences,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove a preference
|
||||
UserPreferences removePreference(String key) {
|
||||
final updatedPreferences = Map<String, dynamic>.from(preferences);
|
||||
updatedPreferences.remove(key);
|
||||
|
||||
return copyWith(
|
||||
preferences: updatedPreferences,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Add item to favorites
|
||||
UserPreferences addFavorite(String itemId) {
|
||||
if (favoriteItems.contains(itemId)) return this;
|
||||
|
||||
final updatedFavorites = List<String>.from(favoriteItems);
|
||||
updatedFavorites.add(itemId);
|
||||
|
||||
return copyWith(
|
||||
favoriteItems: updatedFavorites,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove item from favorites
|
||||
UserPreferences removeFavorite(String itemId) {
|
||||
if (!favoriteItems.contains(itemId)) return this;
|
||||
|
||||
final updatedFavorites = List<String>.from(favoriteItems);
|
||||
updatedFavorites.remove(itemId);
|
||||
|
||||
return copyWith(
|
||||
favoriteItems: updatedFavorites,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if item is favorite
|
||||
bool isFavorite(String itemId) {
|
||||
return favoriteItems.contains(itemId);
|
||||
}
|
||||
|
||||
/// Update last accessed time for an item
|
||||
UserPreferences updateLastAccessed(String itemId) {
|
||||
final updatedLastAccessed = Map<String, DateTime>.from(lastAccessed);
|
||||
updatedLastAccessed[itemId] = DateTime.now();
|
||||
|
||||
return copyWith(
|
||||
lastAccessed: updatedLastAccessed,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get last accessed time for an item
|
||||
DateTime? getLastAccessed(String itemId) {
|
||||
return lastAccessed[itemId];
|
||||
}
|
||||
|
||||
/// Get recently accessed items (sorted by most recent)
|
||||
List<String> getRecentlyAccessed({int limit = 10}) {
|
||||
final entries = lastAccessed.entries.toList();
|
||||
entries.sort((a, b) => b.value.compareTo(a.value));
|
||||
return entries.take(limit).map((e) => e.key).toList();
|
||||
}
|
||||
|
||||
/// Clean old last accessed entries (older than specified days)
|
||||
UserPreferences cleanOldAccess({int maxAgeDays = 30}) {
|
||||
final cutoffDate = DateTime.now().subtract(Duration(days: maxAgeDays));
|
||||
final updatedLastAccessed = Map<String, DateTime>.from(lastAccessed);
|
||||
|
||||
updatedLastAccessed.removeWhere((key, value) => value.isBefore(cutoffDate));
|
||||
|
||||
return copyWith(
|
||||
lastAccessed: updatedLastAccessed,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get user statistics
|
||||
Map<String, dynamic> getStats() {
|
||||
return {
|
||||
'userId': userId,
|
||||
'displayName': displayName,
|
||||
'totalFavorites': favoriteItems.length,
|
||||
'totalAccessedItems': lastAccessed.length,
|
||||
'recentlyAccessed': getRecentlyAccessed(limit: 5),
|
||||
'accountAge': DateTime.now().difference(createdAt).inDays,
|
||||
'lastActive': lastUpdated,
|
||||
'preferences': preferences.keys.length,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert to Map for JSON serialization
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'userId': userId,
|
||||
'displayName': displayName,
|
||||
'email': email,
|
||||
'avatarUrl': avatarUrl,
|
||||
'preferences': preferences,
|
||||
'favoriteItems': favoriteItems,
|
||||
'lastAccessed': lastAccessed.map((k, v) => MapEntry(k, v.toIso8601String())),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'lastUpdated': lastUpdated.toIso8601String(),
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
/// Create from Map for JSON deserialization
|
||||
factory UserPreferences.fromMap(Map<String, dynamic> map) {
|
||||
return UserPreferences(
|
||||
userId: map['userId'] as String,
|
||||
displayName: map['displayName'] as String,
|
||||
email: map['email'] as String?,
|
||||
avatarUrl: map['avatarUrl'] as String?,
|
||||
preferences: Map<String, dynamic>.from(map['preferences'] ?? {}),
|
||||
favoriteItems: List<String>.from(map['favoriteItems'] ?? []),
|
||||
lastAccessed: (map['lastAccessed'] as Map<String, dynamic>?)
|
||||
?.map((k, v) => MapEntry(k, DateTime.parse(v as String))) ?? {},
|
||||
createdAt: DateTime.parse(map['createdAt'] as String),
|
||||
lastUpdated: DateTime.parse(map['lastUpdated'] as String),
|
||||
version: map['version'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if preferences need migration
|
||||
bool needsMigration() {
|
||||
const currentVersion = 1;
|
||||
return version < currentVersion;
|
||||
}
|
||||
|
||||
/// Migrate preferences to current version
|
||||
UserPreferences migrate() {
|
||||
if (!needsMigration()) return this;
|
||||
|
||||
var migrated = this;
|
||||
|
||||
// Add migration logic here as needed
|
||||
// Example:
|
||||
// if (version < 2) {
|
||||
// migrated = _migrateToVersion2(migrated);
|
||||
// }
|
||||
|
||||
return migrated.copyWith(version: 1);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UserPreferences{userId: $userId, displayName: $displayName, '
|
||||
'favorites: ${favoriteItems.length}, preferences: ${preferences.keys.length}, '
|
||||
'lastUpdated: $lastUpdated}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is UserPreferences &&
|
||||
other.userId == userId &&
|
||||
other.displayName == displayName &&
|
||||
other.email == email &&
|
||||
other.avatarUrl == avatarUrl &&
|
||||
_mapEquals(other.preferences, preferences) &&
|
||||
_listEquals(other.favoriteItems, favoriteItems) &&
|
||||
_dateMapEquals(other.lastAccessed, lastAccessed) &&
|
||||
other.createdAt == createdAt &&
|
||||
other.lastUpdated == lastUpdated &&
|
||||
other.version == version;
|
||||
}
|
||||
|
||||
bool _mapEquals(Map<String, dynamic> a, Map<String, dynamic> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final key in a.keys) {
|
||||
if (!b.containsKey(key) || a[key] != b[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _listEquals(List<String> a, List<String> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _dateMapEquals(Map<String, DateTime> a, Map<String, DateTime> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final key in a.keys) {
|
||||
if (!b.containsKey(key) || a[key] != b[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
userId,
|
||||
displayName,
|
||||
email,
|
||||
avatarUrl,
|
||||
preferences.hashCode,
|
||||
favoriteItems.hashCode,
|
||||
lastAccessed.hashCode,
|
||||
createdAt,
|
||||
lastUpdated,
|
||||
version,
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/core/database/models/user_preferences.g.dart
Normal file
68
lib/core/database/models/user_preferences.g.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user_preferences.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class UserPreferencesAdapter extends TypeAdapter<UserPreferences> {
|
||||
@override
|
||||
final int typeId = 2;
|
||||
|
||||
@override
|
||||
UserPreferences read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return UserPreferences(
|
||||
userId: fields[0] as String,
|
||||
displayName: fields[1] as String,
|
||||
email: fields[2] as String?,
|
||||
avatarUrl: fields[3] as String?,
|
||||
preferences: (fields[4] as Map).cast<String, dynamic>(),
|
||||
favoriteItems: (fields[5] as List).cast<String>(),
|
||||
lastAccessed: (fields[6] as Map).cast<String, DateTime>(),
|
||||
createdAt: fields[7] as DateTime,
|
||||
lastUpdated: fields[8] as DateTime,
|
||||
version: fields[9] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, UserPreferences obj) {
|
||||
writer
|
||||
..writeByte(10)
|
||||
..writeByte(0)
|
||||
..write(obj.userId)
|
||||
..writeByte(1)
|
||||
..write(obj.displayName)
|
||||
..writeByte(2)
|
||||
..write(obj.email)
|
||||
..writeByte(3)
|
||||
..write(obj.avatarUrl)
|
||||
..writeByte(4)
|
||||
..write(obj.preferences)
|
||||
..writeByte(5)
|
||||
..write(obj.favoriteItems)
|
||||
..writeByte(6)
|
||||
..write(obj.lastAccessed)
|
||||
..writeByte(7)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(8)
|
||||
..write(obj.lastUpdated)
|
||||
..writeByte(9)
|
||||
..write(obj.version);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is UserPreferencesAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
438
lib/core/database/providers/database_providers.dart
Normal file
438
lib/core/database/providers/database_providers.dart
Normal file
@@ -0,0 +1,438 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../repositories/settings_repository.dart';
|
||||
import '../repositories/cache_repository.dart';
|
||||
import '../repositories/user_preferences_repository.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/cache_item.dart';
|
||||
import '../models/user_preferences.dart';
|
||||
|
||||
/// Providers for database repositories and services
|
||||
|
||||
/// Settings repository provider
|
||||
final settingsRepositoryProvider = Provider<SettingsRepository>((ref) {
|
||||
return SettingsRepository();
|
||||
});
|
||||
|
||||
/// Cache repository provider
|
||||
final cacheRepositoryProvider = Provider<CacheRepository>((ref) {
|
||||
return CacheRepository();
|
||||
});
|
||||
|
||||
/// User preferences repository provider
|
||||
final userPreferencesRepositoryProvider = Provider<UserPreferencesRepository>((ref) {
|
||||
return UserPreferencesRepository();
|
||||
});
|
||||
|
||||
/// Current app settings provider
|
||||
final appSettingsProvider = StateNotifierProvider<AppSettingsNotifier, AppSettings>((ref) {
|
||||
final repository = ref.watch(settingsRepositoryProvider);
|
||||
return AppSettingsNotifier(repository);
|
||||
});
|
||||
|
||||
/// App settings notifier
|
||||
class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
final SettingsRepository _repository;
|
||||
|
||||
AppSettingsNotifier(this._repository) : super(AppSettings.defaultSettings()) {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
void _loadSettings() {
|
||||
try {
|
||||
state = _repository.getSettings();
|
||||
} catch (e) {
|
||||
// Keep default settings if loading fails
|
||||
state = AppSettings.defaultSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// Update theme mode
|
||||
Future<void> updateThemeMode(String themeMode) async {
|
||||
try {
|
||||
await _repository.updateThemeMode(themeMode);
|
||||
state = state.copyWith(themeMode: themeMode);
|
||||
} catch (e) {
|
||||
// Handle error - maybe show a snackbar
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update locale
|
||||
Future<void> updateLocale(String locale) async {
|
||||
try {
|
||||
await _repository.updateLocale(locale);
|
||||
state = state.copyWith(locale: locale);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update notifications enabled
|
||||
Future<void> updateNotificationsEnabled(bool enabled) async {
|
||||
try {
|
||||
await _repository.updateNotificationsEnabled(enabled);
|
||||
state = state.copyWith(notificationsEnabled: enabled);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update analytics enabled
|
||||
Future<void> updateAnalyticsEnabled(bool enabled) async {
|
||||
try {
|
||||
await _repository.updateAnalyticsEnabled(enabled);
|
||||
state = state.copyWith(analyticsEnabled: enabled);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update cache strategy
|
||||
Future<void> updateCacheStrategy(String strategy) async {
|
||||
try {
|
||||
await _repository.updateCacheStrategy(strategy);
|
||||
state = state.copyWith(cacheStrategy: strategy);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update cache expiration hours
|
||||
Future<void> updateCacheExpirationHours(int hours) async {
|
||||
try {
|
||||
await _repository.updateCacheExpirationHours(hours);
|
||||
state = state.copyWith(cacheExpirationHours: hours);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update auto update enabled
|
||||
Future<void> updateAutoUpdateEnabled(bool enabled) async {
|
||||
try {
|
||||
await _repository.updateAutoUpdateEnabled(enabled);
|
||||
state = state.copyWith(autoUpdateEnabled: enabled);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set custom setting
|
||||
Future<void> setCustomSetting(String key, dynamic value) async {
|
||||
try {
|
||||
await _repository.setCustomSetting(key, value);
|
||||
state = state.setCustomSetting(key, value);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove custom setting
|
||||
Future<void> removeCustomSetting(String key) async {
|
||||
try {
|
||||
await _repository.removeCustomSetting(key);
|
||||
state = state.removeCustomSetting(key);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to default settings
|
||||
Future<void> resetToDefault() async {
|
||||
try {
|
||||
await _repository.resetToDefault();
|
||||
state = AppSettings.defaultSettings();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme mode provider (derived from app settings)
|
||||
final themeModeProvider = Provider<ThemeMode>((ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
switch (settings.themeMode) {
|
||||
case 'light':
|
||||
return ThemeMode.light;
|
||||
case 'dark':
|
||||
return ThemeMode.dark;
|
||||
case 'system':
|
||||
default:
|
||||
return ThemeMode.system;
|
||||
}
|
||||
});
|
||||
|
||||
/// Current locale provider (derived from app settings)
|
||||
final localeProvider = Provider<String>((ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
return settings.locale;
|
||||
});
|
||||
|
||||
/// Notifications enabled provider
|
||||
final notificationsEnabledProvider = Provider<bool>((ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
return settings.notificationsEnabled;
|
||||
});
|
||||
|
||||
/// Analytics enabled provider
|
||||
final analyticsEnabledProvider = Provider<bool>((ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
return settings.analyticsEnabled;
|
||||
});
|
||||
|
||||
/// Cache strategy provider
|
||||
final cacheStrategyProvider = Provider<String>((ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
return settings.cacheStrategy;
|
||||
});
|
||||
|
||||
/// Cache expiration hours provider
|
||||
final cacheExpirationHoursProvider = Provider<int>((ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
return settings.cacheExpirationHours;
|
||||
});
|
||||
|
||||
/// Auto update enabled provider
|
||||
final autoUpdateEnabledProvider = Provider<bool>((ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
return settings.autoUpdateEnabled;
|
||||
});
|
||||
|
||||
/// Cache statistics provider
|
||||
final cacheStatsProvider = FutureProvider<CacheStats>((ref) async {
|
||||
final repository = ref.watch(cacheRepositoryProvider);
|
||||
return repository.getStats();
|
||||
});
|
||||
|
||||
/// Cache maintenance provider
|
||||
final cacheMaintenanceProvider = FutureProvider.family<Map<String, dynamic>, bool>(
|
||||
(ref, performMaintenance) async {
|
||||
if (!performMaintenance) return {};
|
||||
|
||||
final repository = ref.watch(cacheRepositoryProvider);
|
||||
return repository.performMaintenance();
|
||||
},
|
||||
);
|
||||
|
||||
/// Cache item provider for a specific key
|
||||
final cacheItemProvider = Provider.family<CacheItem?, String>((ref, key) {
|
||||
final repository = ref.watch(cacheRepositoryProvider);
|
||||
return repository.getCacheItem(key);
|
||||
});
|
||||
|
||||
/// Cache data provider for a specific key with type safety
|
||||
final cacheDataProvider = Provider.family<dynamic, String>((ref, key) {
|
||||
final repository = ref.watch(cacheRepositoryProvider);
|
||||
return repository.get(key);
|
||||
});
|
||||
|
||||
/// Database statistics provider
|
||||
final databaseStatsProvider = Provider<Map<String, dynamic>>((ref) {
|
||||
final settingsRepo = ref.watch(settingsRepositoryProvider);
|
||||
final cacheRepo = ref.watch(cacheRepositoryProvider);
|
||||
|
||||
final settingsStats = settingsRepo.getSettingsStats();
|
||||
final cacheStats = cacheRepo.getStats();
|
||||
|
||||
return {
|
||||
'settings': settingsStats,
|
||||
'cache': cacheStats.toMap(),
|
||||
'totalItems': settingsStats['totalSettingsInBox'] + cacheStats.totalItems,
|
||||
};
|
||||
});
|
||||
|
||||
/// Provider to clear expired cache items
|
||||
final clearExpiredCacheProvider = FutureProvider<int>((ref) async {
|
||||
final repository = ref.watch(cacheRepositoryProvider);
|
||||
return repository.clearExpired();
|
||||
});
|
||||
|
||||
/// Provider to get cache keys by pattern
|
||||
final cacheKeysByPatternProvider = Provider.family<List<String>, String>((ref, pattern) {
|
||||
final repository = ref.watch(cacheRepositoryProvider);
|
||||
return repository.getKeysByPattern(pattern);
|
||||
});
|
||||
|
||||
/// Provider to get cache keys by type
|
||||
final cacheKeysByTypeProvider = Provider.family<List<String>, String>((ref, type) {
|
||||
final repository = ref.watch(cacheRepositoryProvider);
|
||||
return repository.getKeysByType(type);
|
||||
});
|
||||
|
||||
/// Current user preferences provider
|
||||
final userPreferencesProvider = StateNotifierProvider.family<UserPreferencesNotifier, UserPreferences?, String?>((ref, userId) {
|
||||
final repository = ref.watch(userPreferencesRepositoryProvider);
|
||||
return UserPreferencesNotifier(repository, userId);
|
||||
});
|
||||
|
||||
/// User preferences notifier
|
||||
class UserPreferencesNotifier extends StateNotifier<UserPreferences?> {
|
||||
final UserPreferencesRepository _repository;
|
||||
final String? _userId;
|
||||
|
||||
UserPreferencesNotifier(this._repository, this._userId) : super(null) {
|
||||
_loadUserPreferences();
|
||||
}
|
||||
|
||||
void _loadUserPreferences() {
|
||||
try {
|
||||
state = _repository.getUserPreferences(_userId);
|
||||
} catch (e) {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new user preferences
|
||||
Future<void> createUserPreferences({
|
||||
required String userId,
|
||||
required String displayName,
|
||||
String? email,
|
||||
String? avatarUrl,
|
||||
Map<String, dynamic>? preferences,
|
||||
List<String>? favoriteItems,
|
||||
}) async {
|
||||
try {
|
||||
final newPreferences = await _repository.createUserPreferences(
|
||||
userId: userId,
|
||||
displayName: displayName,
|
||||
email: email,
|
||||
avatarUrl: avatarUrl,
|
||||
preferences: preferences,
|
||||
favoriteItems: favoriteItems,
|
||||
);
|
||||
state = newPreferences;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update profile information
|
||||
Future<void> updateProfile({
|
||||
String? displayName,
|
||||
String? email,
|
||||
String? avatarUrl,
|
||||
}) async {
|
||||
if (state == null) return;
|
||||
|
||||
try {
|
||||
await _repository.updateProfile(
|
||||
userId: _userId,
|
||||
displayName: displayName,
|
||||
email: email,
|
||||
avatarUrl: avatarUrl,
|
||||
);
|
||||
|
||||
state = state!.copyWith(
|
||||
displayName: displayName ?? state!.displayName,
|
||||
email: email ?? state!.email,
|
||||
avatarUrl: avatarUrl ?? state!.avatarUrl,
|
||||
);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set preference
|
||||
Future<void> setPreference(String key, dynamic value) async {
|
||||
if (state == null) return;
|
||||
|
||||
try {
|
||||
await _repository.setPreference(key, value, _userId);
|
||||
state = state!.setPreference(key, value);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove preference
|
||||
Future<void> removePreference(String key) async {
|
||||
if (state == null) return;
|
||||
|
||||
try {
|
||||
await _repository.removePreference(key, _userId);
|
||||
state = state!.removePreference(key);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Add favorite
|
||||
Future<void> addFavorite(String itemId) async {
|
||||
if (state == null) return;
|
||||
|
||||
try {
|
||||
await _repository.addFavorite(itemId, _userId);
|
||||
state = state!.addFavorite(itemId);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove favorite
|
||||
Future<void> removeFavorite(String itemId) async {
|
||||
if (state == null) return;
|
||||
|
||||
try {
|
||||
await _repository.removeFavorite(itemId, _userId);
|
||||
state = state!.removeFavorite(itemId);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update last accessed
|
||||
Future<void> updateLastAccessed(String itemId) async {
|
||||
if (state == null) return;
|
||||
|
||||
try {
|
||||
await _repository.updateLastAccessed(itemId, _userId);
|
||||
state = state!.updateLastAccessed(itemId);
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear user preferences
|
||||
Future<void> clearPreferences() async {
|
||||
try {
|
||||
await _repository.clearUserPreferences(_userId);
|
||||
state = null;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User preference provider for specific key
|
||||
final userPreferenceProvider = Provider.family.autoDispose<dynamic, (String, dynamic, String?)>((ref, params) {
|
||||
final (key, defaultValue, userId) = params;
|
||||
final repository = ref.watch(userPreferencesRepositoryProvider);
|
||||
return repository.getPreference(key, defaultValue, userId);
|
||||
});
|
||||
|
||||
/// User favorites provider
|
||||
final userFavoritesProvider = Provider.family<List<String>, String?>((ref, userId) {
|
||||
final repository = ref.watch(userPreferencesRepositoryProvider);
|
||||
return repository.getFavorites(userId);
|
||||
});
|
||||
|
||||
/// Recently accessed provider
|
||||
final recentlyAccessedProvider = Provider.family<List<String>, (int, String?)>((ref, params) {
|
||||
final (limit, userId) = params;
|
||||
final repository = ref.watch(userPreferencesRepositoryProvider);
|
||||
return repository.getRecentlyAccessed(limit: limit, userId: userId);
|
||||
});
|
||||
|
||||
/// Is favorite provider
|
||||
final isFavoriteProvider = Provider.family<bool, (String, String?)>((ref, params) {
|
||||
final (itemId, userId) = params;
|
||||
final repository = ref.watch(userPreferencesRepositoryProvider);
|
||||
return repository.isFavorite(itemId, userId);
|
||||
});
|
||||
|
||||
/// User stats provider
|
||||
final userStatsProvider = Provider.family<Map<String, dynamic>, String?>((ref, userId) {
|
||||
final repository = ref.watch(userPreferencesRepositoryProvider);
|
||||
return repository.getUserStats(userId);
|
||||
});
|
||||
480
lib/core/database/repositories/cache_repository.dart
Normal file
480
lib/core/database/repositories/cache_repository.dart
Normal file
@@ -0,0 +1,480 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../hive_service.dart';
|
||||
import '../models/cache_item.dart';
|
||||
|
||||
/// Repository for managing cached data using Hive
|
||||
class CacheRepository {
|
||||
/// Store data in cache with expiration
|
||||
Future<void> put<T>({
|
||||
required String key,
|
||||
required T data,
|
||||
required Duration expirationDuration,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final cacheItem = CacheItem.create(
|
||||
key: key,
|
||||
data: data,
|
||||
expirationDuration: expirationDuration,
|
||||
metadata: metadata,
|
||||
);
|
||||
await box.put(key, cacheItem);
|
||||
debugPrint('✅ Cache item stored: $key');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('❌ Error storing cache item $key: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Store permanent data in cache (never expires)
|
||||
Future<void> putPermanent<T>({
|
||||
required String key,
|
||||
required T data,
|
||||
Map<String, dynamic>? metadata,
|
||||
}) async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final cacheItem = CacheItem.permanent(
|
||||
key: key,
|
||||
data: data,
|
||||
metadata: metadata,
|
||||
);
|
||||
await box.put(key, cacheItem);
|
||||
debugPrint('✅ Permanent cache item stored: $key');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('❌ Error storing permanent cache item $key: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get data from cache
|
||||
T? get<T>(String key) {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final cacheItem = box.get(key);
|
||||
|
||||
if (cacheItem == null) {
|
||||
debugPrint('📭 Cache miss: $key');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cacheItem.isExpired) {
|
||||
debugPrint('⏰ Cache expired: $key');
|
||||
// Optionally remove expired item
|
||||
delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
debugPrint('✅ Cache hit: $key');
|
||||
return cacheItem.data as T?;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting cache item $key: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache item with full metadata
|
||||
CacheItem? getCacheItem(String key) {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final cacheItem = box.get(key);
|
||||
|
||||
if (cacheItem == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cacheItem.isExpired) {
|
||||
// Optionally remove expired item
|
||||
delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cacheItem;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting cache item $key: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if key exists and is valid (not expired)
|
||||
bool contains(String key) {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final cacheItem = box.get(key);
|
||||
|
||||
if (cacheItem == null) return false;
|
||||
if (cacheItem.isExpired) {
|
||||
delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error checking cache item $key: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if key exists regardless of expiration
|
||||
bool containsKey(String key) {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
return box.containsKey(key);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error checking key $key: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete specific cache item
|
||||
Future<void> delete(String key) async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
await box.delete(key);
|
||||
debugPrint('🗑️ Cache item deleted: $key');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error deleting cache item $key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete multiple cache items
|
||||
Future<void> deleteMultiple(List<String> keys) async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
for (final key in keys) {
|
||||
await box.delete(key);
|
||||
}
|
||||
debugPrint('🗑️ Multiple cache items deleted: ${keys.length} items');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error deleting multiple cache items: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all expired items
|
||||
Future<int> cleanExpiredItems() async {
|
||||
return await clearExpired();
|
||||
}
|
||||
|
||||
/// Clear all expired items
|
||||
Future<int> clearExpired() async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final expiredKeys = <String>[];
|
||||
final now = DateTime.now();
|
||||
|
||||
for (final key in box.keys) {
|
||||
final cacheItem = box.get(key);
|
||||
if (cacheItem != null && now.isAfter(cacheItem.expiresAt)) {
|
||||
expiredKeys.add(key as String);
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in expiredKeys) {
|
||||
await box.delete(key);
|
||||
}
|
||||
|
||||
debugPrint('🧹 Cleared ${expiredKeys.length} expired cache items');
|
||||
return expiredKeys.length;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error clearing expired items: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all cache items
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final count = box.length;
|
||||
await box.clear();
|
||||
debugPrint('🧹 Cleared all cache items: $count items');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error clearing all cache items: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cache items by pattern
|
||||
Future<int> clearByPattern(Pattern pattern) async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final keysToDelete = <String>[];
|
||||
|
||||
for (final key in box.keys) {
|
||||
if (key is String && key.contains(pattern)) {
|
||||
keysToDelete.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in keysToDelete) {
|
||||
await box.delete(key);
|
||||
}
|
||||
|
||||
debugPrint('🧹 Cleared ${keysToDelete.length} cache items matching pattern: $pattern');
|
||||
return keysToDelete.length;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error clearing cache items by pattern: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cache items by type
|
||||
Future<int> clearByType(String dataType) async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final keysToDelete = <String>[];
|
||||
|
||||
for (final key in box.keys) {
|
||||
final cacheItem = box.get(key);
|
||||
if (cacheItem != null && cacheItem.dataType == dataType) {
|
||||
keysToDelete.add(key as String);
|
||||
}
|
||||
}
|
||||
|
||||
for (final key in keysToDelete) {
|
||||
await box.delete(key);
|
||||
}
|
||||
|
||||
debugPrint('🧹 Cleared ${keysToDelete.length} cache items of type: $dataType');
|
||||
return keysToDelete.length;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error clearing cache items by type: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh cache item with new expiration
|
||||
Future<bool> refresh(String key, Duration newExpirationDuration) async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final cacheItem = box.get(key);
|
||||
|
||||
if (cacheItem == null) return false;
|
||||
|
||||
final refreshedItem = cacheItem.refresh(newExpirationDuration);
|
||||
await box.put(key, refreshedItem);
|
||||
|
||||
debugPrint('🔄 Cache item refreshed: $key');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error refreshing cache item $key: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update cache item data
|
||||
Future<bool> update<T>(String key, T newData, {Duration? newExpirationDuration}) async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final cacheItem = box.get(key);
|
||||
|
||||
if (cacheItem == null) return false;
|
||||
|
||||
final updatedItem = cacheItem.updateData(newData, newExpirationDuration: newExpirationDuration);
|
||||
await box.put(key, updatedItem);
|
||||
|
||||
debugPrint('📝 Cache item updated: $key');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error updating cache item $key: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all keys in cache
|
||||
List<String> getAllKeys() {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
return box.keys.cast<String>().toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting all keys: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get keys by pattern
|
||||
List<String> getKeysByPattern(Pattern pattern) {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
return box.keys
|
||||
.cast<String>()
|
||||
.where((key) => key.contains(pattern))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting keys by pattern: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get keys by data type
|
||||
List<String> getKeysByType(String dataType) {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final keys = <String>[];
|
||||
|
||||
for (final key in box.keys) {
|
||||
final cacheItem = box.get(key);
|
||||
if (cacheItem != null && cacheItem.dataType == dataType) {
|
||||
keys.add(key as String);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting keys by type: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
CacheStats getStats() {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final now = DateTime.now();
|
||||
var validItems = 0;
|
||||
var expiredItems = 0;
|
||||
DateTime? oldestItem;
|
||||
DateTime? newestItem;
|
||||
final typeCount = <String, int>{};
|
||||
|
||||
for (final key in box.keys) {
|
||||
final cacheItem = box.get(key);
|
||||
if (cacheItem == null) continue;
|
||||
|
||||
// Count by expiration status
|
||||
if (now.isAfter(cacheItem.expiresAt)) {
|
||||
expiredItems++;
|
||||
} else {
|
||||
validItems++;
|
||||
}
|
||||
|
||||
// Track oldest and newest items
|
||||
if (oldestItem == null || cacheItem.createdAt.isBefore(oldestItem)) {
|
||||
oldestItem = cacheItem.createdAt;
|
||||
}
|
||||
if (newestItem == null || cacheItem.createdAt.isAfter(newestItem)) {
|
||||
newestItem = cacheItem.createdAt;
|
||||
}
|
||||
|
||||
// Count by type
|
||||
typeCount[cacheItem.dataType] = (typeCount[cacheItem.dataType] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return CacheStats(
|
||||
totalItems: box.length,
|
||||
validItems: validItems,
|
||||
expiredItems: expiredItems,
|
||||
oldestItem: oldestItem ?? DateTime.now(),
|
||||
newestItem: newestItem ?? DateTime.now(),
|
||||
typeCount: typeCount,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting cache stats: $e');
|
||||
return CacheStats(
|
||||
totalItems: 0,
|
||||
validItems: 0,
|
||||
expiredItems: 0,
|
||||
oldestItem: DateTime.now(),
|
||||
newestItem: DateTime.now(),
|
||||
typeCount: const {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache size in bytes (approximate)
|
||||
int getApproximateSize() {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
// This is an approximation as Hive doesn't provide exact size
|
||||
return box.length * 1024; // Assume average 1KB per item
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting cache size: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact cache storage
|
||||
Future<void> compact() async {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
await box.compact();
|
||||
debugPrint('✅ Cache storage compacted');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error compacting cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Export cache data (for debugging or backup)
|
||||
Map<String, dynamic> exportCache({bool includeExpired = false}) {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
final now = DateTime.now();
|
||||
final exportData = <String, dynamic>{};
|
||||
|
||||
for (final key in box.keys) {
|
||||
final cacheItem = box.get(key);
|
||||
if (cacheItem == null) continue;
|
||||
|
||||
if (!includeExpired && now.isAfter(cacheItem.expiresAt)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
exportData[key as String] = cacheItem.toMap();
|
||||
}
|
||||
|
||||
return exportData;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error exporting cache: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch cache changes for a specific key
|
||||
Stream<CacheItem?> watch(String key) {
|
||||
try {
|
||||
final box = HiveService.cacheBox;
|
||||
return box.watch(key: key).map((event) => event.value as CacheItem?);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error watching cache key $key: $e');
|
||||
return Stream.value(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform cache maintenance (cleanup expired items, compact storage)
|
||||
Future<Map<String, dynamic>> performMaintenance() async {
|
||||
try {
|
||||
final startTime = DateTime.now();
|
||||
|
||||
// Get stats before maintenance
|
||||
final statsBefore = getStats();
|
||||
|
||||
// Clear expired items
|
||||
final expiredCount = await clearExpired();
|
||||
|
||||
// Compact storage
|
||||
await compact();
|
||||
|
||||
// Get stats after maintenance
|
||||
final statsAfter = getStats();
|
||||
|
||||
final maintenanceTime = DateTime.now().difference(startTime);
|
||||
|
||||
final result = {
|
||||
'expiredItemsRemoved': expiredCount,
|
||||
'itemsBefore': statsBefore.totalItems,
|
||||
'itemsAfter': statsAfter.totalItems,
|
||||
'maintenanceTimeMs': maintenanceTime.inMilliseconds,
|
||||
'completedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
debugPrint('🔧 Cache maintenance completed: $result');
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error during cache maintenance: $e');
|
||||
return {'error': e.toString()};
|
||||
}
|
||||
}
|
||||
}
|
||||
249
lib/core/database/repositories/settings_repository.dart
Normal file
249
lib/core/database/repositories/settings_repository.dart
Normal file
@@ -0,0 +1,249 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../hive_service.dart';
|
||||
import '../models/app_settings.dart';
|
||||
|
||||
/// Repository for managing application settings using Hive
|
||||
class SettingsRepository {
|
||||
static const String _defaultKey = 'app_settings';
|
||||
|
||||
/// Get the current app settings
|
||||
AppSettings getSettings() {
|
||||
try {
|
||||
final box = HiveService.appSettingsBox;
|
||||
final settings = box.get(_defaultKey);
|
||||
|
||||
if (settings == null) {
|
||||
// Return default settings if none exist
|
||||
final defaultSettings = AppSettings.defaultSettings();
|
||||
saveSettings(defaultSettings);
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
// Check if settings need migration
|
||||
if (settings.version < 1) {
|
||||
final migratedSettings = _migrateSettings(settings);
|
||||
saveSettings(migratedSettings);
|
||||
return migratedSettings;
|
||||
}
|
||||
|
||||
return settings;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('Error getting settings: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
|
||||
// Return default settings on error
|
||||
return AppSettings.defaultSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// Save app settings
|
||||
Future<void> saveSettings(AppSettings settings) async {
|
||||
try {
|
||||
final box = HiveService.appSettingsBox;
|
||||
final updatedSettings = settings.copyWith(lastUpdated: DateTime.now());
|
||||
await box.put(_defaultKey, updatedSettings);
|
||||
debugPrint('✅ Settings saved successfully');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('❌ Error saving settings: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update theme mode
|
||||
Future<void> updateThemeMode(String themeMode) async {
|
||||
final currentSettings = getSettings();
|
||||
final updatedSettings = currentSettings.copyWith(themeMode: themeMode);
|
||||
await saveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// Update locale
|
||||
Future<void> updateLocale(String locale) async {
|
||||
final currentSettings = getSettings();
|
||||
final updatedSettings = currentSettings.copyWith(locale: locale);
|
||||
await saveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// Update notifications enabled
|
||||
Future<void> updateNotificationsEnabled(bool enabled) async {
|
||||
final currentSettings = getSettings();
|
||||
final updatedSettings = currentSettings.copyWith(notificationsEnabled: enabled);
|
||||
await saveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// Update analytics enabled
|
||||
Future<void> updateAnalyticsEnabled(bool enabled) async {
|
||||
final currentSettings = getSettings();
|
||||
final updatedSettings = currentSettings.copyWith(analyticsEnabled: enabled);
|
||||
await saveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// Update cache strategy
|
||||
Future<void> updateCacheStrategy(String strategy) async {
|
||||
final currentSettings = getSettings();
|
||||
final updatedSettings = currentSettings.copyWith(cacheStrategy: strategy);
|
||||
await saveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// Update cache expiration hours
|
||||
Future<void> updateCacheExpirationHours(int hours) async {
|
||||
final currentSettings = getSettings();
|
||||
final updatedSettings = currentSettings.copyWith(cacheExpirationHours: hours);
|
||||
await saveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// Update auto update enabled
|
||||
Future<void> updateAutoUpdateEnabled(bool enabled) async {
|
||||
final currentSettings = getSettings();
|
||||
final updatedSettings = currentSettings.copyWith(autoUpdateEnabled: enabled);
|
||||
await saveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// Set custom setting
|
||||
Future<void> setCustomSetting(String key, dynamic value) async {
|
||||
final currentSettings = getSettings();
|
||||
final updatedSettings = currentSettings.setCustomSetting(key, value);
|
||||
await saveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// Get custom setting
|
||||
T? getCustomSetting<T>(String key) {
|
||||
final settings = getSettings();
|
||||
return settings.getCustomSetting<T>(key);
|
||||
}
|
||||
|
||||
/// Remove custom setting
|
||||
Future<void> removeCustomSetting(String key) async {
|
||||
final currentSettings = getSettings();
|
||||
final updatedSettings = currentSettings.removeCustomSetting(key);
|
||||
await saveSettings(updatedSettings);
|
||||
}
|
||||
|
||||
/// Reset to default settings
|
||||
Future<void> resetToDefault() async {
|
||||
final defaultSettings = AppSettings.defaultSettings();
|
||||
await saveSettings(defaultSettings);
|
||||
debugPrint('✅ Settings reset to default');
|
||||
}
|
||||
|
||||
/// Export settings to Map (for backup)
|
||||
Map<String, dynamic> exportSettings() {
|
||||
try {
|
||||
final settings = getSettings();
|
||||
return settings.toMap();
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error exporting settings: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Import settings from Map (for restore)
|
||||
Future<bool> importSettings(Map<String, dynamic> settingsMap) async {
|
||||
try {
|
||||
final settings = AppSettings.fromMap(settingsMap);
|
||||
await saveSettings(settings);
|
||||
debugPrint('✅ Settings imported successfully');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error importing settings: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if settings exist
|
||||
bool hasSettings() {
|
||||
try {
|
||||
final box = HiveService.appSettingsBox;
|
||||
return box.containsKey(_defaultKey);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error checking settings existence: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all settings (use with caution)
|
||||
Future<void> clearSettings() async {
|
||||
try {
|
||||
final box = HiveService.appSettingsBox;
|
||||
await box.delete(_defaultKey);
|
||||
debugPrint('✅ Settings cleared');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error clearing settings: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get settings statistics
|
||||
Map<String, dynamic> getSettingsStats() {
|
||||
try {
|
||||
final settings = getSettings();
|
||||
final box = HiveService.appSettingsBox;
|
||||
|
||||
return {
|
||||
'hasCustomSettings': settings.customSettings?.isNotEmpty ?? false,
|
||||
'customSettingsCount': settings.customSettings?.length ?? 0,
|
||||
'lastUpdated': settings.lastUpdated.toIso8601String(),
|
||||
'version': settings.version,
|
||||
'settingsAge': DateTime.now().difference(settings.lastUpdated).inDays,
|
||||
'isExpired': settings.isExpired(),
|
||||
'totalSettingsInBox': box.length,
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting settings stats: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate settings from older version
|
||||
AppSettings _migrateSettings(AppSettings oldSettings) {
|
||||
debugPrint('🔄 Migrating settings from version ${oldSettings.version} to 1');
|
||||
|
||||
// Perform any necessary migrations here
|
||||
// For now, just update the version and timestamp
|
||||
return oldSettings.copyWith(
|
||||
version: 1,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Validate settings
|
||||
bool validateSettings(AppSettings settings) {
|
||||
try {
|
||||
// Basic validation
|
||||
if (settings.version < 1) return false;
|
||||
if (settings.cacheExpirationHours < 1) return false;
|
||||
if (!['light', 'dark', 'system'].contains(settings.themeMode)) return false;
|
||||
if (!['aggressive', 'normal', 'minimal'].contains(settings.cacheStrategy)) return false;
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error validating settings: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch settings changes
|
||||
Stream<AppSettings> watchSettings() {
|
||||
try {
|
||||
final box = HiveService.appSettingsBox;
|
||||
return box.watch(key: _defaultKey).map((event) {
|
||||
final settings = event.value as AppSettings?;
|
||||
return settings ?? AppSettings.defaultSettings();
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error watching settings: $e');
|
||||
return Stream.value(AppSettings.defaultSettings());
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact settings storage
|
||||
Future<void> compact() async {
|
||||
try {
|
||||
final box = HiveService.appSettingsBox;
|
||||
await box.compact();
|
||||
debugPrint('✅ Settings storage compacted');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error compacting settings: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
329
lib/core/database/repositories/user_preferences_repository.dart
Normal file
329
lib/core/database/repositories/user_preferences_repository.dart
Normal file
@@ -0,0 +1,329 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../hive_service.dart';
|
||||
import '../models/user_preferences.dart';
|
||||
|
||||
/// Repository for managing user preferences using Hive
|
||||
class UserPreferencesRepository {
|
||||
static const String _defaultKey = 'current_user_preferences';
|
||||
|
||||
/// Get the current user preferences (alias for getUserPreferences)
|
||||
UserPreferences? getPreferences([String? userId]) {
|
||||
return getUserPreferences(userId);
|
||||
}
|
||||
|
||||
/// Get the current user preferences
|
||||
UserPreferences? getUserPreferences([String? userId]) {
|
||||
try {
|
||||
final box = HiveService.userDataBox;
|
||||
final key = userId ?? _defaultKey;
|
||||
|
||||
final preferences = box.get(key);
|
||||
if (preferences == null) return null;
|
||||
|
||||
// Check if preferences need migration
|
||||
if (preferences.needsMigration()) {
|
||||
final migratedPreferences = preferences.migrate();
|
||||
saveUserPreferences(migratedPreferences, userId);
|
||||
return migratedPreferences;
|
||||
}
|
||||
|
||||
return preferences;
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('Error getting user preferences: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save user preferences
|
||||
Future<void> saveUserPreferences(UserPreferences preferences, [String? userId]) async {
|
||||
try {
|
||||
final box = HiveService.userDataBox;
|
||||
final key = userId ?? _defaultKey;
|
||||
|
||||
final updatedPreferences = preferences.copyWith(lastUpdated: DateTime.now());
|
||||
await box.put(key, updatedPreferences);
|
||||
|
||||
debugPrint('✅ User preferences saved for key: $key');
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('❌ Error saving user preferences: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new user preferences
|
||||
Future<UserPreferences> createUserPreferences({
|
||||
required String userId,
|
||||
required String displayName,
|
||||
String? email,
|
||||
String? avatarUrl,
|
||||
Map<String, dynamic>? preferences,
|
||||
List<String>? favoriteItems,
|
||||
}) async {
|
||||
final userPreferences = UserPreferences.create(
|
||||
userId: userId,
|
||||
displayName: displayName,
|
||||
email: email,
|
||||
avatarUrl: avatarUrl,
|
||||
preferences: preferences,
|
||||
favoriteItems: favoriteItems,
|
||||
);
|
||||
|
||||
await saveUserPreferences(userPreferences, userId);
|
||||
return userPreferences;
|
||||
}
|
||||
|
||||
/// Update user profile information
|
||||
Future<void> updateProfile({
|
||||
String? userId,
|
||||
String? displayName,
|
||||
String? email,
|
||||
String? avatarUrl,
|
||||
}) async {
|
||||
final currentPreferences = getUserPreferences(userId);
|
||||
if (currentPreferences == null) return;
|
||||
|
||||
final updatedPreferences = currentPreferences.copyWith(
|
||||
displayName: displayName ?? currentPreferences.displayName,
|
||||
email: email ?? currentPreferences.email,
|
||||
avatarUrl: avatarUrl ?? currentPreferences.avatarUrl,
|
||||
);
|
||||
|
||||
await saveUserPreferences(updatedPreferences, userId);
|
||||
}
|
||||
|
||||
/// Set a user preference
|
||||
Future<void> setPreference(String key, dynamic value, [String? userId]) async {
|
||||
final currentPreferences = getUserPreferences(userId);
|
||||
if (currentPreferences == null) return;
|
||||
|
||||
final updatedPreferences = currentPreferences.setPreference(key, value);
|
||||
await saveUserPreferences(updatedPreferences, userId);
|
||||
}
|
||||
|
||||
/// Get a user preference with type safety
|
||||
T getPreference<T>(String key, T defaultValue, [String? userId]) {
|
||||
final preferences = getUserPreferences(userId);
|
||||
if (preferences == null) return defaultValue;
|
||||
|
||||
return preferences.getPreference<T>(key, defaultValue);
|
||||
}
|
||||
|
||||
/// Remove a user preference
|
||||
Future<void> removePreference(String key, [String? userId]) async {
|
||||
final currentPreferences = getUserPreferences(userId);
|
||||
if (currentPreferences == null) return;
|
||||
|
||||
final updatedPreferences = currentPreferences.removePreference(key);
|
||||
await saveUserPreferences(updatedPreferences, userId);
|
||||
}
|
||||
|
||||
/// Add item to favorites
|
||||
Future<void> addFavorite(String itemId, [String? userId]) async {
|
||||
final currentPreferences = getUserPreferences(userId);
|
||||
if (currentPreferences == null) return;
|
||||
|
||||
final updatedPreferences = currentPreferences.addFavorite(itemId);
|
||||
await saveUserPreferences(updatedPreferences, userId);
|
||||
}
|
||||
|
||||
/// Remove item from favorites
|
||||
Future<void> removeFavorite(String itemId, [String? userId]) async {
|
||||
final currentPreferences = getUserPreferences(userId);
|
||||
if (currentPreferences == null) return;
|
||||
|
||||
final updatedPreferences = currentPreferences.removeFavorite(itemId);
|
||||
await saveUserPreferences(updatedPreferences, userId);
|
||||
}
|
||||
|
||||
/// Check if item is favorite
|
||||
bool isFavorite(String itemId, [String? userId]) {
|
||||
final preferences = getUserPreferences(userId);
|
||||
return preferences?.isFavorite(itemId) ?? false;
|
||||
}
|
||||
|
||||
/// Get all favorite items
|
||||
List<String> getFavorites([String? userId]) {
|
||||
final preferences = getUserPreferences(userId);
|
||||
return preferences?.favoriteItems ?? [];
|
||||
}
|
||||
|
||||
/// Update last accessed time for an item
|
||||
Future<void> updateLastAccessed(String itemId, [String? userId]) async {
|
||||
final currentPreferences = getUserPreferences(userId);
|
||||
if (currentPreferences == null) return;
|
||||
|
||||
final updatedPreferences = currentPreferences.updateLastAccessed(itemId);
|
||||
await saveUserPreferences(updatedPreferences, userId);
|
||||
}
|
||||
|
||||
/// Get last accessed time for an item
|
||||
DateTime? getLastAccessed(String itemId, [String? userId]) {
|
||||
final preferences = getUserPreferences(userId);
|
||||
return preferences?.getLastAccessed(itemId);
|
||||
}
|
||||
|
||||
/// Get recently accessed items
|
||||
List<String> getRecentlyAccessed({int limit = 10, String? userId}) {
|
||||
final preferences = getUserPreferences(userId);
|
||||
return preferences?.getRecentlyAccessed(limit: limit) ?? [];
|
||||
}
|
||||
|
||||
/// Clean old access records
|
||||
Future<void> cleanOldAccess({int maxAgeDays = 30, String? userId}) async {
|
||||
final currentPreferences = getUserPreferences(userId);
|
||||
if (currentPreferences == null) return;
|
||||
|
||||
final updatedPreferences = currentPreferences.cleanOldAccess(maxAgeDays: maxAgeDays);
|
||||
await saveUserPreferences(updatedPreferences, userId);
|
||||
}
|
||||
|
||||
/// Get user statistics
|
||||
Map<String, dynamic> getUserStats([String? userId]) {
|
||||
final preferences = getUserPreferences(userId);
|
||||
return preferences?.getStats() ?? {};
|
||||
}
|
||||
|
||||
/// Export user preferences to Map (for backup)
|
||||
Map<String, dynamic> exportUserPreferences([String? userId]) {
|
||||
try {
|
||||
final preferences = getUserPreferences(userId);
|
||||
return preferences?.toMap() ?? {};
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error exporting user preferences: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Import user preferences from Map (for restore)
|
||||
Future<bool> importUserPreferences(Map<String, dynamic> preferencesMap, [String? userId]) async {
|
||||
try {
|
||||
final preferences = UserPreferences.fromMap(preferencesMap);
|
||||
await saveUserPreferences(preferences, userId);
|
||||
debugPrint('✅ User preferences imported successfully');
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error importing user preferences: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if user preferences exist
|
||||
bool hasUserPreferences([String? userId]) {
|
||||
try {
|
||||
final box = HiveService.userDataBox;
|
||||
final key = userId ?? _defaultKey;
|
||||
return box.containsKey(key);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error checking user preferences existence: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear user preferences (use with caution)
|
||||
Future<void> clearUserPreferences([String? userId]) async {
|
||||
try {
|
||||
final box = HiveService.userDataBox;
|
||||
final key = userId ?? _defaultKey;
|
||||
await box.delete(key);
|
||||
debugPrint('✅ User preferences cleared for key: $key');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error clearing user preferences: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all user IDs that have preferences stored
|
||||
List<String> getAllUserIds() {
|
||||
try {
|
||||
final box = HiveService.userDataBox;
|
||||
return box.keys.cast<String>().where((key) => key != _defaultKey).toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting all user IDs: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete preferences for a specific user
|
||||
Future<void> deleteUserPreferences(String userId) async {
|
||||
try {
|
||||
final box = HiveService.userDataBox;
|
||||
await box.delete(userId);
|
||||
debugPrint('✅ User preferences deleted for user: $userId');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error deleting user preferences: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get multiple users' preferences
|
||||
Map<String, UserPreferences> getMultipleUserPreferences(List<String> userIds) {
|
||||
final result = <String, UserPreferences>{};
|
||||
|
||||
for (final userId in userIds) {
|
||||
final preferences = getUserPreferences(userId);
|
||||
if (preferences != null) {
|
||||
result[userId] = preferences;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Validate user preferences
|
||||
bool validateUserPreferences(UserPreferences preferences) {
|
||||
try {
|
||||
// Basic validation
|
||||
if (preferences.userId.isEmpty) return false;
|
||||
if (preferences.displayName.isEmpty) return false;
|
||||
if (preferences.version < 1) return false;
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error validating user preferences: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch user preferences changes
|
||||
Stream<UserPreferences?> watchUserPreferences([String? userId]) {
|
||||
try {
|
||||
final box = HiveService.userDataBox;
|
||||
final key = userId ?? _defaultKey;
|
||||
return box.watch(key: key).map((event) => event.value as UserPreferences?);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error watching user preferences: $e');
|
||||
return Stream.value(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact user preferences storage
|
||||
Future<void> compact() async {
|
||||
try {
|
||||
final box = HiveService.userDataBox;
|
||||
await box.compact();
|
||||
debugPrint('✅ User preferences storage compacted');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error compacting user preferences: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Map<String, dynamic> getStorageStats() {
|
||||
try {
|
||||
final box = HiveService.userDataBox;
|
||||
final allUserIds = getAllUserIds();
|
||||
|
||||
return {
|
||||
'totalUsers': allUserIds.length,
|
||||
'hasDefaultUser': hasUserPreferences(),
|
||||
'totalEntries': box.length,
|
||||
'userIds': allUserIds,
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting storage stats: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
3
lib/core/errors/errors.dart
Normal file
3
lib/core/errors/errors.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
// Barrel export file for errors
|
||||
export 'exceptions.dart';
|
||||
export 'failures.dart';
|
||||
79
lib/core/errors/exceptions.dart
Normal file
79
lib/core/errors/exceptions.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
/// Base exception class for the application
|
||||
abstract class AppException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
final dynamic originalError;
|
||||
|
||||
const AppException(
|
||||
this.message, {
|
||||
this.code,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AppException: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Network-related exceptions
|
||||
class NetworkException extends AppException {
|
||||
const NetworkException(
|
||||
super.message, {
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
}
|
||||
|
||||
/// Cache-related exceptions
|
||||
class CacheException extends AppException {
|
||||
const CacheException(
|
||||
super.message, {
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
}
|
||||
|
||||
/// Validation-related exceptions
|
||||
class ValidationException extends AppException {
|
||||
const ValidationException(
|
||||
super.message, {
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
}
|
||||
|
||||
/// Authentication-related exceptions
|
||||
class AuthException extends AppException {
|
||||
const AuthException(
|
||||
super.message, {
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
}
|
||||
|
||||
/// Server-related exceptions
|
||||
class ServerException extends AppException {
|
||||
final int? statusCode;
|
||||
|
||||
const ServerException(
|
||||
super.message, {
|
||||
this.statusCode,
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ServerException: $message${statusCode != null ? ' (Status: $statusCode)' : ''}${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
}
|
||||
|
||||
/// Local storage exceptions
|
||||
class StorageException extends AppException {
|
||||
const StorageException(
|
||||
super.message, {
|
||||
super.code,
|
||||
super.originalError,
|
||||
});
|
||||
}
|
||||
59
lib/core/errors/failures.dart
Normal file
59
lib/core/errors/failures.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Base failure class for error handling in the application
|
||||
abstract class Failure extends Equatable {
|
||||
final String message;
|
||||
final String? code;
|
||||
|
||||
const Failure(this.message, {this.code});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, code];
|
||||
|
||||
@override
|
||||
String toString() => 'Failure: $message${code != null ? ' (Code: $code)' : ''}';
|
||||
}
|
||||
|
||||
/// Network failure
|
||||
class NetworkFailure extends Failure {
|
||||
const NetworkFailure(super.message, {super.code});
|
||||
}
|
||||
|
||||
/// Server failure
|
||||
class ServerFailure extends Failure {
|
||||
final int? statusCode;
|
||||
|
||||
const ServerFailure(
|
||||
super.message, {
|
||||
this.statusCode,
|
||||
super.code,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message, code, statusCode];
|
||||
}
|
||||
|
||||
/// Cache failure
|
||||
class CacheFailure extends Failure {
|
||||
const CacheFailure(super.message, {super.code});
|
||||
}
|
||||
|
||||
/// Validation failure
|
||||
class ValidationFailure extends Failure {
|
||||
const ValidationFailure(super.message, {super.code});
|
||||
}
|
||||
|
||||
/// Authentication failure
|
||||
class AuthFailure extends Failure {
|
||||
const AuthFailure(super.message, {super.code});
|
||||
}
|
||||
|
||||
/// Storage failure
|
||||
class StorageFailure extends Failure {
|
||||
const StorageFailure(super.message, {super.code});
|
||||
}
|
||||
|
||||
/// Unknown failure
|
||||
class UnknownFailure extends Failure {
|
||||
const UnknownFailure(super.message, {super.code});
|
||||
}
|
||||
501
lib/core/network/README.md
Normal file
501
lib/core/network/README.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# Network Layer Documentation
|
||||
|
||||
This network layer provides a comprehensive HTTP client implementation using Dio with advanced features like authentication, retry logic, error handling, and connectivity monitoring.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Configured Dio client** with timeouts and base URL management
|
||||
- ✅ **Authentication interceptor** with automatic token refresh
|
||||
- ✅ **Comprehensive error handling** with domain-specific exceptions
|
||||
- ✅ **Request/response logging** for debugging
|
||||
- ✅ **Automatic retry logic** for failed requests
|
||||
- ✅ **Network connectivity monitoring**
|
||||
- ✅ **Certificate pinning setup** (configurable)
|
||||
- ✅ **File upload/download support**
|
||||
- ✅ **Standardized API response models**
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lib/core/network/
|
||||
├── dio_client.dart # Main HTTP client wrapper
|
||||
├── api_constants.dart # API configuration and endpoints
|
||||
├── network_info.dart # Connectivity monitoring
|
||||
├── interceptors/
|
||||
│ ├── auth_interceptor.dart # Token management and refresh
|
||||
│ ├── logging_interceptor.dart # Request/response logging
|
||||
│ └── error_interceptor.dart # Error handling and mapping
|
||||
├── models/
|
||||
│ └── api_response.dart # Standardized response models
|
||||
└── README.md # This documentation
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Setup Providers
|
||||
|
||||
```dart
|
||||
// In your app, use the pre-configured providers
|
||||
final dioClient = ref.watch(dioClientProvider);
|
||||
|
||||
// Or manually create
|
||||
final dioClient = DioClient(
|
||||
networkInfo: NetworkInfoImpl(Connectivity()),
|
||||
secureStorage: FlutterSecureStorage(),
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Basic HTTP Requests
|
||||
|
||||
```dart
|
||||
// GET request
|
||||
final response = await dioClient.get('/users/123');
|
||||
|
||||
// POST request
|
||||
final response = await dioClient.post('/posts', data: {
|
||||
'title': 'My Post',
|
||||
'content': 'Post content'
|
||||
});
|
||||
|
||||
// PUT request
|
||||
final response = await dioClient.put('/users/123', data: userData);
|
||||
|
||||
// DELETE request
|
||||
final response = await dioClient.delete('/posts/456');
|
||||
```
|
||||
|
||||
### 3. File Operations
|
||||
|
||||
```dart
|
||||
// Upload file
|
||||
final response = await dioClient.uploadFile(
|
||||
'/upload',
|
||||
File('/path/to/file.jpg'),
|
||||
filename: 'avatar.jpg',
|
||||
);
|
||||
|
||||
// Download file
|
||||
await dioClient.downloadFile(
|
||||
'/files/document.pdf',
|
||||
'/local/path/document.pdf',
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Authentication
|
||||
|
||||
```dart
|
||||
// Store tokens after login
|
||||
await dioClient.authInterceptor.storeTokens(
|
||||
accessToken: 'your-access-token',
|
||||
refreshToken: 'your-refresh-token',
|
||||
expiresIn: 3600, // 1 hour
|
||||
);
|
||||
|
||||
// Check authentication status
|
||||
final isAuth = await dioClient.authInterceptor.isAuthenticated();
|
||||
|
||||
// Logout (clears tokens)
|
||||
await dioClient.authInterceptor.logout();
|
||||
```
|
||||
|
||||
### 5. Network Connectivity
|
||||
|
||||
```dart
|
||||
// Check current connectivity
|
||||
final isConnected = await dioClient.isConnected;
|
||||
|
||||
// Listen to connectivity changes
|
||||
dioClient.connectionStream.listen((isConnected) {
|
||||
if (isConnected) {
|
||||
print('Connected to internet');
|
||||
} else {
|
||||
print('No internet connection');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### API Constants (`api_constants.dart`)
|
||||
|
||||
```dart
|
||||
class ApiConstants {
|
||||
// Environment URLs
|
||||
static const String baseUrlDev = 'https://api-dev.example.com';
|
||||
static const String baseUrlProd = 'https://api.example.com';
|
||||
|
||||
// Timeouts
|
||||
static const int connectTimeout = 30000;
|
||||
static const int receiveTimeout = 30000;
|
||||
|
||||
// Retry configuration
|
||||
static const int maxRetries = 3;
|
||||
static const Duration retryDelay = Duration(seconds: 1);
|
||||
|
||||
// Endpoints
|
||||
static const String loginEndpoint = '/auth/login';
|
||||
static const String userEndpoint = '/user';
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Switching
|
||||
|
||||
```dart
|
||||
// Update base URL at runtime
|
||||
dioClient.updateBaseUrl('https://api-staging.example.com');
|
||||
|
||||
// Add custom headers
|
||||
dioClient.addHeader('X-Custom-Header', 'value');
|
||||
|
||||
// Remove headers
|
||||
dioClient.removeHeader('X-Custom-Header');
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The network layer provides comprehensive error handling with domain-specific exceptions:
|
||||
|
||||
```dart
|
||||
try {
|
||||
final response = await dioClient.get('/api/data');
|
||||
// Handle success
|
||||
} on DioException catch (e) {
|
||||
final failure = e.networkFailure;
|
||||
|
||||
failure.when(
|
||||
serverError: (statusCode, message, errors) {
|
||||
// Handle server errors (5xx)
|
||||
},
|
||||
networkError: (message) {
|
||||
// Handle network connectivity issues
|
||||
},
|
||||
timeoutError: (message) {
|
||||
// Handle timeout errors
|
||||
},
|
||||
unauthorizedError: (message) {
|
||||
// Handle authentication errors (401)
|
||||
},
|
||||
forbiddenError: (message) {
|
||||
// Handle authorization errors (403)
|
||||
},
|
||||
notFoundError: (message) {
|
||||
// Handle not found errors (404)
|
||||
},
|
||||
validationError: (message, errors) {
|
||||
// Handle validation errors (422)
|
||||
},
|
||||
unknownError: (message) {
|
||||
// Handle unknown errors
|
||||
},
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
- **ServerError**: HTTP 5xx errors from the server
|
||||
- **NetworkConnectionError**: Network connectivity issues
|
||||
- **TimeoutError**: Request/response timeouts
|
||||
- **UnauthorizedError**: HTTP 401 authentication failures
|
||||
- **ForbiddenError**: HTTP 403 authorization failures
|
||||
- **NotFoundError**: HTTP 404 resource not found
|
||||
- **ValidationError**: HTTP 422 validation failures with field details
|
||||
- **UnknownError**: Any other unexpected errors
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
The authentication interceptor automatically handles:
|
||||
|
||||
1. **Adding tokens** to requests (Authorization header)
|
||||
2. **Token refresh** when access token expires
|
||||
3. **Retry failed requests** after token refresh
|
||||
4. **Token storage** in secure storage
|
||||
5. **Automatic logout** when refresh fails
|
||||
|
||||
### Token Storage
|
||||
|
||||
Tokens are securely stored using `flutter_secure_storage`:
|
||||
|
||||
- `access_token`: Current access token
|
||||
- `refresh_token`: Refresh token for getting new access tokens
|
||||
- `token_expiry`: Token expiration timestamp
|
||||
|
||||
### Automatic Refresh
|
||||
|
||||
When a request fails with 401 Unauthorized:
|
||||
|
||||
1. Interceptor checks if refresh token exists
|
||||
2. Makes refresh request to `/auth/refresh`
|
||||
3. Stores new tokens if successful
|
||||
4. Retries original request with new token
|
||||
5. If refresh fails, clears all tokens
|
||||
|
||||
## Retry Logic
|
||||
|
||||
Requests are automatically retried for:
|
||||
|
||||
- **Connection timeouts**
|
||||
- **Server errors (5xx)**
|
||||
- **Network connectivity issues**
|
||||
|
||||
Configuration:
|
||||
- Maximum retries: 3
|
||||
- Delay between retries: Progressive (1s, 2s, 3s)
|
||||
- Only retries on recoverable errors
|
||||
|
||||
## Logging
|
||||
|
||||
Request/response logging is automatically handled by the logging interceptor:
|
||||
|
||||
### Log Output Example
|
||||
|
||||
```
|
||||
🚀 REQUEST: GET https://api.example.com/api/v1/users/123
|
||||
📋 Headers: {"Authorization": "***HIDDEN***", "Content-Type": "application/json"}
|
||||
✅ RESPONSE: GET https://api.example.com/api/v1/users/123 [200] (245ms)
|
||||
📥 Response Body: {"id": 123, "name": "John Doe"}
|
||||
```
|
||||
|
||||
### Log Features
|
||||
|
||||
- **Request/response timing**
|
||||
- **Sensitive header sanitization** (Authorization, API keys, etc.)
|
||||
- **Body truncation** for large responses
|
||||
- **Error stack traces** in debug mode
|
||||
- **Configurable log levels**
|
||||
|
||||
### Controlling Logging
|
||||
|
||||
```dart
|
||||
// Disable logging
|
||||
dioClient.setLoggingEnabled(false);
|
||||
|
||||
// Create client with custom logging
|
||||
final loggingInterceptor = LoggingInterceptor(
|
||||
enabled: true,
|
||||
logRequestBody: true,
|
||||
logResponseBody: false, // Disable response body logging
|
||||
maxBodyLength: 1000, // Limit body length
|
||||
);
|
||||
```
|
||||
|
||||
## Network Monitoring
|
||||
|
||||
The network info service provides detailed connectivity information:
|
||||
|
||||
```dart
|
||||
final networkInfo = NetworkInfoImpl(Connectivity());
|
||||
|
||||
// Simple connectivity check
|
||||
final isConnected = await networkInfo.isConnected;
|
||||
|
||||
// Detailed connection info
|
||||
final details = await networkInfo.getConnectionDetails();
|
||||
print(details.connectionDescription); // "Connected via WiFi"
|
||||
|
||||
// Connection type checks
|
||||
final isWiFi = await networkInfo.isConnectedToWiFi;
|
||||
final isMobile = await networkInfo.isConnectedToMobile;
|
||||
```
|
||||
|
||||
## API Response Models
|
||||
|
||||
### Basic API Response
|
||||
|
||||
```dart
|
||||
class ApiResponse<T> {
|
||||
final bool success;
|
||||
final String message;
|
||||
final T? data;
|
||||
final List<String>? errors;
|
||||
final Map<String, dynamic>? meta;
|
||||
|
||||
// Factory constructors
|
||||
factory ApiResponse.success({required T data});
|
||||
factory ApiResponse.error({required String message});
|
||||
}
|
||||
```
|
||||
|
||||
### Usage with Services
|
||||
|
||||
```dart
|
||||
class UserService {
|
||||
Future<User> getUser(String id) async {
|
||||
final response = await dioClient.get('/users/$id');
|
||||
|
||||
return handleApiResponse(
|
||||
response,
|
||||
(data) => User.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
T handleApiResponse<T>(Response response, T Function(dynamic) fromJson) {
|
||||
if (response.statusCode == 200) {
|
||||
return fromJson(response.data);
|
||||
}
|
||||
throw Exception('Request failed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Providers for Dependency Injection
|
||||
|
||||
```dart
|
||||
final userServiceProvider = Provider((ref) {
|
||||
final dioClient = ref.watch(dioClientProvider);
|
||||
return UserService(dioClient);
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Create Service Classes
|
||||
|
||||
```dart
|
||||
class UserService extends BaseApiService {
|
||||
UserService(super.dioClient);
|
||||
|
||||
Future<User> getUser(String id) => executeRequest(
|
||||
() => dioClient.get('/users/$id'),
|
||||
User.fromJson,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Handle Errors Gracefully
|
||||
|
||||
```dart
|
||||
try {
|
||||
final user = await userService.getUser('123');
|
||||
// Handle success
|
||||
} catch (e) {
|
||||
// Show user-friendly error message
|
||||
showErrorSnackBar(context, e.toString());
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use Network Status
|
||||
|
||||
```dart
|
||||
Widget build(BuildContext context) {
|
||||
final networkStatus = ref.watch(networkConnectivityProvider);
|
||||
|
||||
return networkStatus.when(
|
||||
data: (isConnected) => isConnected
|
||||
? MainContent()
|
||||
: OfflineWidget(),
|
||||
loading: () => LoadingWidget(),
|
||||
error: (_, __) => ErrorWidget(),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Configure for Different Environments
|
||||
|
||||
```dart
|
||||
class ApiEnvironment {
|
||||
static String get baseUrl {
|
||||
if (kDebugMode) return ApiConstants.baseUrlDev;
|
||||
if (kProfileMode) return ApiConstants.baseUrlStaging;
|
||||
return ApiConstants.baseUrlProd;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Mock Network Responses
|
||||
|
||||
```dart
|
||||
class MockDioClient extends DioClient {
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {options, cancelToken, queryParameters}) async {
|
||||
// Return mock response
|
||||
return Response(
|
||||
data: {'id': 1, 'name': 'Test User'},
|
||||
statusCode: 200,
|
||||
requestOptions: RequestOptions(path: path),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Network Info
|
||||
|
||||
```dart
|
||||
class MockNetworkInfo implements NetworkInfo {
|
||||
final bool _isConnected;
|
||||
|
||||
MockNetworkInfo({required bool isConnected}) : _isConnected = isConnected;
|
||||
|
||||
@override
|
||||
Future<bool> get isConnected => Future.value(_isConnected);
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. Certificate Pinning
|
||||
|
||||
```dart
|
||||
// Enable in production
|
||||
class ApiConstants {
|
||||
static const bool enableCertificatePinning = true;
|
||||
static const List<String> certificateHashes = [
|
||||
'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Secure Token Storage
|
||||
|
||||
Tokens are automatically stored in secure storage with platform-specific encryption.
|
||||
|
||||
### 3. Request Sanitization
|
||||
|
||||
Sensitive headers are automatically sanitized in logs to prevent token leakage.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Connection Timeouts**
|
||||
- Increase timeout values in `ApiConstants`
|
||||
- Check network connectivity
|
||||
- Verify server availability
|
||||
|
||||
2. **Authentication Failures**
|
||||
- Ensure tokens are correctly stored
|
||||
- Verify refresh endpoint configuration
|
||||
- Check token expiration handling
|
||||
|
||||
3. **Certificate Errors**
|
||||
- Disable certificate pinning in development
|
||||
- Add proper certificate hashes for production
|
||||
- Check server SSL configuration
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable detailed logging to troubleshoot issues:
|
||||
|
||||
```dart
|
||||
final dioClient = DioClient(
|
||||
networkInfo: networkInfo,
|
||||
secureStorage: secureStorage,
|
||||
);
|
||||
|
||||
// Enable detailed logging
|
||||
dioClient.setLoggingEnabled(true);
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
When migrating from basic Dio to this network layer:
|
||||
|
||||
1. Replace `Dio()` instances with `DioClient`
|
||||
2. Update error handling to use `NetworkFailure` types
|
||||
3. Use providers for dependency injection
|
||||
4. Migrate to service classes extending `BaseApiService`
|
||||
5. Update authentication flow to use interceptor methods
|
||||
|
||||
This network layer provides a solid foundation for any Flutter app requiring robust HTTP communication with proper error handling, authentication, and network monitoring.
|
||||
78
lib/core/network/api_constants.dart
Normal file
78
lib/core/network/api_constants.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
/// API constants for network configuration
|
||||
class ApiConstants {
|
||||
// Private constructor to prevent instantiation
|
||||
const ApiConstants._();
|
||||
|
||||
// Base URLs for different environments
|
||||
static const String baseUrlDev = 'https://api-dev.example.com';
|
||||
static const String baseUrlStaging = 'https://api-staging.example.com';
|
||||
static const String baseUrlProd = 'https://api.example.com';
|
||||
|
||||
// Current environment base URL
|
||||
// In a real app, this would be determined by build configuration
|
||||
static const String baseUrl = baseUrlDev;
|
||||
|
||||
// API versioning
|
||||
static const String apiVersion = 'v1';
|
||||
static const String apiPath = '/api/$apiVersion';
|
||||
|
||||
// Timeout configurations (in milliseconds)
|
||||
static const int connectTimeout = 30000; // 30 seconds
|
||||
static const int receiveTimeout = 30000; // 30 seconds
|
||||
static const int sendTimeout = 30000; // 30 seconds
|
||||
|
||||
// Retry configurations
|
||||
static const int maxRetries = 3;
|
||||
static const Duration retryDelay = Duration(seconds: 1);
|
||||
|
||||
// Headers
|
||||
static const String contentType = 'application/json';
|
||||
static const String accept = 'application/json';
|
||||
static const String userAgent = 'BaseFlutter/1.0.0';
|
||||
|
||||
// Authentication
|
||||
static const String authHeaderKey = 'Authorization';
|
||||
static const String bearerPrefix = 'Bearer';
|
||||
static const String apiKeyHeaderKey = 'X-API-Key';
|
||||
|
||||
// Common API endpoints
|
||||
static const String authEndpoint = '/auth';
|
||||
static const String loginEndpoint = '$authEndpoint/login';
|
||||
static const String refreshEndpoint = '$authEndpoint/refresh';
|
||||
static const String logoutEndpoint = '$authEndpoint/logout';
|
||||
static const String userEndpoint = '/user';
|
||||
static const String profileEndpoint = '$userEndpoint/profile';
|
||||
|
||||
// Example service endpoints (for demonstration)
|
||||
static const String todosEndpoint = '/todos';
|
||||
static const String postsEndpoint = '/posts';
|
||||
static const String usersEndpoint = '/users';
|
||||
|
||||
// Cache configurations
|
||||
static const Duration cacheMaxAge = Duration(minutes: 5);
|
||||
static const String cacheControlHeader = 'Cache-Control';
|
||||
static const String etagHeader = 'ETag';
|
||||
static const String ifNoneMatchHeader = 'If-None-Match';
|
||||
|
||||
// Error codes
|
||||
static const int unauthorizedCode = 401;
|
||||
static const int forbiddenCode = 403;
|
||||
static const int notFoundCode = 404;
|
||||
static const int internalServerErrorCode = 500;
|
||||
static const int badGatewayCode = 502;
|
||||
static const int serviceUnavailableCode = 503;
|
||||
|
||||
// Certificate pinning (for production)
|
||||
static const List<String> certificateHashes = [
|
||||
// Add SHA256 hashes of your server certificates here
|
||||
// Example: 'sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||
];
|
||||
|
||||
// Development flags
|
||||
static const bool enableLogging = true;
|
||||
static const bool enableCertificatePinning = false; // Disabled for development
|
||||
|
||||
// API rate limiting
|
||||
static const int maxRequestsPerMinute = 100;
|
||||
static const Duration rateLimitWindow = Duration(minutes: 1);
|
||||
}
|
||||
362
lib/core/network/dio_client.dart
Normal file
362
lib/core/network/dio_client.dart
Normal file
@@ -0,0 +1,362 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'api_constants.dart';
|
||||
import 'interceptors/auth_interceptor.dart';
|
||||
import 'interceptors/error_interceptor.dart';
|
||||
import 'interceptors/logging_interceptor.dart';
|
||||
import 'network_info.dart';
|
||||
|
||||
/// Dio HTTP client wrapper with comprehensive configuration
|
||||
class DioClient {
|
||||
late final Dio _dio;
|
||||
final NetworkInfo _networkInfo;
|
||||
late final AuthInterceptor _authInterceptor;
|
||||
final LoggingInterceptor _loggingInterceptor;
|
||||
final ErrorInterceptor _errorInterceptor;
|
||||
|
||||
DioClient({
|
||||
required NetworkInfo networkInfo,
|
||||
required FlutterSecureStorage secureStorage,
|
||||
String? baseUrl,
|
||||
}) : _networkInfo = networkInfo,
|
||||
_loggingInterceptor = LoggingInterceptor(),
|
||||
_errorInterceptor = ErrorInterceptor() {
|
||||
_dio = _createDio(baseUrl ?? ApiConstants.baseUrl);
|
||||
_authInterceptor = AuthInterceptor(
|
||||
secureStorage: secureStorage,
|
||||
dio: _dio,
|
||||
);
|
||||
_setupInterceptors();
|
||||
_configureHttpClient();
|
||||
}
|
||||
|
||||
/// Getter for the underlying Dio instance
|
||||
Dio get dio => _dio;
|
||||
|
||||
/// Create and configure Dio instance
|
||||
Dio _createDio(String baseUrl) {
|
||||
final dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl + ApiConstants.apiPath,
|
||||
connectTimeout: const Duration(milliseconds: ApiConstants.connectTimeout),
|
||||
receiveTimeout: const Duration(milliseconds: ApiConstants.receiveTimeout),
|
||||
sendTimeout: const Duration(milliseconds: ApiConstants.sendTimeout),
|
||||
headers: {
|
||||
'Content-Type': ApiConstants.contentType,
|
||||
'Accept': ApiConstants.accept,
|
||||
'User-Agent': ApiConstants.userAgent,
|
||||
},
|
||||
responseType: ResponseType.json,
|
||||
followRedirects: true,
|
||||
validateStatus: (status) {
|
||||
// Consider all status codes as valid to handle them in interceptors
|
||||
return status != null && status < 500;
|
||||
},
|
||||
));
|
||||
|
||||
return dio;
|
||||
}
|
||||
|
||||
/// Setup interceptors in the correct order
|
||||
void _setupInterceptors() {
|
||||
// Add interceptors in order:
|
||||
// 1. Request logging and preparation
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
// Add request start time for duration calculation
|
||||
options.extra['start_time'] = DateTime.now().millisecondsSinceEpoch;
|
||||
handler.next(options);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// 2. Authentication (adds tokens to requests)
|
||||
_dio.interceptors.add(_authInterceptor);
|
||||
|
||||
// 3. Retry interceptor for network failures
|
||||
_dio.interceptors.add(_createRetryInterceptor());
|
||||
|
||||
// 4. Logging (logs requests and responses)
|
||||
_dio.interceptors.add(_loggingInterceptor);
|
||||
|
||||
// 5. Error handling (last to catch all errors)
|
||||
_dio.interceptors.add(_errorInterceptor);
|
||||
}
|
||||
|
||||
/// Configure HTTP client for certificate pinning and other security features
|
||||
void _configureHttpClient() {
|
||||
if (_dio.httpClientAdapter is IOHttpClientAdapter) {
|
||||
final adapter = _dio.httpClientAdapter as IOHttpClientAdapter;
|
||||
|
||||
adapter.createHttpClient = () {
|
||||
final client = HttpClient();
|
||||
// Configure certificate pinning in production
|
||||
if (ApiConstants.enableCertificatePinning) {
|
||||
client.badCertificateCallback = (cert, host, port) {
|
||||
// Implement certificate pinning logic here
|
||||
// For now, return false to reject invalid certificates
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
// Configure timeouts
|
||||
client.connectionTimeout = const Duration(
|
||||
milliseconds: ApiConstants.connectTimeout,
|
||||
);
|
||||
|
||||
return client;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Create retry interceptor for handling network failures
|
||||
InterceptorsWrapper _createRetryInterceptor() {
|
||||
return InterceptorsWrapper(
|
||||
onError: (error, handler) async {
|
||||
// Only retry on network errors, not server errors
|
||||
if (_shouldRetry(error)) {
|
||||
final retryCount = error.requestOptions.extra['retry_count'] as int? ?? 0;
|
||||
|
||||
if (retryCount < ApiConstants.maxRetries) {
|
||||
error.requestOptions.extra['retry_count'] = retryCount + 1;
|
||||
|
||||
// Wait before retrying
|
||||
await Future.delayed(
|
||||
ApiConstants.retryDelay * (retryCount + 1),
|
||||
);
|
||||
|
||||
// Check network connectivity before retry
|
||||
final isConnected = await _networkInfo.isConnected;
|
||||
if (!isConnected) {
|
||||
handler.next(error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await _dio.fetch(error.requestOptions);
|
||||
handler.resolve(response);
|
||||
return;
|
||||
} catch (e) {
|
||||
// If retry fails, continue with original error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handler.next(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Determine if an error should trigger a retry
|
||||
bool _shouldRetry(DioException error) {
|
||||
// Retry on network connectivity issues
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.connectionError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Retry on server errors (5xx)
|
||||
if (error.response?.statusCode != null) {
|
||||
final statusCode = error.response!.statusCode!;
|
||||
return statusCode >= 500 && statusCode < 600;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// HTTP Methods
|
||||
|
||||
/// GET request
|
||||
Future<Response<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
return await _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// POST request
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
return await _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// PUT request
|
||||
Future<Response<T>> put<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
return await _dio.put<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// PATCH request
|
||||
Future<Response<T>> patch<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
return await _dio.patch<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// DELETE request
|
||||
Future<Response<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
return await _dio.delete<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upload file
|
||||
Future<Response<T>> uploadFile<T>(
|
||||
String path,
|
||||
File file, {
|
||||
String? field,
|
||||
String? filename,
|
||||
Map<String, dynamic>? data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
}) async {
|
||||
final formData = FormData();
|
||||
|
||||
// Add file
|
||||
formData.files.add(MapEntry(
|
||||
field ?? 'file',
|
||||
await MultipartFile.fromFile(
|
||||
file.path,
|
||||
filename: filename ?? file.path.split('/').last,
|
||||
),
|
||||
));
|
||||
|
||||
// Add other form fields
|
||||
if (data != null) {
|
||||
data.forEach((key, value) {
|
||||
formData.fields.add(MapEntry(key, value.toString()));
|
||||
});
|
||||
}
|
||||
|
||||
return await _dio.post<T>(
|
||||
path,
|
||||
data: formData,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
);
|
||||
}
|
||||
|
||||
/// Download file
|
||||
Future<Response> downloadFile(
|
||||
String urlPath,
|
||||
String savePath, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
}) async {
|
||||
return await _dio.download(
|
||||
urlPath,
|
||||
savePath,
|
||||
queryParameters: queryParameters,
|
||||
cancelToken: cancelToken,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
);
|
||||
}
|
||||
|
||||
// Utility Methods
|
||||
|
||||
/// Check network connectivity
|
||||
Future<bool> get isConnected => _networkInfo.isConnected;
|
||||
|
||||
/// Get network connection stream
|
||||
Stream<bool> get connectionStream => _networkInfo.connectionStream;
|
||||
|
||||
/// Update base URL (useful for environment switching)
|
||||
void updateBaseUrl(String baseUrl) {
|
||||
_dio.options.baseUrl = baseUrl + ApiConstants.apiPath;
|
||||
}
|
||||
|
||||
/// Add custom header
|
||||
void addHeader(String key, String value) {
|
||||
_dio.options.headers[key] = value;
|
||||
}
|
||||
|
||||
/// Remove header
|
||||
void removeHeader(String key) {
|
||||
_dio.options.headers.remove(key);
|
||||
}
|
||||
|
||||
/// Clear all custom headers (keeps default headers)
|
||||
void clearHeaders() {
|
||||
_dio.options.headers.clear();
|
||||
_dio.options.headers.addAll({
|
||||
'Content-Type': ApiConstants.contentType,
|
||||
'Accept': ApiConstants.accept,
|
||||
'User-Agent': ApiConstants.userAgent,
|
||||
});
|
||||
}
|
||||
|
||||
/// Get auth interceptor for token management
|
||||
AuthInterceptor get authInterceptor => _authInterceptor;
|
||||
|
||||
/// Enable/disable logging
|
||||
void setLoggingEnabled(bool enabled) {
|
||||
_loggingInterceptor.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Create a CancelToken for request cancellation
|
||||
CancelToken createCancelToken() => CancelToken();
|
||||
|
||||
/// Close the client and clean up resources
|
||||
void close({bool force = false}) {
|
||||
_dio.close(force: force);
|
||||
}
|
||||
}
|
||||
279
lib/core/network/interceptors/auth_interceptor.dart
Normal file
279
lib/core/network/interceptors/auth_interceptor.dart
Normal file
@@ -0,0 +1,279 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../api_constants.dart';
|
||||
|
||||
/// Interceptor that handles authentication tokens and automatic token refresh
|
||||
class AuthInterceptor extends Interceptor {
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final Dio _dio;
|
||||
|
||||
// Token storage keys
|
||||
static const String _accessTokenKey = 'access_token';
|
||||
static const String _refreshTokenKey = 'refresh_token';
|
||||
static const String _tokenExpiryKey = 'token_expiry';
|
||||
|
||||
// Track if we're currently refreshing to prevent multiple refresh attempts
|
||||
bool _isRefreshing = false;
|
||||
final List<Completer<void>> _refreshCompleters = [];
|
||||
|
||||
AuthInterceptor({
|
||||
required FlutterSecureStorage secureStorage,
|
||||
required Dio dio,
|
||||
}) : _secureStorage = secureStorage,
|
||||
_dio = dio;
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
try {
|
||||
// Skip auth for certain endpoints
|
||||
if (_shouldSkipAuth(options.path)) {
|
||||
handler.next(options);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add access token to request
|
||||
final accessToken = await _getAccessToken();
|
||||
if (accessToken != null && accessToken.isNotEmpty) {
|
||||
options.headers[ApiConstants.authHeaderKey] =
|
||||
'${ApiConstants.bearerPrefix} $accessToken';
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
} catch (e) {
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: options,
|
||||
error: 'Failed to add authentication token: $e',
|
||||
type: DioExceptionType.unknown,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// Only handle 401 unauthorized errors
|
||||
if (err.response?.statusCode != ApiConstants.unauthorizedCode) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip refresh for certain endpoints
|
||||
if (_shouldSkipAuth(err.requestOptions.path)) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt to refresh token
|
||||
final refreshed = await _refreshToken();
|
||||
|
||||
if (refreshed) {
|
||||
// Retry the original request with new token
|
||||
final response = await _retryRequest(err.requestOptions);
|
||||
handler.resolve(response);
|
||||
} else {
|
||||
// Refresh failed, clear tokens and propagate error
|
||||
await _clearTokens();
|
||||
handler.next(err);
|
||||
}
|
||||
} catch (e) {
|
||||
// If refresh fails, clear tokens and propagate original error
|
||||
await _clearTokens();
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the endpoint should skip authentication
|
||||
bool _shouldSkipAuth(String path) {
|
||||
final skipAuthEndpoints = [
|
||||
ApiConstants.loginEndpoint,
|
||||
ApiConstants.refreshEndpoint,
|
||||
// Add other public endpoints here
|
||||
];
|
||||
|
||||
return skipAuthEndpoints.any((endpoint) => path.contains(endpoint));
|
||||
}
|
||||
|
||||
/// Get the stored access token
|
||||
Future<String?> _getAccessToken() async {
|
||||
try {
|
||||
return await _secureStorage.read(key: _accessTokenKey);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the stored refresh token
|
||||
Future<String?> _getRefreshToken() async {
|
||||
try {
|
||||
return await _secureStorage.read(key: _refreshTokenKey);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the token is expired
|
||||
Future<bool> _isTokenExpired() async {
|
||||
try {
|
||||
final expiryString = await _secureStorage.read(key: _tokenExpiryKey);
|
||||
if (expiryString == null) return true;
|
||||
|
||||
final expiry = DateTime.parse(expiryString);
|
||||
return DateTime.now().isAfter(expiry);
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the access token using the refresh token
|
||||
Future<bool> _refreshToken() async {
|
||||
// If already refreshing, wait for it to complete
|
||||
if (_isRefreshing) {
|
||||
final completer = Completer<void>();
|
||||
_refreshCompleters.add(completer);
|
||||
await completer.future;
|
||||
return await _getAccessToken() != null;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
|
||||
try {
|
||||
final refreshToken = await _getRefreshToken();
|
||||
if (refreshToken == null || refreshToken.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Make refresh request
|
||||
final response = await _dio.post(
|
||||
ApiConstants.refreshEndpoint,
|
||||
data: {'refresh_token': refreshToken},
|
||||
options: Options(
|
||||
headers: {
|
||||
ApiConstants.contentType: ApiConstants.contentType,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
|
||||
// Store new tokens
|
||||
await _storeTokens(
|
||||
accessToken: data['access_token'] as String,
|
||||
refreshToken: data['refresh_token'] as String?,
|
||||
expiresIn: data['expires_in'] as int?,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
} finally {
|
||||
_isRefreshing = false;
|
||||
|
||||
// Complete all waiting requests
|
||||
for (final completer in _refreshCompleters) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
_refreshCompleters.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry the original request with the new token
|
||||
Future<Response> _retryRequest(RequestOptions requestOptions) async {
|
||||
// Add the new access token
|
||||
final accessToken = await _getAccessToken();
|
||||
if (accessToken != null) {
|
||||
requestOptions.headers[ApiConstants.authHeaderKey] =
|
||||
'${ApiConstants.bearerPrefix} $accessToken';
|
||||
}
|
||||
|
||||
// Retry the request
|
||||
return await _dio.fetch(requestOptions);
|
||||
}
|
||||
|
||||
/// Store authentication tokens securely
|
||||
Future<void> storeTokens({
|
||||
required String accessToken,
|
||||
String? refreshToken,
|
||||
int? expiresIn,
|
||||
}) async {
|
||||
await _storeTokens(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresIn: expiresIn,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _storeTokens({
|
||||
required String accessToken,
|
||||
String? refreshToken,
|
||||
int? expiresIn,
|
||||
}) async {
|
||||
try {
|
||||
// Store access token
|
||||
await _secureStorage.write(key: _accessTokenKey, value: accessToken);
|
||||
|
||||
// Store refresh token if provided
|
||||
if (refreshToken != null) {
|
||||
await _secureStorage.write(key: _refreshTokenKey, value: refreshToken);
|
||||
}
|
||||
|
||||
// Calculate and store expiry time
|
||||
if (expiresIn != null) {
|
||||
final expiry = DateTime.now().add(Duration(seconds: expiresIn));
|
||||
await _secureStorage.write(
|
||||
key: _tokenExpiryKey,
|
||||
value: expiry.toIso8601String(),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to store tokens: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all stored tokens
|
||||
Future<void> _clearTokens() async {
|
||||
try {
|
||||
await _secureStorage.delete(key: _accessTokenKey);
|
||||
await _secureStorage.delete(key: _refreshTokenKey);
|
||||
await _secureStorage.delete(key: _tokenExpiryKey);
|
||||
} catch (e) {
|
||||
// Log error but don't throw
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if user is authenticated
|
||||
Future<bool> isAuthenticated() async {
|
||||
final accessToken = await _getAccessToken();
|
||||
if (accessToken == null || accessToken.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
return !(await _isTokenExpired());
|
||||
}
|
||||
|
||||
/// Logout by clearing all tokens
|
||||
Future<void> logout() async {
|
||||
await _clearTokens();
|
||||
}
|
||||
|
||||
/// Get current access token (for debugging or manual API calls)
|
||||
Future<String?> getCurrentAccessToken() async {
|
||||
return await _getAccessToken();
|
||||
}
|
||||
|
||||
/// Get current refresh token (for debugging)
|
||||
Future<String?> getCurrentRefreshToken() async {
|
||||
return await _getRefreshToken();
|
||||
}
|
||||
}
|
||||
348
lib/core/network/interceptors/error_interceptor.dart
Normal file
348
lib/core/network/interceptors/error_interceptor.dart
Normal file
@@ -0,0 +1,348 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../api_constants.dart';
|
||||
import '../models/api_response.dart';
|
||||
|
||||
/// Interceptor that handles and transforms network errors into domain-specific exceptions
|
||||
class ErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final networkFailure = _mapDioExceptionToNetworkFailure(err);
|
||||
|
||||
// Create a new DioException with our custom error
|
||||
final customError = DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
response: err.response,
|
||||
error: networkFailure,
|
||||
type: err.type,
|
||||
message: networkFailure.message,
|
||||
stackTrace: err.stackTrace,
|
||||
);
|
||||
|
||||
handler.next(customError);
|
||||
}
|
||||
|
||||
NetworkFailure _mapDioExceptionToNetworkFailure(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return TimeoutError(
|
||||
message: _getTimeoutMessage(error.type),
|
||||
);
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
return _handleBadResponse(error);
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return const NetworkConnectionError(
|
||||
message: 'Request was cancelled',
|
||||
);
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return _handleConnectionError(error);
|
||||
|
||||
case DioExceptionType.badCertificate:
|
||||
return const NetworkConnectionError(
|
||||
message: 'Certificate verification failed. Please check your connection security.',
|
||||
);
|
||||
|
||||
case DioExceptionType.unknown:
|
||||
return _handleUnknownError(error);
|
||||
}
|
||||
}
|
||||
|
||||
NetworkFailure _handleBadResponse(DioException error) {
|
||||
final statusCode = error.response?.statusCode;
|
||||
final responseData = error.response?.data;
|
||||
|
||||
switch (statusCode) {
|
||||
case ApiConstants.unauthorizedCode:
|
||||
return UnauthorizedError(
|
||||
message: _extractErrorMessage(responseData) ??
|
||||
'Authentication failed. Please log in again.',
|
||||
);
|
||||
|
||||
case ApiConstants.forbiddenCode:
|
||||
return ForbiddenError(
|
||||
message: _extractErrorMessage(responseData) ??
|
||||
'Access denied. You don\'t have permission to access this resource.',
|
||||
);
|
||||
|
||||
case ApiConstants.notFoundCode:
|
||||
return NotFoundError(
|
||||
message: _extractErrorMessage(responseData) ??
|
||||
'The requested resource was not found.',
|
||||
);
|
||||
|
||||
case 422: // Validation error
|
||||
final errors = _extractValidationErrors(responseData);
|
||||
return ValidationError(
|
||||
message: _extractErrorMessage(responseData) ??
|
||||
'Validation failed. Please check your input.',
|
||||
errors: errors,
|
||||
);
|
||||
|
||||
case ApiConstants.internalServerErrorCode:
|
||||
case ApiConstants.badGatewayCode:
|
||||
case ApiConstants.serviceUnavailableCode:
|
||||
return ServerError(
|
||||
statusCode: statusCode!,
|
||||
message: _extractErrorMessage(responseData) ??
|
||||
'Server error occurred. Please try again later.',
|
||||
);
|
||||
|
||||
default:
|
||||
return ServerError(
|
||||
statusCode: statusCode ?? 0,
|
||||
message: _extractErrorMessage(responseData) ??
|
||||
'An unexpected server error occurred.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NetworkFailure _handleConnectionError(DioException error) {
|
||||
// Check for specific connection error types
|
||||
final originalError = error.error;
|
||||
|
||||
if (originalError is SocketException) {
|
||||
return _handleSocketException(originalError);
|
||||
}
|
||||
|
||||
if (originalError is HttpException) {
|
||||
return NetworkConnectionError(
|
||||
message: 'HTTP error: ${originalError.message}',
|
||||
);
|
||||
}
|
||||
|
||||
return const NetworkConnectionError(
|
||||
message: 'Connection failed. Please check your internet connection and try again.',
|
||||
);
|
||||
}
|
||||
|
||||
NetworkFailure _handleSocketException(SocketException socketException) {
|
||||
final message = socketException.message.toLowerCase();
|
||||
|
||||
if (message.contains('network is unreachable') ||
|
||||
message.contains('no route to host')) {
|
||||
return const NetworkConnectionError(
|
||||
message: 'Network is unreachable. Please check your internet connection.',
|
||||
);
|
||||
}
|
||||
|
||||
if (message.contains('connection refused') ||
|
||||
message.contains('connection failed')) {
|
||||
return const NetworkConnectionError(
|
||||
message: 'Unable to connect to server. Please try again later.',
|
||||
);
|
||||
}
|
||||
|
||||
if (message.contains('host lookup failed') ||
|
||||
message.contains('nodename nor servname provided')) {
|
||||
return const NetworkConnectionError(
|
||||
message: 'Server not found. Please check your connection and try again.',
|
||||
);
|
||||
}
|
||||
|
||||
return NetworkConnectionError(
|
||||
message: 'Connection error: ${socketException.message}',
|
||||
);
|
||||
}
|
||||
|
||||
NetworkFailure _handleUnknownError(DioException error) {
|
||||
final originalError = error.error;
|
||||
|
||||
if (originalError is FormatException) {
|
||||
return const UnknownError(
|
||||
message: 'Invalid response format received from server.',
|
||||
);
|
||||
}
|
||||
|
||||
if (originalError is TypeError) {
|
||||
return const UnknownError(
|
||||
message: 'Data parsing error occurred.',
|
||||
);
|
||||
}
|
||||
|
||||
return UnknownError(
|
||||
message: originalError?.toString() ?? 'An unexpected error occurred.',
|
||||
);
|
||||
}
|
||||
|
||||
String _getTimeoutMessage(DioExceptionType type) {
|
||||
switch (type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
return 'Connection timeout. Please check your internet connection and try again.';
|
||||
case DioExceptionType.sendTimeout:
|
||||
return 'Send timeout. Request took too long to send.';
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'Receive timeout. Server took too long to respond.';
|
||||
default:
|
||||
return 'Request timeout. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
String? _extractErrorMessage(dynamic responseData) {
|
||||
if (responseData == null) return null;
|
||||
|
||||
try {
|
||||
// Handle different response formats
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
// Try common error message fields
|
||||
final messageFields = ['message', 'error', 'detail', 'error_description'];
|
||||
|
||||
for (final field in messageFields) {
|
||||
if (responseData.containsKey(field) && responseData[field] != null) {
|
||||
return responseData[field].toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from nested error object
|
||||
if (responseData.containsKey('error') && responseData['error'] is Map) {
|
||||
final errorObj = responseData['error'] as Map<String, dynamic>;
|
||||
for (final field in messageFields) {
|
||||
if (errorObj.containsKey(field) && errorObj[field] != null) {
|
||||
return errorObj[field].toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from errors array
|
||||
if (responseData.containsKey('errors') && responseData['errors'] is List) {
|
||||
final errors = responseData['errors'] as List;
|
||||
if (errors.isNotEmpty) {
|
||||
final firstError = errors.first;
|
||||
if (firstError is Map<String, dynamic> && firstError.containsKey('message')) {
|
||||
return firstError['message'].toString();
|
||||
} else if (firstError is String) {
|
||||
return firstError;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a string, return it directly
|
||||
if (responseData is String) {
|
||||
return responseData;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<ApiError> _extractValidationErrors(dynamic responseData) {
|
||||
final errors = <ApiError>[];
|
||||
|
||||
if (responseData == null) return errors;
|
||||
|
||||
try {
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
// Handle Laravel-style validation errors
|
||||
if (responseData.containsKey('errors') && responseData['errors'] is Map) {
|
||||
final errorsMap = responseData['errors'] as Map<String, dynamic>;
|
||||
|
||||
errorsMap.forEach((field, messages) {
|
||||
if (messages is List) {
|
||||
for (final message in messages) {
|
||||
errors.add(ApiError(
|
||||
code: 'validation_error',
|
||||
message: message.toString(),
|
||||
field: field,
|
||||
));
|
||||
}
|
||||
} else if (messages is String) {
|
||||
errors.add(ApiError(
|
||||
code: 'validation_error',
|
||||
message: messages,
|
||||
field: field,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle array of error objects
|
||||
if (responseData.containsKey('errors') && responseData['errors'] is List) {
|
||||
final errorsList = responseData['errors'] as List;
|
||||
|
||||
for (final error in errorsList) {
|
||||
if (error is Map<String, dynamic>) {
|
||||
errors.add(ApiError(
|
||||
code: error['code']?.toString() ?? 'validation_error',
|
||||
message: error['message']?.toString() ?? 'Validation error',
|
||||
field: error['field']?.toString(),
|
||||
details: error['details'] as Map<String, dynamic>?,
|
||||
));
|
||||
} else if (error is String) {
|
||||
errors.add(ApiError(
|
||||
code: 'validation_error',
|
||||
message: error,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, add a generic validation error
|
||||
errors.add(const ApiError(
|
||||
code: 'validation_error',
|
||||
message: 'Validation failed',
|
||||
));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to get NetworkFailure from DioException
|
||||
extension DioExceptionExtension on DioException {
|
||||
NetworkFailure get networkFailure {
|
||||
if (error is NetworkFailure) {
|
||||
return error as NetworkFailure;
|
||||
}
|
||||
|
||||
// Fallback mapping if not processed by interceptor
|
||||
return const UnknownError(
|
||||
message: 'An unexpected error occurred',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper extension to check error types
|
||||
extension NetworkFailureExtension on NetworkFailure {
|
||||
bool get isNetworkError => when(
|
||||
serverError: (_, __, ___) => false,
|
||||
networkError: (_) => true,
|
||||
timeoutError: (_) => true,
|
||||
unauthorizedError: (_) => false,
|
||||
forbiddenError: (_) => false,
|
||||
notFoundError: (_) => false,
|
||||
validationError: (_, __) => false,
|
||||
unknownError: (_) => false,
|
||||
);
|
||||
|
||||
bool get isServerError => when(
|
||||
serverError: (_, __, ___) => true,
|
||||
networkError: (_) => false,
|
||||
timeoutError: (_) => false,
|
||||
unauthorizedError: (_) => false,
|
||||
forbiddenError: (_) => false,
|
||||
notFoundError: (_) => false,
|
||||
validationError: (_, __) => false,
|
||||
unknownError: (_) => false,
|
||||
);
|
||||
|
||||
bool get isAuthError => when(
|
||||
serverError: (_, __, ___) => false,
|
||||
networkError: (_) => false,
|
||||
timeoutError: (_) => false,
|
||||
unauthorizedError: (_) => true,
|
||||
forbiddenError: (_) => true,
|
||||
notFoundError: (_) => false,
|
||||
validationError: (_, __) => false,
|
||||
unknownError: (_) => false,
|
||||
);
|
||||
}
|
||||
281
lib/core/network/interceptors/logging_interceptor.dart
Normal file
281
lib/core/network/interceptors/logging_interceptor.dart
Normal file
@@ -0,0 +1,281 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../api_constants.dart';
|
||||
|
||||
/// Custom logging interceptor for detailed request/response logging
|
||||
class LoggingInterceptor extends Interceptor {
|
||||
bool enabled;
|
||||
final bool logRequestBody;
|
||||
final bool logResponseBody;
|
||||
final bool logHeaders;
|
||||
final int maxBodyLength;
|
||||
|
||||
LoggingInterceptor({
|
||||
this.enabled = ApiConstants.enableLogging,
|
||||
this.logRequestBody = true,
|
||||
this.logResponseBody = true,
|
||||
this.logHeaders = true,
|
||||
this.maxBodyLength = 2000,
|
||||
});
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (enabled) {
|
||||
_logRequest(options);
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
if (enabled) {
|
||||
_logResponse(response);
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
if (enabled) {
|
||||
_logError(err);
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
void _logRequest(RequestOptions options) {
|
||||
final uri = options.uri;
|
||||
final method = options.method.toUpperCase();
|
||||
|
||||
developer.log(
|
||||
'🚀 REQUEST: $method $uri',
|
||||
name: 'HTTP_REQUEST',
|
||||
);
|
||||
|
||||
// Log headers
|
||||
if (logHeaders && options.headers.isNotEmpty) {
|
||||
final headers = _sanitizeHeaders(options.headers);
|
||||
developer.log(
|
||||
'📋 Headers: ${_formatJson(headers)}',
|
||||
name: 'HTTP_REQUEST',
|
||||
);
|
||||
}
|
||||
|
||||
// Log query parameters
|
||||
if (options.queryParameters.isNotEmpty) {
|
||||
developer.log(
|
||||
'🔍 Query Parameters: ${_formatJson(options.queryParameters)}',
|
||||
name: 'HTTP_REQUEST',
|
||||
);
|
||||
}
|
||||
|
||||
// Log request body
|
||||
if (logRequestBody && options.data != null) {
|
||||
final body = _formatRequestBody(options.data);
|
||||
if (body.isNotEmpty) {
|
||||
developer.log(
|
||||
'📝 Request Body: $body',
|
||||
name: 'HTTP_REQUEST',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _logResponse(Response response) {
|
||||
final statusCode = response.statusCode;
|
||||
final method = response.requestOptions.method.toUpperCase();
|
||||
final uri = response.requestOptions.uri;
|
||||
final duration = DateTime.now().millisecondsSinceEpoch -
|
||||
(response.requestOptions.extra['start_time'] as int? ?? 0);
|
||||
|
||||
// Status icon based on response code
|
||||
String statusIcon;
|
||||
if (statusCode != null && statusCode != 0) {
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
statusIcon = '✅';
|
||||
} else if (statusCode >= 300 && statusCode < 400) {
|
||||
statusIcon = '↩️';
|
||||
} else if (statusCode >= 400 && statusCode < 500) {
|
||||
statusIcon = '❌';
|
||||
} else {
|
||||
statusIcon = '💥';
|
||||
}
|
||||
} else {
|
||||
statusIcon = '❓';
|
||||
}
|
||||
|
||||
developer.log(
|
||||
'$statusIcon RESPONSE: $method $uri [$statusCode] (${duration}ms)',
|
||||
name: 'HTTP_RESPONSE',
|
||||
);
|
||||
|
||||
// Log response headers
|
||||
if (logHeaders && response.headers.map.isNotEmpty) {
|
||||
final headers = _sanitizeHeaders(response.headers.map);
|
||||
developer.log(
|
||||
'📋 Response Headers: ${_formatJson(headers)}',
|
||||
name: 'HTTP_RESPONSE',
|
||||
);
|
||||
}
|
||||
|
||||
// Log response body
|
||||
if (logResponseBody && response.data != null) {
|
||||
final body = _formatResponseBody(response.data);
|
||||
if (body.isNotEmpty) {
|
||||
developer.log(
|
||||
'📥 Response Body: $body',
|
||||
name: 'HTTP_RESPONSE',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _logError(DioException error) {
|
||||
final method = error.requestOptions.method.toUpperCase();
|
||||
final uri = error.requestOptions.uri;
|
||||
final statusCode = error.response?.statusCode;
|
||||
|
||||
developer.log(
|
||||
'💥 ERROR: $method $uri [${statusCode ?? 'NO_STATUS'}] - ${error.type.name}',
|
||||
name: 'HTTP_ERROR',
|
||||
error: error,
|
||||
);
|
||||
|
||||
// Log error message
|
||||
if (error.message != null) {
|
||||
developer.log(
|
||||
'❗ Error Message: ${error.message}',
|
||||
name: 'HTTP_ERROR',
|
||||
);
|
||||
}
|
||||
|
||||
// Log error response if available
|
||||
if (error.response?.data != null) {
|
||||
final errorBody = _formatResponseBody(error.response!.data);
|
||||
if (errorBody.isNotEmpty) {
|
||||
developer.log(
|
||||
'📥 Error Response: $errorBody',
|
||||
name: 'HTTP_ERROR',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Log stack trace in debug mode
|
||||
if (error.stackTrace.toString().isNotEmpty) {
|
||||
developer.log(
|
||||
'🔍 Stack Trace: ${error.stackTrace}',
|
||||
name: 'HTTP_ERROR',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatRequestBody(dynamic data) {
|
||||
if (data == null) return '';
|
||||
|
||||
try {
|
||||
String body;
|
||||
|
||||
if (data is Map || data is List) {
|
||||
body = _formatJson(data);
|
||||
} else if (data is FormData) {
|
||||
body = _formatFormData(data);
|
||||
} else {
|
||||
body = data.toString();
|
||||
}
|
||||
|
||||
return _truncateIfNeeded(body);
|
||||
} catch (e) {
|
||||
return 'Failed to format request body: $e';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatResponseBody(dynamic data) {
|
||||
if (data == null) return '';
|
||||
|
||||
try {
|
||||
String body;
|
||||
|
||||
if (data is Map || data is List) {
|
||||
body = _formatJson(data);
|
||||
} else {
|
||||
body = data.toString();
|
||||
}
|
||||
|
||||
return _truncateIfNeeded(body);
|
||||
} catch (e) {
|
||||
return 'Failed to format response body: $e';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatJson(dynamic data) {
|
||||
try {
|
||||
const encoder = JsonEncoder.withIndent(' ');
|
||||
return encoder.convert(data);
|
||||
} catch (e) {
|
||||
return data.toString();
|
||||
}
|
||||
}
|
||||
|
||||
String _formatFormData(FormData formData) {
|
||||
final buffer = StringBuffer('FormData{\n');
|
||||
|
||||
for (final field in formData.fields) {
|
||||
buffer.writeln(' ${field.key}: ${field.value}');
|
||||
}
|
||||
|
||||
for (final file in formData.files) {
|
||||
buffer.writeln(' ${file.key}: ${file.value.filename} (${file.value.length} bytes)');
|
||||
}
|
||||
|
||||
buffer.write('}');
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _sanitizeHeaders(Map<String, dynamic> headers) {
|
||||
final sanitized = <String, dynamic>{};
|
||||
|
||||
headers.forEach((key, value) {
|
||||
final lowerKey = key.toLowerCase();
|
||||
|
||||
// Sanitize sensitive headers
|
||||
if (_isSensitiveHeader(lowerKey)) {
|
||||
sanitized[key] = '***HIDDEN***';
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
bool _isSensitiveHeader(String headerName) {
|
||||
const sensitiveHeaders = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'x-api-key',
|
||||
'x-auth-token',
|
||||
'x-access-token',
|
||||
'x-refresh-token',
|
||||
];
|
||||
|
||||
return sensitiveHeaders.contains(headerName);
|
||||
}
|
||||
|
||||
String _truncateIfNeeded(String text) {
|
||||
if (text.length <= maxBodyLength) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return '${text.substring(0, maxBodyLength)}... (truncated ${text.length - maxBodyLength} characters)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to add start time to request options for duration calculation
|
||||
extension RequestOptionsExtension on RequestOptions {
|
||||
void markStartTime() {
|
||||
extra['start_time'] = DateTime.now().millisecondsSinceEpoch;
|
||||
}
|
||||
}
|
||||
333
lib/core/network/models/api_response.dart
Normal file
333
lib/core/network/models/api_response.dart
Normal file
@@ -0,0 +1,333 @@
|
||||
/// Simple API response wrapper that standardizes all API responses
|
||||
class ApiResponse<T> {
|
||||
final bool success;
|
||||
final String message;
|
||||
final T? data;
|
||||
final List<String>? errors;
|
||||
final Map<String, dynamic>? meta;
|
||||
final int? statusCode;
|
||||
final String? timestamp;
|
||||
|
||||
const ApiResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
this.data,
|
||||
this.errors,
|
||||
this.meta,
|
||||
this.statusCode,
|
||||
this.timestamp,
|
||||
});
|
||||
|
||||
/// Factory constructor for successful responses
|
||||
factory ApiResponse.success({
|
||||
required T data,
|
||||
String message = 'Success',
|
||||
Map<String, dynamic>? meta,
|
||||
}) {
|
||||
return ApiResponse(
|
||||
success: true,
|
||||
message: message,
|
||||
data: data,
|
||||
meta: meta,
|
||||
statusCode: 200,
|
||||
timestamp: DateTime.now().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Factory constructor for error responses
|
||||
factory ApiResponse.error({
|
||||
required String message,
|
||||
List<String>? errors,
|
||||
int? statusCode,
|
||||
Map<String, dynamic>? meta,
|
||||
}) {
|
||||
return ApiResponse(
|
||||
success: false,
|
||||
message: message,
|
||||
errors: errors,
|
||||
statusCode: statusCode,
|
||||
meta: meta,
|
||||
timestamp: DateTime.now().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create from JSON
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(dynamic)? fromJsonT,
|
||||
) {
|
||||
return ApiResponse(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'] ?? '',
|
||||
data: json['data'] != null && fromJsonT != null ? fromJsonT(json['data']) : null,
|
||||
errors: (json['errors'] as List<dynamic>?)?.cast<String>(),
|
||||
meta: json['meta'] as Map<String, dynamic>?,
|
||||
statusCode: json['status_code'] as int?,
|
||||
timestamp: json['timestamp'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON
|
||||
Map<String, dynamic> toJson([dynamic Function(T)? toJsonT]) {
|
||||
return {
|
||||
'success': success,
|
||||
'message': message,
|
||||
'data': data != null && toJsonT != null ? toJsonT(data as T) : data,
|
||||
if (errors != null) 'errors': errors,
|
||||
if (meta != null) 'meta': meta,
|
||||
if (statusCode != null) 'status_code': statusCode,
|
||||
if (timestamp != null) 'timestamp': timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ApiResponse(success: $success, message: $message, data: $data)';
|
||||
}
|
||||
}
|
||||
|
||||
/// Pagination metadata for paginated API responses
|
||||
class PaginationMeta {
|
||||
final int currentPage;
|
||||
final int perPage;
|
||||
final int total;
|
||||
final int totalPages;
|
||||
final bool hasNextPage;
|
||||
final bool hasPreviousPage;
|
||||
|
||||
const PaginationMeta({
|
||||
required this.currentPage,
|
||||
required this.perPage,
|
||||
required this.total,
|
||||
required this.totalPages,
|
||||
required this.hasNextPage,
|
||||
required this.hasPreviousPage,
|
||||
});
|
||||
|
||||
factory PaginationMeta.fromJson(Map<String, dynamic> json) {
|
||||
return PaginationMeta(
|
||||
currentPage: json['current_page'] ?? 0,
|
||||
perPage: json['per_page'] ?? 0,
|
||||
total: json['total'] ?? 0,
|
||||
totalPages: json['total_pages'] ?? 0,
|
||||
hasNextPage: json['has_next_page'] ?? false,
|
||||
hasPreviousPage: json['has_previous_page'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'current_page': currentPage,
|
||||
'per_page': perPage,
|
||||
'total': total,
|
||||
'total_pages': totalPages,
|
||||
'has_next_page': hasNextPage,
|
||||
'has_previous_page': hasPreviousPage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Paginated API response wrapper
|
||||
class PaginatedApiResponse<T> {
|
||||
final bool success;
|
||||
final String message;
|
||||
final List<T> data;
|
||||
final PaginationMeta pagination;
|
||||
final List<String>? errors;
|
||||
final int? statusCode;
|
||||
final String? timestamp;
|
||||
|
||||
const PaginatedApiResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
required this.pagination,
|
||||
this.errors,
|
||||
this.statusCode,
|
||||
this.timestamp,
|
||||
});
|
||||
|
||||
factory PaginatedApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(dynamic) fromJsonT,
|
||||
) {
|
||||
return PaginatedApiResponse(
|
||||
success: json['success'] ?? false,
|
||||
message: json['message'] ?? '',
|
||||
data: (json['data'] as List<dynamic>?)?.map(fromJsonT).toList() ?? [],
|
||||
pagination: PaginationMeta.fromJson(json['pagination'] ?? {}),
|
||||
errors: (json['errors'] as List<dynamic>?)?.cast<String>(),
|
||||
statusCode: json['status_code'] as int?,
|
||||
timestamp: json['timestamp'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// API error details for more specific error handling
|
||||
class ApiError {
|
||||
final String code;
|
||||
final String message;
|
||||
final String? field;
|
||||
final Map<String, dynamic>? details;
|
||||
|
||||
const ApiError({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.field,
|
||||
this.details,
|
||||
});
|
||||
|
||||
factory ApiError.fromJson(Map<String, dynamic> json) {
|
||||
return ApiError(
|
||||
code: json['code'] ?? '',
|
||||
message: json['message'] ?? '',
|
||||
field: json['field'] as String?,
|
||||
details: json['details'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'code': code,
|
||||
'message': message,
|
||||
if (field != null) 'field': field,
|
||||
if (details != null) 'details': details,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'ApiError(code: $code, message: $message, field: $field)';
|
||||
}
|
||||
|
||||
/// Network response wrapper that includes both success and error cases
|
||||
abstract class NetworkResponse<T> {
|
||||
const NetworkResponse();
|
||||
}
|
||||
|
||||
class NetworkSuccess<T> extends NetworkResponse<T> {
|
||||
final T data;
|
||||
|
||||
const NetworkSuccess(this.data);
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkSuccess(data: $data)';
|
||||
}
|
||||
|
||||
class NetworkError<T> extends NetworkResponse<T> {
|
||||
final NetworkFailure failure;
|
||||
|
||||
const NetworkError(this.failure);
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkError(failure: $failure)';
|
||||
}
|
||||
|
||||
/// Network failure types
|
||||
abstract class NetworkFailure {
|
||||
final String message;
|
||||
|
||||
const NetworkFailure({required this.message});
|
||||
|
||||
/// Pattern matching helper
|
||||
T when<T>({
|
||||
required T Function(int statusCode, String message, List<ApiError>? errors) serverError,
|
||||
required T Function(String message) networkError,
|
||||
required T Function(String message) timeoutError,
|
||||
required T Function(String message) unauthorizedError,
|
||||
required T Function(String message) forbiddenError,
|
||||
required T Function(String message) notFoundError,
|
||||
required T Function(String message, List<ApiError> errors) validationError,
|
||||
required T Function(String message) unknownError,
|
||||
}) {
|
||||
if (this is ServerError) {
|
||||
final error = this as ServerError;
|
||||
return serverError(error.statusCode, error.message, error.errors);
|
||||
} else if (this is NetworkConnectionError) {
|
||||
return networkError(message);
|
||||
} else if (this is TimeoutError) {
|
||||
return timeoutError(message);
|
||||
} else if (this is UnauthorizedError) {
|
||||
return unauthorizedError(message);
|
||||
} else if (this is ForbiddenError) {
|
||||
return forbiddenError(message);
|
||||
} else if (this is NotFoundError) {
|
||||
return notFoundError(message);
|
||||
} else if (this is ValidationError) {
|
||||
final error = this as ValidationError;
|
||||
return validationError(error.message, error.errors);
|
||||
} else {
|
||||
return unknownError(message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkFailure(message: $message)';
|
||||
}
|
||||
|
||||
class ServerError extends NetworkFailure {
|
||||
final int statusCode;
|
||||
final List<ApiError>? errors;
|
||||
|
||||
const ServerError({
|
||||
required this.statusCode,
|
||||
required String message,
|
||||
this.errors,
|
||||
}) : super(message: message);
|
||||
|
||||
@override
|
||||
String toString() => 'ServerError(statusCode: $statusCode, message: $message)';
|
||||
}
|
||||
|
||||
class NetworkConnectionError extends NetworkFailure {
|
||||
const NetworkConnectionError({required String message}) : super(message: message);
|
||||
|
||||
@override
|
||||
String toString() => 'NetworkConnectionError(message: $message)';
|
||||
}
|
||||
|
||||
class TimeoutError extends NetworkFailure {
|
||||
const TimeoutError({required String message}) : super(message: message);
|
||||
|
||||
@override
|
||||
String toString() => 'TimeoutError(message: $message)';
|
||||
}
|
||||
|
||||
class UnauthorizedError extends NetworkFailure {
|
||||
const UnauthorizedError({required String message}) : super(message: message);
|
||||
|
||||
@override
|
||||
String toString() => 'UnauthorizedError(message: $message)';
|
||||
}
|
||||
|
||||
class ForbiddenError extends NetworkFailure {
|
||||
const ForbiddenError({required String message}) : super(message: message);
|
||||
|
||||
@override
|
||||
String toString() => 'ForbiddenError(message: $message)';
|
||||
}
|
||||
|
||||
class NotFoundError extends NetworkFailure {
|
||||
const NotFoundError({required String message}) : super(message: message);
|
||||
|
||||
@override
|
||||
String toString() => 'NotFoundError(message: $message)';
|
||||
}
|
||||
|
||||
class ValidationError extends NetworkFailure {
|
||||
final List<ApiError> errors;
|
||||
|
||||
const ValidationError({
|
||||
required String message,
|
||||
required this.errors,
|
||||
}) : super(message: message);
|
||||
|
||||
@override
|
||||
String toString() => 'ValidationError(message: $message, errors: $errors)';
|
||||
}
|
||||
|
||||
class UnknownError extends NetworkFailure {
|
||||
const UnknownError({required String message}) : super(message: message);
|
||||
|
||||
@override
|
||||
String toString() => 'UnknownError(message: $message)';
|
||||
}
|
||||
233
lib/core/network/network_info.dart
Normal file
233
lib/core/network/network_info.dart
Normal file
@@ -0,0 +1,233 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
/// Abstract class defining network information interface
|
||||
abstract class NetworkInfo {
|
||||
Future<bool> get isConnected;
|
||||
Stream<bool> get connectionStream;
|
||||
Future<ConnectivityResult> get connectionStatus;
|
||||
Future<bool> hasInternetConnection();
|
||||
}
|
||||
|
||||
/// Implementation of NetworkInfo using connectivity_plus package
|
||||
class NetworkInfoImpl implements NetworkInfo {
|
||||
final Connectivity _connectivity;
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
||||
final StreamController<bool> _connectionController = StreamController<bool>.broadcast();
|
||||
|
||||
NetworkInfoImpl(this._connectivity) {
|
||||
_initializeConnectivityStream();
|
||||
}
|
||||
|
||||
void _initializeConnectivityStream() {
|
||||
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(
|
||||
(List<ConnectivityResult> results) {
|
||||
_updateConnectionStatus(results);
|
||||
},
|
||||
);
|
||||
|
||||
// Check initial connectivity status
|
||||
_checkInitialConnectivity();
|
||||
}
|
||||
|
||||
Future<void> _checkInitialConnectivity() async {
|
||||
try {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
_updateConnectionStatus(results);
|
||||
} catch (e) {
|
||||
_connectionController.add(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateConnectionStatus(List<ConnectivityResult> results) async {
|
||||
final hasConnection = _hasConnectionFromResults(results);
|
||||
|
||||
// Double-check with internet connectivity test for reliability
|
||||
if (hasConnection) {
|
||||
final hasInternet = await hasInternetConnection();
|
||||
_connectionController.add(hasInternet);
|
||||
} else {
|
||||
_connectionController.add(false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasConnectionFromResults(List<ConnectivityResult> results) {
|
||||
return results.any((result) =>
|
||||
result != ConnectivityResult.none);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> get isConnected async {
|
||||
try {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
if (!_hasConnectionFromResults(results)) {
|
||||
return false;
|
||||
}
|
||||
return await hasInternetConnection();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<bool> get connectionStream => _connectionController.stream;
|
||||
|
||||
@override
|
||||
Future<ConnectivityResult> get connectionStatus async {
|
||||
try {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
// Return the first non-none result, or none if all are none
|
||||
return results.firstWhere(
|
||||
(result) => result != ConnectivityResult.none,
|
||||
orElse: () => ConnectivityResult.none,
|
||||
);
|
||||
} catch (e) {
|
||||
return ConnectivityResult.none;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasInternetConnection() async {
|
||||
try {
|
||||
// Try to connect to multiple reliable hosts for better reliability
|
||||
final hosts = [
|
||||
'google.com',
|
||||
'cloudflare.com',
|
||||
'8.8.8.8', // Google DNS
|
||||
];
|
||||
|
||||
for (final host in hosts) {
|
||||
try {
|
||||
final result = await InternetAddress.lookup(host).timeout(
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to next host if this one fails
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get detailed connectivity information
|
||||
Future<NetworkConnectionDetails> getConnectionDetails() async {
|
||||
try {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
final hasInternet = await hasInternetConnection();
|
||||
|
||||
return NetworkConnectionDetails(
|
||||
connectivityResults: results,
|
||||
hasInternetConnection: hasInternet,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
} catch (e) {
|
||||
return NetworkConnectionDetails(
|
||||
connectivityResults: [ConnectivityResult.none],
|
||||
hasInternetConnection: false,
|
||||
timestamp: DateTime.now(),
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if connected to WiFi
|
||||
Future<bool> get isConnectedToWiFi async {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
return results.contains(ConnectivityResult.wifi);
|
||||
}
|
||||
|
||||
/// Check if connected to mobile data
|
||||
Future<bool> get isConnectedToMobile async {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
return results.contains(ConnectivityResult.mobile);
|
||||
}
|
||||
|
||||
/// Check if connected to ethernet (mainly for desktop/web)
|
||||
Future<bool> get isConnectedToEthernet async {
|
||||
final results = await _connectivity.checkConnectivity();
|
||||
return results.contains(ConnectivityResult.ethernet);
|
||||
}
|
||||
|
||||
/// Dispose of resources
|
||||
void dispose() {
|
||||
_connectivitySubscription?.cancel();
|
||||
_connectionController.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Detailed network connection information
|
||||
class NetworkConnectionDetails {
|
||||
final List<ConnectivityResult> connectivityResults;
|
||||
final bool hasInternetConnection;
|
||||
final DateTime timestamp;
|
||||
final String? error;
|
||||
|
||||
const NetworkConnectionDetails({
|
||||
required this.connectivityResults,
|
||||
required this.hasInternetConnection,
|
||||
required this.timestamp,
|
||||
this.error,
|
||||
});
|
||||
|
||||
/// Check if any connection is available
|
||||
bool get hasConnection =>
|
||||
connectivityResults.any((result) => result != ConnectivityResult.none);
|
||||
|
||||
/// Get primary connection type
|
||||
ConnectivityResult get primaryConnection {
|
||||
return connectivityResults.firstWhere(
|
||||
(result) => result != ConnectivityResult.none,
|
||||
orElse: () => ConnectivityResult.none,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if connected via WiFi
|
||||
bool get isWiFi => connectivityResults.contains(ConnectivityResult.wifi);
|
||||
|
||||
/// Check if connected via mobile
|
||||
bool get isMobile => connectivityResults.contains(ConnectivityResult.mobile);
|
||||
|
||||
/// Check if connected via ethernet
|
||||
bool get isEthernet => connectivityResults.contains(ConnectivityResult.ethernet);
|
||||
|
||||
/// Get human-readable connection description
|
||||
String get connectionDescription {
|
||||
if (error != null) {
|
||||
return 'Connection error: $error';
|
||||
}
|
||||
|
||||
if (!hasConnection) {
|
||||
return 'No connection';
|
||||
}
|
||||
|
||||
if (!hasInternetConnection) {
|
||||
return 'Connected but no internet access';
|
||||
}
|
||||
|
||||
final types = <String>[];
|
||||
if (isWiFi) types.add('WiFi');
|
||||
if (isMobile) types.add('Mobile');
|
||||
if (isEthernet) types.add('Ethernet');
|
||||
|
||||
return types.isEmpty ? 'Connected' : 'Connected via ${types.join(', ')}';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NetworkConnectionDetails('
|
||||
'results: $connectivityResults, '
|
||||
'hasInternet: $hasInternetConnection, '
|
||||
'timestamp: $timestamp, '
|
||||
'error: $error)';
|
||||
}
|
||||
}
|
||||
360
lib/core/providers/README.md
Normal file
360
lib/core/providers/README.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# Riverpod State Management Setup
|
||||
|
||||
This directory contains a comprehensive Riverpod state management setup following Riverpod 2.x best practices with modern provider patterns.
|
||||
|
||||
## 📁 Directory Structure
|
||||
|
||||
```
|
||||
lib/core/providers/
|
||||
├── providers.dart # Barrel file - single import point
|
||||
├── app_providers.dart # Global app state and initialization
|
||||
├── theme_providers.dart # Theme and UI state management
|
||||
├── storage_providers.dart # Secure storage and Hive management
|
||||
├── network_providers.dart # HTTP clients and API providers
|
||||
├── api_providers.dart # API-specific providers
|
||||
└── provider_usage_example.dart # Usage examples and patterns
|
||||
|
||||
lib/shared/presentation/providers/
|
||||
└── connectivity_providers.dart # Network connectivity monitoring
|
||||
```
|
||||
|
||||
## 🚀 Key Features
|
||||
|
||||
### Modern Riverpod 2.x Patterns
|
||||
- **AsyncNotifierProvider**: For async mutable state management
|
||||
- **NotifierProvider**: For synchronous mutable state management
|
||||
- **StreamProvider**: For reactive data streams
|
||||
- **Provider**: For dependency injection and immutable values
|
||||
- **Code Generation**: Using `@riverpod` annotation for type safety
|
||||
|
||||
### Comprehensive State Management
|
||||
- **App Initialization**: Multi-stage app startup with error handling
|
||||
- **Theme Management**: Dark/light mode with system preference support
|
||||
- **Storage Integration**: Hive + Secure Storage with health monitoring
|
||||
- **Network Connectivity**: Real-time connection status and history
|
||||
- **Feature Flags**: Dynamic feature toggling
|
||||
- **Error Tracking**: Centralized error logging and monitoring
|
||||
|
||||
### Performance Optimized
|
||||
- **State Persistence**: Automatic state saving and restoration
|
||||
- **Efficient Rebuilds**: Minimal widget rebuilds with proper selectors
|
||||
- **Resource Management**: Automatic disposal and cleanup
|
||||
- **Caching Strategy**: Intelligent data caching with expiration
|
||||
|
||||
## 📋 Provider Categories
|
||||
|
||||
### 🏗️ Core Application (`app_providers.dart`)
|
||||
```dart
|
||||
// App initialization with multi-stage loading
|
||||
final initData = ref.watch(appInitializationProvider);
|
||||
|
||||
// Global app state management
|
||||
final globalState = ref.watch(globalAppStateProvider);
|
||||
|
||||
// Feature flags for conditional features
|
||||
final featureFlags = ref.watch(featureFlagsProvider);
|
||||
```
|
||||
|
||||
### 🎨 Theme & UI (`theme_providers.dart`)
|
||||
```dart
|
||||
// Theme mode management with persistence
|
||||
final themeMode = ref.watch(currentThemeModeProvider);
|
||||
await ref.read(appSettingsNotifierProvider.notifier)
|
||||
.updateThemeMode(AppThemeMode.dark);
|
||||
|
||||
// Reactive theme changes
|
||||
final isDark = ref.watch(isDarkModeProvider);
|
||||
```
|
||||
|
||||
### 💾 Storage Management (`storage_providers.dart`)
|
||||
```dart
|
||||
// Secure storage operations
|
||||
final secureNotifier = ref.read(secureStorageNotifierProvider.notifier);
|
||||
await secureNotifier.store('token', 'secure_value');
|
||||
|
||||
// Hive storage with health monitoring
|
||||
final storageHealth = ref.watch(storageHealthMonitorProvider);
|
||||
```
|
||||
|
||||
### 🌐 Network Connectivity (`connectivity_providers.dart`)
|
||||
```dart
|
||||
// Real-time connectivity monitoring
|
||||
final isConnected = ref.watch(isConnectedProvider);
|
||||
final connectionType = ref.watch(connectionTypeProvider);
|
||||
|
||||
// Network history and statistics
|
||||
final networkHistory = ref.watch(networkHistoryNotifierProvider);
|
||||
```
|
||||
|
||||
## 🔧 Usage Patterns
|
||||
|
||||
### 1. Basic Provider Consumption
|
||||
```dart
|
||||
class MyWidget extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settingsAsync = ref.watch(appSettingsNotifierProvider);
|
||||
|
||||
return settingsAsync.when(
|
||||
data: (settings) => Text('Theme: ${settings.themeMode}'),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (error, stack) => Text('Error: $error'),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. State Mutation
|
||||
```dart
|
||||
// Theme change
|
||||
await ref.read(appSettingsNotifierProvider.notifier)
|
||||
.updateThemeMode(AppThemeMode.dark);
|
||||
|
||||
// Feature flag toggle
|
||||
ref.read(featureFlagsProvider.notifier)
|
||||
.toggleFeature('darkMode');
|
||||
|
||||
// Storage operations
|
||||
await ref.read(secureStorageNotifierProvider.notifier)
|
||||
.store('api_key', newApiKey);
|
||||
```
|
||||
|
||||
### 3. Provider Listening
|
||||
```dart
|
||||
// Listen for state changes
|
||||
ref.listen(networkStatusNotifierProvider, (previous, next) {
|
||||
if (next.isConnected && !previous?.isConnected) {
|
||||
// Connection restored
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Connection restored')),
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Combining Providers
|
||||
```dart
|
||||
@riverpod
|
||||
String userStatus(UserStatusRef ref) {
|
||||
final isConnected = ref.watch(isConnectedProvider);
|
||||
final settings = ref.watch(appSettingsNotifierProvider);
|
||||
|
||||
return settings.when(
|
||||
data: (data) => isConnected
|
||||
? 'Online - ${data.locale}'
|
||||
: 'Offline - ${data.locale}',
|
||||
loading: () => 'Loading...',
|
||||
error: (_, __) => 'Error',
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 📱 App Integration
|
||||
|
||||
### 1. Provider Setup
|
||||
```dart
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize Hive before app starts
|
||||
await HiveService.init();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: const MyApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. App Initialization
|
||||
```dart
|
||||
class MyApp extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final initAsync = ref.watch(appInitializationProvider);
|
||||
|
||||
return initAsync.when(
|
||||
data: (data) => data.state == AppInitializationState.initialized
|
||||
? const MainApp()
|
||||
: const SplashScreen(),
|
||||
loading: () => const SplashScreen(),
|
||||
error: (error, stack) => ErrorApp(error: error),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Theme Integration
|
||||
```dart
|
||||
class MainApp extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(effectiveThemeModeProvider);
|
||||
|
||||
return MaterialApp(
|
||||
themeMode: themeMode,
|
||||
theme: ThemeData.light(),
|
||||
darkTheme: ThemeData.dark(),
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 State Persistence
|
||||
|
||||
### Automatic Persistence
|
||||
- **Theme Mode**: Automatically saved to Hive
|
||||
- **User Preferences**: Persisted across app restarts
|
||||
- **Feature Flags**: Maintained in local storage
|
||||
- **Network History**: Cached for analytics
|
||||
|
||||
### Manual Persistence
|
||||
```dart
|
||||
// Save custom app state
|
||||
await ref.read(appSettingsNotifierProvider.notifier)
|
||||
.setCustomSetting('user_preference', value);
|
||||
|
||||
// Restore state on app start
|
||||
final customValue = settings.getCustomSetting<String>('user_preference');
|
||||
```
|
||||
|
||||
## 🚨 Error Handling
|
||||
|
||||
### Provider-Level Error Handling
|
||||
```dart
|
||||
@riverpod
|
||||
class DataNotifier extends _$DataNotifier {
|
||||
@override
|
||||
Future<Data> build() async {
|
||||
try {
|
||||
return await loadData();
|
||||
} catch (error, stackTrace) {
|
||||
// Log error for debugging
|
||||
ref.read(errorTrackerProvider.notifier)
|
||||
.logError(error, stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Global Error Tracking
|
||||
```dart
|
||||
// Access error history
|
||||
final recentErrors = ref.read(errorTrackerProvider.notifier)
|
||||
.getRecentErrors(count: 5);
|
||||
|
||||
// Clear error history
|
||||
ref.read(errorTrackerProvider.notifier).clearErrors();
|
||||
```
|
||||
|
||||
## 🔍 Debugging & Monitoring
|
||||
|
||||
### Provider Inspector
|
||||
```dart
|
||||
// Check provider state in debug mode
|
||||
if (kDebugMode) {
|
||||
final globalState = ref.read(globalAppStateProvider);
|
||||
debugPrint('App State: $globalState');
|
||||
}
|
||||
```
|
||||
|
||||
### Storage Health Monitoring
|
||||
```dart
|
||||
// Perform health check
|
||||
await ref.read(storageHealthMonitorProvider.notifier)
|
||||
.performHealthCheck();
|
||||
|
||||
// Check storage statistics
|
||||
final stats = ref.read(hiveStorageNotifierProvider.notifier)
|
||||
.getStorageStats();
|
||||
```
|
||||
|
||||
## 🛠️ Best Practices
|
||||
|
||||
1. **Import**: Use the barrel file `import '../core/providers/providers.dart'`
|
||||
2. **Error Handling**: Always handle AsyncValue error states
|
||||
3. **Disposal**: Providers auto-dispose, but manual cleanup when needed
|
||||
4. **Performance**: Use `select()` for specific state slices
|
||||
5. **Testing**: Mock providers using `ProviderContainer`
|
||||
6. **State Size**: Keep provider state minimal and focused
|
||||
7. **Provider Names**: Use descriptive names indicating purpose
|
||||
8. **Documentation**: Document complex provider logic
|
||||
|
||||
## 📚 Code Generation
|
||||
|
||||
The providers use Riverpod's code generation. Run this command after making changes:
|
||||
|
||||
```bash
|
||||
dart run build_runner build
|
||||
```
|
||||
|
||||
For continuous generation during development:
|
||||
|
||||
```bash
|
||||
dart run build_runner watch
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Provider Testing
|
||||
```dart
|
||||
test('theme provider updates correctly', () async {
|
||||
final container = ProviderContainer();
|
||||
|
||||
final notifier = container.read(appSettingsNotifierProvider.notifier);
|
||||
await notifier.updateThemeMode(AppThemeMode.dark);
|
||||
|
||||
final settings = container.read(appSettingsNotifierProvider);
|
||||
expect(settings.value?.themeMode, 'dark');
|
||||
|
||||
container.dispose();
|
||||
});
|
||||
```
|
||||
|
||||
### Widget Testing with Providers
|
||||
```dart
|
||||
testWidgets('widget shows correct theme', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
home: MyWidget(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Theme: system'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## 📄 Dependencies
|
||||
|
||||
This setup requires the following dependencies in `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter_riverpod: ^2.5.1
|
||||
riverpod_annotation: ^2.3.5
|
||||
connectivity_plus: ^6.0.5
|
||||
flutter_secure_storage: ^9.2.2
|
||||
hive: ^2.2.3
|
||||
hive_flutter: ^1.1.0
|
||||
|
||||
dev_dependencies:
|
||||
riverpod_generator: ^2.4.0
|
||||
build_runner: ^2.4.7
|
||||
```
|
||||
|
||||
## 🔄 Migration from Provider/Bloc
|
||||
|
||||
If migrating from other state management solutions:
|
||||
|
||||
1. Replace Provider with Riverpod providers
|
||||
2. Convert Bloc to AsyncNotifierProvider
|
||||
3. Update UI to use ConsumerWidget
|
||||
4. Migrate state persistence logic
|
||||
5. Update testing to use ProviderContainer
|
||||
|
||||
This setup provides a solid foundation for scalable Flutter applications with proper state management, error handling, and performance optimization.
|
||||
34
lib/core/providers/api_providers.dart
Normal file
34
lib/core/providers/api_providers.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/api_service.dart';
|
||||
import 'network_providers.dart';
|
||||
|
||||
/// Provider for ExampleApiService
|
||||
final exampleApiServiceProvider = Provider<ExampleApiService>((ref) {
|
||||
final dioClient = ref.watch(dioClientProvider);
|
||||
return ExampleApiService(dioClient);
|
||||
});
|
||||
|
||||
/// Provider for AuthApiService
|
||||
final authApiServiceProvider = Provider<AuthApiService>((ref) {
|
||||
final dioClient = ref.watch(dioClientProvider);
|
||||
return AuthApiService(dioClient);
|
||||
});
|
||||
|
||||
/// Provider to check authentication status
|
||||
final isAuthenticatedProvider = FutureProvider<bool>((ref) async {
|
||||
final authService = ref.watch(authApiServiceProvider);
|
||||
return await authService.isAuthenticated();
|
||||
});
|
||||
|
||||
/// Example provider for user profile data
|
||||
final userProfileProvider = FutureProvider.family<Map<String, dynamic>, String>((ref, userId) async {
|
||||
final apiService = ref.watch(exampleApiServiceProvider);
|
||||
return await apiService.getUserProfile(userId);
|
||||
});
|
||||
|
||||
/// Example provider for posts list with pagination
|
||||
final postsProvider = FutureProvider.family<List<Map<String, dynamic>>, ({int page, int limit})>((ref, params) async {
|
||||
final apiService = ref.watch(exampleApiServiceProvider);
|
||||
return await apiService.getPosts(page: params.page, limit: params.limit);
|
||||
});
|
||||
348
lib/core/providers/app_providers.dart
Normal file
348
lib/core/providers/app_providers.dart
Normal file
@@ -0,0 +1,348 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../database/hive_service.dart';
|
||||
import '../database/models/app_settings.dart';
|
||||
import '../database/providers/database_providers.dart';
|
||||
import '../database/repositories/settings_repository.dart';
|
||||
import '../database/repositories/cache_repository.dart';
|
||||
import '../database/repositories/user_preferences_repository.dart';
|
||||
|
||||
part 'app_providers.g.dart';
|
||||
|
||||
/// App initialization state
|
||||
enum AppInitializationState {
|
||||
uninitialized,
|
||||
initializing,
|
||||
initialized,
|
||||
error,
|
||||
}
|
||||
|
||||
/// App initialization data
|
||||
class AppInitializationData {
|
||||
final AppInitializationState state;
|
||||
final String? error;
|
||||
final DateTime? initializedAt;
|
||||
|
||||
const AppInitializationData({
|
||||
required this.state,
|
||||
this.error,
|
||||
this.initializedAt,
|
||||
});
|
||||
|
||||
AppInitializationData copyWith({
|
||||
AppInitializationState? state,
|
||||
String? error,
|
||||
DateTime? initializedAt,
|
||||
}) {
|
||||
return AppInitializationData(
|
||||
state: state ?? this.state,
|
||||
error: error ?? this.error,
|
||||
initializedAt: initializedAt ?? this.initializedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repository providers
|
||||
@riverpod
|
||||
CacheRepository cacheRepository(CacheRepositoryRef ref) {
|
||||
return CacheRepository();
|
||||
}
|
||||
|
||||
@riverpod
|
||||
UserPreferencesRepository userPreferencesRepository(UserPreferencesRepositoryRef ref) {
|
||||
return UserPreferencesRepository();
|
||||
}
|
||||
|
||||
/// App initialization provider
|
||||
@riverpod
|
||||
class AppInitialization extends _$AppInitialization {
|
||||
@override
|
||||
Future<AppInitializationData> build() async {
|
||||
return _initializeApp();
|
||||
}
|
||||
|
||||
Future<AppInitializationData> _initializeApp() async {
|
||||
try {
|
||||
debugPrint('🚀 Starting app initialization...');
|
||||
|
||||
// Initialize Hive
|
||||
debugPrint('📦 Initializing Hive database...');
|
||||
await HiveService.initialize();
|
||||
|
||||
// Initialize repositories
|
||||
debugPrint('🗂️ Initializing repositories...');
|
||||
final settingsRepo = ref.read(settingsRepositoryProvider);
|
||||
final cacheRepo = ref.read(cacheRepositoryProvider);
|
||||
final userPrefsRepo = ref.read(userPreferencesRepositoryProvider);
|
||||
|
||||
// Load initial settings
|
||||
debugPrint('⚙️ Loading app settings...');
|
||||
final settings = settingsRepo.getSettings();
|
||||
|
||||
// Initialize user preferences if needed
|
||||
debugPrint('👤 Initializing user preferences...');
|
||||
userPrefsRepo.getPreferences();
|
||||
|
||||
// Perform any necessary migrations
|
||||
debugPrint('🔄 Checking for migrations...');
|
||||
await _performMigrations(settingsRepo, settings);
|
||||
|
||||
// Clean up expired cache entries
|
||||
debugPrint('🧹 Cleaning up expired cache...');
|
||||
await cacheRepo.cleanExpiredItems();
|
||||
|
||||
debugPrint('✅ App initialization completed successfully');
|
||||
|
||||
return AppInitializationData(
|
||||
state: AppInitializationState.initialized,
|
||||
initializedAt: DateTime.now(),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('❌ App initialization failed: $error');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
|
||||
return AppInitializationData(
|
||||
state: AppInitializationState.error,
|
||||
error: error.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performMigrations(SettingsRepository repo, AppSettings settings) async {
|
||||
// Add any migration logic here
|
||||
if (settings.version < 1) {
|
||||
debugPrint('🔄 Migrating settings to version 1...');
|
||||
// Migration logic would go here
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry initialization
|
||||
Future<void> retry() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = AsyncValue.data(await _initializeApp());
|
||||
}
|
||||
|
||||
/// Force re-initialization
|
||||
Future<void> reinitialize() async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
// Close all Hive boxes
|
||||
await HiveService.closeAll();
|
||||
|
||||
// Re-initialize everything
|
||||
state = AsyncValue.data(await _initializeApp());
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// App version provider
|
||||
@riverpod
|
||||
String appVersion(AppVersionRef ref) {
|
||||
// This would typically come from package_info_plus
|
||||
return '1.0.0+1';
|
||||
}
|
||||
|
||||
/// App build mode provider
|
||||
@riverpod
|
||||
String appBuildMode(AppBuildModeRef ref) {
|
||||
if (kDebugMode) return 'debug';
|
||||
if (kProfileMode) return 'profile';
|
||||
return 'release';
|
||||
}
|
||||
|
||||
/// App ready state provider
|
||||
@riverpod
|
||||
bool isAppReady(IsAppReadyRef ref) {
|
||||
final initData = ref.watch(appInitializationProvider);
|
||||
|
||||
return initData.when(
|
||||
data: (data) => data.state == AppInitializationState.initialized,
|
||||
loading: () => false,
|
||||
error: (_, __) => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Global app state notifier
|
||||
@riverpod
|
||||
class GlobalAppState extends _$GlobalAppState {
|
||||
@override
|
||||
Map<String, dynamic> build() {
|
||||
final initAsync = ref.watch(appInitializationProvider);
|
||||
final appVersion = ref.watch(appVersionProvider);
|
||||
final buildMode = ref.watch(appBuildModeProvider);
|
||||
|
||||
return initAsync.when(
|
||||
data: (initData) => {
|
||||
'isInitialized': initData.state == AppInitializationState.initialized,
|
||||
'initializationState': initData.state,
|
||||
'initializationError': initData.error,
|
||||
'initializedAt': initData.initializedAt?.toIso8601String(),
|
||||
'appVersion': appVersion,
|
||||
'buildMode': buildMode,
|
||||
'isReady': initData.state == AppInitializationState.initialized,
|
||||
},
|
||||
loading: () => {
|
||||
'isInitialized': false,
|
||||
'initializationState': AppInitializationState.initializing,
|
||||
'initializationError': null,
|
||||
'initializedAt': null,
|
||||
'appVersion': appVersion,
|
||||
'buildMode': buildMode,
|
||||
'isReady': false,
|
||||
},
|
||||
error: (error, _) => {
|
||||
'isInitialized': false,
|
||||
'initializationState': AppInitializationState.error,
|
||||
'initializationError': error.toString(),
|
||||
'initializedAt': null,
|
||||
'appVersion': appVersion,
|
||||
'buildMode': buildMode,
|
||||
'isReady': false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Update global state
|
||||
void updateState(String key, dynamic value) {
|
||||
final currentState = Map<String, dynamic>.from(state);
|
||||
currentState[key] = value;
|
||||
state = currentState;
|
||||
}
|
||||
|
||||
/// Reset global state
|
||||
void reset() {
|
||||
ref.invalidate(appInitializationProvider);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature flags provider
|
||||
@riverpod
|
||||
class FeatureFlags extends _$FeatureFlags {
|
||||
@override
|
||||
Map<String, bool> build() {
|
||||
final buildMode = ref.watch(appBuildModeProvider);
|
||||
|
||||
return {
|
||||
'enableDebugMode': buildMode == 'debug',
|
||||
'enableAnalytics': buildMode == 'release',
|
||||
'enableCrashReporting': buildMode != 'debug',
|
||||
'enablePerformanceMonitoring': true,
|
||||
'enableOfflineMode': true,
|
||||
'enableDarkMode': true,
|
||||
'enableNotifications': true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if feature is enabled
|
||||
bool isEnabled(String feature) {
|
||||
return state[feature] ?? false;
|
||||
}
|
||||
|
||||
/// Enable feature
|
||||
void enableFeature(String feature) {
|
||||
state = {...state, feature: true};
|
||||
}
|
||||
|
||||
/// Disable feature
|
||||
void disableFeature(String feature) {
|
||||
state = {...state, feature: false};
|
||||
}
|
||||
|
||||
/// Toggle feature
|
||||
void toggleFeature(String feature) {
|
||||
final currentValue = state[feature] ?? false;
|
||||
state = {...state, feature: !currentValue};
|
||||
}
|
||||
}
|
||||
|
||||
/// App configuration provider
|
||||
@riverpod
|
||||
class AppConfiguration extends _$AppConfiguration {
|
||||
@override
|
||||
Map<String, dynamic> build() {
|
||||
final buildMode = ref.watch(appBuildModeProvider);
|
||||
|
||||
return {
|
||||
'apiTimeout': 30000, // 30 seconds
|
||||
'cacheTimeout': 3600000, // 1 hour in milliseconds
|
||||
'maxRetries': 3,
|
||||
'retryDelay': 1000, // 1 second
|
||||
'enableLogging': buildMode == 'debug',
|
||||
'logLevel': buildMode == 'debug' ? 'verbose' : 'error',
|
||||
'maxCacheSize': 100 * 1024 * 1024, // 100MB
|
||||
'imageQuality': 85,
|
||||
'compressionEnabled': true,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get configuration value
|
||||
T? getValue<T>(String key) {
|
||||
return state[key] as T?;
|
||||
}
|
||||
|
||||
/// Update configuration
|
||||
void updateConfiguration(String key, dynamic value) {
|
||||
state = {...state, key: value};
|
||||
}
|
||||
|
||||
/// Update multiple configurations
|
||||
void updateConfigurations(Map<String, dynamic> updates) {
|
||||
state = {...state, ...updates};
|
||||
}
|
||||
}
|
||||
|
||||
/// App lifecycle state provider
|
||||
@riverpod
|
||||
class AppLifecycleNotifier extends _$AppLifecycleNotifier {
|
||||
@override
|
||||
String build() {
|
||||
return 'resumed'; // Default state
|
||||
}
|
||||
|
||||
void updateState(String newState) {
|
||||
state = newState;
|
||||
debugPrint('📱 App lifecycle state changed to: $newState');
|
||||
}
|
||||
}
|
||||
|
||||
/// Error tracking provider
|
||||
@riverpod
|
||||
class ErrorTracker extends _$ErrorTracker {
|
||||
@override
|
||||
List<Map<String, dynamic>> build() {
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Log error
|
||||
void logError(dynamic error, StackTrace? stackTrace, {Map<String, dynamic>? context}) {
|
||||
final errorEntry = {
|
||||
'error': error.toString(),
|
||||
'stackTrace': stackTrace?.toString(),
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
'context': context,
|
||||
};
|
||||
|
||||
state = [...state, errorEntry];
|
||||
|
||||
// Keep only last 100 errors to prevent memory issues
|
||||
if (state.length > 100) {
|
||||
state = state.sublist(state.length - 100);
|
||||
}
|
||||
|
||||
debugPrint('🐛 Error logged: $error');
|
||||
}
|
||||
|
||||
/// Clear errors
|
||||
void clearErrors() {
|
||||
state = [];
|
||||
}
|
||||
|
||||
/// Get recent errors
|
||||
List<Map<String, dynamic>> getRecentErrors({int count = 10}) {
|
||||
return state.reversed.take(count).toList();
|
||||
}
|
||||
}
|
||||
200
lib/core/providers/app_providers.g.dart
Normal file
200
lib/core/providers/app_providers.g.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'app_providers.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$cacheRepositoryHash() => r'0137087454bd51e0465886de0eab7acdc124ecb9';
|
||||
|
||||
/// Repository providers
|
||||
///
|
||||
/// Copied from [cacheRepository].
|
||||
@ProviderFor(cacheRepository)
|
||||
final cacheRepositoryProvider = AutoDisposeProvider<CacheRepository>.internal(
|
||||
cacheRepository,
|
||||
name: r'cacheRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$cacheRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef CacheRepositoryRef = AutoDisposeProviderRef<CacheRepository>;
|
||||
String _$userPreferencesRepositoryHash() =>
|
||||
r'0244be191fd7576cbfc90468fe491306ed06d537';
|
||||
|
||||
/// See also [userPreferencesRepository].
|
||||
@ProviderFor(userPreferencesRepository)
|
||||
final userPreferencesRepositoryProvider =
|
||||
AutoDisposeProvider<UserPreferencesRepository>.internal(
|
||||
userPreferencesRepository,
|
||||
name: r'userPreferencesRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$userPreferencesRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef UserPreferencesRepositoryRef
|
||||
= AutoDisposeProviderRef<UserPreferencesRepository>;
|
||||
String _$appVersionHash() => r'2605d9c0fd6d6a24e56caceadbe25b8370fedc4f';
|
||||
|
||||
/// App version provider
|
||||
///
|
||||
/// Copied from [appVersion].
|
||||
@ProviderFor(appVersion)
|
||||
final appVersionProvider = AutoDisposeProvider<String>.internal(
|
||||
appVersion,
|
||||
name: r'appVersionProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$appVersionHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef AppVersionRef = AutoDisposeProviderRef<String>;
|
||||
String _$appBuildModeHash() => r'fa100842dc5c894edb352036f8d887d97618f696';
|
||||
|
||||
/// App build mode provider
|
||||
///
|
||||
/// Copied from [appBuildMode].
|
||||
@ProviderFor(appBuildMode)
|
||||
final appBuildModeProvider = AutoDisposeProvider<String>.internal(
|
||||
appBuildMode,
|
||||
name: r'appBuildModeProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$appBuildModeHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef AppBuildModeRef = AutoDisposeProviderRef<String>;
|
||||
String _$isAppReadyHash() => r'b23a0450aa7bb2c9e3ea07630429118f239e610a';
|
||||
|
||||
/// App ready state provider
|
||||
///
|
||||
/// Copied from [isAppReady].
|
||||
@ProviderFor(isAppReady)
|
||||
final isAppReadyProvider = AutoDisposeProvider<bool>.internal(
|
||||
isAppReady,
|
||||
name: r'isAppReadyProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$isAppReadyHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef IsAppReadyRef = AutoDisposeProviderRef<bool>;
|
||||
String _$appInitializationHash() => r'eb87040a5ee3d20a172bef9221c2c56d7e07fe77';
|
||||
|
||||
/// App initialization provider
|
||||
///
|
||||
/// Copied from [AppInitialization].
|
||||
@ProviderFor(AppInitialization)
|
||||
final appInitializationProvider = AutoDisposeAsyncNotifierProvider<
|
||||
AppInitialization, AppInitializationData>.internal(
|
||||
AppInitialization.new,
|
||||
name: r'appInitializationProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$appInitializationHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AppInitialization = AutoDisposeAsyncNotifier<AppInitializationData>;
|
||||
String _$globalAppStateHash() => r'fd0daa69a2a1dc4aaa3af95a1b148ba1e6de0e3f';
|
||||
|
||||
/// Global app state notifier
|
||||
///
|
||||
/// Copied from [GlobalAppState].
|
||||
@ProviderFor(GlobalAppState)
|
||||
final globalAppStateProvider =
|
||||
AutoDisposeNotifierProvider<GlobalAppState, Map<String, dynamic>>.internal(
|
||||
GlobalAppState.new,
|
||||
name: r'globalAppStateProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$globalAppStateHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$GlobalAppState = AutoDisposeNotifier<Map<String, dynamic>>;
|
||||
String _$featureFlagsHash() => r'747e9d64c73eed5b374f37a8f28eb4b7fc94e53d';
|
||||
|
||||
/// Feature flags provider
|
||||
///
|
||||
/// Copied from [FeatureFlags].
|
||||
@ProviderFor(FeatureFlags)
|
||||
final featureFlagsProvider =
|
||||
AutoDisposeNotifierProvider<FeatureFlags, Map<String, bool>>.internal(
|
||||
FeatureFlags.new,
|
||||
name: r'featureFlagsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$featureFlagsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$FeatureFlags = AutoDisposeNotifier<Map<String, bool>>;
|
||||
String _$appConfigurationHash() => r'115fff1ac67a37ff620bbd15ea142a7211e9dc9c';
|
||||
|
||||
/// App configuration provider
|
||||
///
|
||||
/// Copied from [AppConfiguration].
|
||||
@ProviderFor(AppConfiguration)
|
||||
final appConfigurationProvider = AutoDisposeNotifierProvider<AppConfiguration,
|
||||
Map<String, dynamic>>.internal(
|
||||
AppConfiguration.new,
|
||||
name: r'appConfigurationProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$appConfigurationHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AppConfiguration = AutoDisposeNotifier<Map<String, dynamic>>;
|
||||
String _$appLifecycleNotifierHash() =>
|
||||
r'344a33715910c38bccc596ac0b543e59cb5752a0';
|
||||
|
||||
/// App lifecycle state provider
|
||||
///
|
||||
/// Copied from [AppLifecycleNotifier].
|
||||
@ProviderFor(AppLifecycleNotifier)
|
||||
final appLifecycleNotifierProvider =
|
||||
AutoDisposeNotifierProvider<AppLifecycleNotifier, String>.internal(
|
||||
AppLifecycleNotifier.new,
|
||||
name: r'appLifecycleNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$appLifecycleNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AppLifecycleNotifier = AutoDisposeNotifier<String>;
|
||||
String _$errorTrackerHash() => r'c286897f0ac33b2b619be30d3fd8d18331635b88';
|
||||
|
||||
/// Error tracking provider
|
||||
///
|
||||
/// Copied from [ErrorTracker].
|
||||
@ProviderFor(ErrorTracker)
|
||||
final errorTrackerProvider = AutoDisposeNotifierProvider<ErrorTracker,
|
||||
List<Map<String, dynamic>>>.internal(
|
||||
ErrorTracker.new,
|
||||
name: r'errorTrackerProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$errorTrackerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ErrorTracker = AutoDisposeNotifier<List<Map<String, dynamic>>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
51
lib/core/providers/network_providers.dart
Normal file
51
lib/core/providers/network_providers.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../network/dio_client.dart';
|
||||
import '../network/network_info.dart';
|
||||
|
||||
/// Provider for Connectivity instance
|
||||
final connectivityProvider = Provider<Connectivity>((ref) {
|
||||
return Connectivity();
|
||||
});
|
||||
|
||||
/// Provider for FlutterSecureStorage instance
|
||||
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
||||
return const FlutterSecureStorage();
|
||||
});
|
||||
|
||||
/// Provider for NetworkInfo implementation
|
||||
final networkInfoProvider = Provider<NetworkInfo>((ref) {
|
||||
final connectivity = ref.watch(connectivityProvider);
|
||||
return NetworkInfoImpl(connectivity);
|
||||
});
|
||||
|
||||
/// Provider for DioClient - the main HTTP client
|
||||
final dioClientProvider = Provider<DioClient>((ref) {
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
final secureStorage = ref.watch(secureStorageProvider);
|
||||
|
||||
return DioClient(
|
||||
networkInfo: networkInfo,
|
||||
secureStorage: secureStorage,
|
||||
);
|
||||
});
|
||||
|
||||
/// Provider for network connectivity stream
|
||||
final networkConnectivityProvider = StreamProvider<bool>((ref) {
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
return networkInfo.connectionStream;
|
||||
});
|
||||
|
||||
/// Provider for current network status
|
||||
final isConnectedProvider = FutureProvider<bool>((ref) async {
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
return await networkInfo.isConnected;
|
||||
});
|
||||
|
||||
/// Utility provider to get detailed network connection information
|
||||
final networkConnectionDetailsProvider = FutureProvider((ref) async {
|
||||
final networkInfo = ref.watch(networkInfoProvider) as NetworkInfoImpl;
|
||||
return await networkInfo.getConnectionDetails();
|
||||
});
|
||||
381
lib/core/providers/provider_usage_example.dart
Normal file
381
lib/core/providers/provider_usage_example.dart
Normal file
@@ -0,0 +1,381 @@
|
||||
/// Example usage of the Riverpod state management system
|
||||
///
|
||||
/// This file demonstrates how to properly use the various providers
|
||||
/// created in this comprehensive Riverpod setup following 2.x best practices.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'providers.dart'; // Import the barrel file
|
||||
|
||||
/// Example widget showing theme provider usage
|
||||
class ThemeExampleWidget extends ConsumerWidget {
|
||||
const ThemeExampleWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch theme-related providers
|
||||
final settingsAsync = ref.watch(appSettingsNotifierProvider);
|
||||
final currentTheme = ref.watch(currentThemeModeProvider);
|
||||
final isDark = ref.watch(isDarkModeProvider);
|
||||
|
||||
return settingsAsync.when(
|
||||
data: (settings) => Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('Current Theme'),
|
||||
subtitle: Text(currentTheme.value),
|
||||
trailing: Switch(
|
||||
value: isDark,
|
||||
onChanged: (value) {
|
||||
// Update theme mode
|
||||
final notifier = ref.read(appSettingsNotifierProvider.notifier);
|
||||
notifier.updateThemeMode(
|
||||
value ? AppThemeMode.dark : AppThemeMode.light,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Notifications'),
|
||||
trailing: Switch(
|
||||
value: settings.notificationsEnabled,
|
||||
onChanged: (value) {
|
||||
final notifier = ref.read(appSettingsNotifierProvider.notifier);
|
||||
notifier.updateNotificationsEnabled(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading: () => const Card(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stack) => Card(
|
||||
child: ListTile(
|
||||
title: const Text('Error loading settings'),
|
||||
subtitle: Text(error.toString()),
|
||||
leading: const Icon(Icons.error),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Example widget showing connectivity provider usage
|
||||
class ConnectivityExampleWidget extends ConsumerWidget {
|
||||
const ConnectivityExampleWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch connectivity providers
|
||||
final isConnected = ref.watch(isConnectedProvider);
|
||||
final connectionType = ref.watch(connectionTypeProvider);
|
||||
final networkQuality = ref.watch(networkQualityProvider);
|
||||
final isWifi = ref.watch(isWifiConnectedProvider);
|
||||
|
||||
return Card(
|
||||
color: isConnected == true ? Colors.green.shade50 : Colors.red.shade50,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isConnected == true ? Icons.wifi : Icons.wifi_off,
|
||||
color: isConnected == true ? Colors.green : Colors.red,
|
||||
),
|
||||
title: Text(isConnected == true ? 'Connected' : 'Disconnected'),
|
||||
subtitle: Text('Connection: ${connectionType.toString().split('.').last}'),
|
||||
),
|
||||
if (isConnected == true) ...[
|
||||
ListTile(
|
||||
title: const Text('Network Quality'),
|
||||
subtitle: Text(networkQuality),
|
||||
),
|
||||
if (isWifi == true)
|
||||
const ListTile(
|
||||
leading: Icon(Icons.wifi, color: Colors.blue),
|
||||
title: Text('Using Wi-Fi'),
|
||||
),
|
||||
],
|
||||
ListTile(
|
||||
title: const Text('Refresh Network Status'),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.read(networkStatusNotifierProvider.notifier).refresh();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Example widget showing app initialization and global state
|
||||
class AppStateExampleWidget extends ConsumerWidget {
|
||||
const AppStateExampleWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch app state providers
|
||||
final initAsync = ref.watch(appInitializationProvider);
|
||||
final globalState = ref.watch(globalAppStateProvider);
|
||||
final isReady = ref.watch(isAppReadyProvider);
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('App Status'),
|
||||
subtitle: Text(isReady == true ? 'Ready' : 'Initializing...'),
|
||||
leading: Icon(
|
||||
isReady == true ? Icons.check_circle : Icons.hourglass_empty,
|
||||
color: isReady == true ? Colors.green : Colors.orange,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('App Version'),
|
||||
subtitle: Text(globalState['appVersion'] ?? 'Unknown'),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Build Mode'),
|
||||
subtitle: Text(globalState['buildMode'] ?? 'Unknown'),
|
||||
),
|
||||
initAsync.when(
|
||||
data: (data) => ListTile(
|
||||
title: const Text('Initialization Status'),
|
||||
subtitle: Text(data.state.toString().split('.').last),
|
||||
trailing: data.state == AppInitializationState.error
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.read(appInitializationProvider.notifier).retry();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
loading: () => const ListTile(
|
||||
title: Text('Initialization Status'),
|
||||
subtitle: Text('Loading...'),
|
||||
trailing: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => ListTile(
|
||||
title: const Text('Initialization Error'),
|
||||
subtitle: Text(error.toString()),
|
||||
leading: const Icon(Icons.error, color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Example widget showing storage provider usage
|
||||
class StorageExampleWidget extends ConsumerWidget {
|
||||
const StorageExampleWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch storage providers
|
||||
final storageManager = ref.watch(storageManagerProvider);
|
||||
|
||||
final hiveStats = storageManager['hive'] as Map<String, dynamic>? ?? {};
|
||||
final secureStorageInfo = storageManager['secureStorage'] as Map<String, dynamic>? ?? {};
|
||||
final healthInfo = storageManager['health'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('Storage Health'),
|
||||
subtitle: Text(healthInfo['isHealthy'] == true ? 'Healthy' : 'Issues detected'),
|
||||
leading: Icon(
|
||||
healthInfo['isHealthy'] == true ? Icons.check_circle : Icons.warning,
|
||||
color: healthInfo['isHealthy'] == true ? Colors.green : Colors.orange,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Hive Storage'),
|
||||
subtitle: Text('${hiveStats['appSettingsCount'] ?? 0} settings, ${hiveStats['cacheItemsCount'] ?? 0} cache items'),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Secure Storage'),
|
||||
subtitle: Text('${secureStorageInfo['keyCount'] ?? 0} secure keys stored'),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(storageManagerProvider.notifier).performMaintenance();
|
||||
},
|
||||
child: const Text('Maintenance'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(storageHealthMonitorProvider.notifier).performHealthCheck();
|
||||
},
|
||||
child: const Text('Health Check'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Example widget showing feature flags usage
|
||||
class FeatureFlagsExampleWidget extends ConsumerWidget {
|
||||
const FeatureFlagsExampleWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final featureFlags = ref.watch(featureFlagsProvider);
|
||||
final flagsNotifier = ref.watch(featureFlagsProvider.notifier);
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
children: [
|
||||
const ListTile(
|
||||
title: Text('Feature Flags'),
|
||||
leading: Icon(Icons.flag),
|
||||
),
|
||||
...featureFlags.entries.map((entry) => ListTile(
|
||||
title: Text(entry.key),
|
||||
trailing: Switch(
|
||||
value: entry.value,
|
||||
onChanged: (value) {
|
||||
if (value) {
|
||||
flagsNotifier.enableFeature(entry.key);
|
||||
} else {
|
||||
flagsNotifier.disableFeature(entry.key);
|
||||
}
|
||||
},
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Example screen demonstrating all provider usage
|
||||
class ProviderDemoScreen extends ConsumerWidget {
|
||||
const ProviderDemoScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Provider Demo'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
// Refresh all providers
|
||||
ref.invalidate(appSettingsNotifierProvider);
|
||||
ref.invalidate(networkStatusNotifierProvider);
|
||||
ref.read(globalAppStateProvider.notifier).reset();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: const SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
AppStateExampleWidget(),
|
||||
SizedBox(height: 16),
|
||||
ThemeExampleWidget(),
|
||||
SizedBox(height: 16),
|
||||
ConnectivityExampleWidget(),
|
||||
SizedBox(height: 16),
|
||||
StorageExampleWidget(),
|
||||
SizedBox(height: 16),
|
||||
FeatureFlagsExampleWidget(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Example of using providers in a lifecycle-aware way
|
||||
class ProviderLifecycleExample extends ConsumerStatefulWidget {
|
||||
const ProviderLifecycleExample({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProviderLifecycleExample> createState() => _ProviderLifecycleExampleState();
|
||||
}
|
||||
|
||||
class _ProviderLifecycleExampleState extends ConsumerState<ProviderLifecycleExample>
|
||||
with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// Listen to app initialization
|
||||
ref.listenManual(appInitializationProvider, (previous, next) {
|
||||
next.when(
|
||||
data: (data) {
|
||||
if (data.state == AppInitializationState.initialized) {
|
||||
debugPrint('✅ App initialized successfully');
|
||||
}
|
||||
},
|
||||
loading: () => debugPrint('🔄 App initializing...'),
|
||||
error: (error, stack) => debugPrint('❌ App initialization failed: $error'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
super.didChangeAppLifecycleState(state);
|
||||
|
||||
// Update app lifecycle state provider
|
||||
ref.read(appLifecycleNotifierProvider.notifier).updateState(state.name);
|
||||
|
||||
// Handle different lifecycle states
|
||||
switch (state) {
|
||||
case AppLifecycleState.paused:
|
||||
// App is paused, save important state
|
||||
ref.read(storageManagerProvider.notifier).performMaintenance();
|
||||
break;
|
||||
case AppLifecycleState.resumed:
|
||||
// App is resumed, refresh network status
|
||||
ref.read(networkStatusNotifierProvider.notifier).refresh();
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
// App is detached, cleanup if needed
|
||||
break;
|
||||
case AppLifecycleState.inactive:
|
||||
case AppLifecycleState.hidden:
|
||||
// Handle inactive/hidden states
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This would be your main app content
|
||||
return const ProviderDemoScreen();
|
||||
}
|
||||
}
|
||||
267
lib/core/providers/providers.dart
Normal file
267
lib/core/providers/providers.dart
Normal file
@@ -0,0 +1,267 @@
|
||||
/// Barrel file for all providers - central export point for dependency injection
|
||||
///
|
||||
/// This file exports all provider files to provide a single import point
|
||||
/// for accessing all application providers following clean architecture principles.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// import '../core/providers/providers.dart';
|
||||
///
|
||||
/// // Access any provider from the exported modules
|
||||
/// final theme = ref.watch(currentThemeModeProvider);
|
||||
/// final isConnected = ref.watch(isConnectedProvider);
|
||||
/// ```
|
||||
|
||||
// Core application providers
|
||||
export 'app_providers.dart';
|
||||
|
||||
// Network and API providers
|
||||
export 'network_providers.dart';
|
||||
export 'api_providers.dart';
|
||||
|
||||
// Theme and UI state providers
|
||||
export 'theme_providers.dart';
|
||||
|
||||
// Storage and persistence providers
|
||||
export 'storage_providers.dart' hide secureStorageProvider;
|
||||
|
||||
// Shared connectivity providers
|
||||
export '../../../shared/presentation/providers/connectivity_providers.dart' hide connectivityProvider, isConnectedProvider;
|
||||
|
||||
/// Provider initialization helper
|
||||
///
|
||||
/// This class provides utilities for initializing and managing providers
|
||||
/// across the application lifecycle.
|
||||
class ProviderInitializer {
|
||||
const ProviderInitializer._();
|
||||
|
||||
/// List of providers that need to be initialized early in the app lifecycle
|
||||
/// These providers will be warmed up during app initialization to ensure
|
||||
/// they're ready when needed.
|
||||
static const List<String> criticalProviders = [
|
||||
'appInitializationProvider',
|
||||
'appSettingsNotifierProvider',
|
||||
'networkStatusNotifierProvider',
|
||||
'secureStorageNotifierProvider',
|
||||
];
|
||||
|
||||
/// List of providers that can be initialized lazily when first accessed
|
||||
static const List<String> lazyProviders = [
|
||||
'featureFlagsProvider',
|
||||
'appConfigurationProvider',
|
||||
'networkHistoryNotifierProvider',
|
||||
'errorTrackerProvider',
|
||||
];
|
||||
|
||||
/// Provider categories for better organization and management
|
||||
static const Map<String, List<String>> providerCategories = {
|
||||
'core': [
|
||||
'appInitializationProvider',
|
||||
'globalAppStateProvider',
|
||||
'appVersionProvider',
|
||||
'appBuildModeProvider',
|
||||
],
|
||||
'theme': [
|
||||
'appSettingsNotifierProvider',
|
||||
'currentThemeModeProvider',
|
||||
'effectiveThemeModeProvider',
|
||||
'isDarkModeProvider',
|
||||
'currentLocaleProvider',
|
||||
],
|
||||
'storage': [
|
||||
'secureStorageNotifierProvider',
|
||||
'hiveStorageNotifierProvider',
|
||||
'storageHealthMonitorProvider',
|
||||
'storageManagerProvider',
|
||||
],
|
||||
'network': [
|
||||
'networkStatusNotifierProvider',
|
||||
'networkConnectivityStreamProvider',
|
||||
'isConnectedProvider',
|
||||
'connectionTypeProvider',
|
||||
'networkQualityProvider',
|
||||
],
|
||||
'utilities': [
|
||||
'featureFlagsProvider',
|
||||
'appConfigurationProvider',
|
||||
'errorTrackerProvider',
|
||||
'appLifecycleNotifierProvider',
|
||||
],
|
||||
};
|
||||
|
||||
/// Get providers by category
|
||||
static List<String> getProvidersByCategory(String category) {
|
||||
return providerCategories[category] ?? [];
|
||||
}
|
||||
|
||||
/// Get all provider names
|
||||
static List<String> getAllProviders() {
|
||||
return providerCategories.values.expand((providers) => providers).toList();
|
||||
}
|
||||
|
||||
/// Check if provider is critical (needs early initialization)
|
||||
static bool isCriticalProvider(String providerName) {
|
||||
return criticalProviders.contains(providerName);
|
||||
}
|
||||
|
||||
/// Check if provider can be loaded lazily
|
||||
static bool isLazyProvider(String providerName) {
|
||||
return lazyProviders.contains(providerName);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider documentation and usage guidelines
|
||||
///
|
||||
/// This class contains documentation for proper provider usage patterns
|
||||
/// following Riverpod 2.x best practices.
|
||||
class ProviderDocumentation {
|
||||
const ProviderDocumentation._();
|
||||
|
||||
/// Usage examples for different provider types
|
||||
static const Map<String, String> usageExamples = {
|
||||
'AsyncNotifierProvider': '''
|
||||
// For async mutable state management
|
||||
final notifier = ref.watch(appSettingsNotifierProvider.notifier);
|
||||
await notifier.updateThemeMode(AppThemeMode.dark);
|
||||
|
||||
// Watch for state changes
|
||||
ref.listen(appSettingsNotifierProvider, (previous, next) {
|
||||
next.when(
|
||||
data: (settings) => print('Settings updated'),
|
||||
loading: () => print('Loading...'),
|
||||
error: (error, stack) => print('Error: \$error'),
|
||||
);
|
||||
});
|
||||
''',
|
||||
|
||||
'NotifierProvider': '''
|
||||
// For synchronous mutable state
|
||||
final flags = ref.watch(featureFlagsProvider);
|
||||
final notifier = ref.watch(featureFlagsProvider.notifier);
|
||||
|
||||
// Update state
|
||||
notifier.enableFeature('darkMode');
|
||||
notifier.toggleFeature('analytics');
|
||||
''',
|
||||
|
||||
'StreamProvider': '''
|
||||
// For reactive data streams
|
||||
final connectivityStream = ref.watch(networkConnectivityStreamProvider);
|
||||
|
||||
connectivityStream.when(
|
||||
data: (status) => Text('Connected: \${status.isConnected}'),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (error, stack) => Text('Error: \$error'),
|
||||
);
|
||||
''',
|
||||
|
||||
'Provider': '''
|
||||
// For dependency injection and immutable values
|
||||
final storage = ref.watch(secureStorageProvider);
|
||||
final connectivity = ref.watch(connectivityProvider);
|
||||
|
||||
// Use in other providers
|
||||
@riverpod
|
||||
MyService myService(MyServiceRef ref) {
|
||||
final storage = ref.watch(secureStorageProvider);
|
||||
return MyService(storage);
|
||||
}
|
||||
''',
|
||||
|
||||
'FutureProvider': '''
|
||||
// For async operations (read-only)
|
||||
final initData = ref.watch(appInitializationProvider);
|
||||
|
||||
initData.when(
|
||||
data: (data) => data.state == AppInitializationState.initialized
|
||||
? const HomeScreen()
|
||||
: const LoadingScreen(),
|
||||
loading: () => const SplashScreen(),
|
||||
error: (error, stack) => ErrorScreen(error: error),
|
||||
);
|
||||
''',
|
||||
};
|
||||
|
||||
/// Best practices for provider usage
|
||||
static const List<String> bestPractices = [
|
||||
'1. Use @riverpod annotation for new providers (Riverpod 2.x)',
|
||||
'2. Prefer AsyncNotifierProvider for mutable async state',
|
||||
'3. Use NotifierProvider for mutable synchronous state',
|
||||
'4. Use StreamProvider for reactive data streams',
|
||||
'5. Use Provider for dependency injection and immutable values',
|
||||
'6. Always handle error states in AsyncValue.when()',
|
||||
'7. Use ref.invalidate() to refresh provider state',
|
||||
'8. Implement proper disposal in ref.onDispose()',
|
||||
'9. Keep providers focused on a single responsibility',
|
||||
'10. Use meaningful provider names that indicate their purpose',
|
||||
];
|
||||
|
||||
/// Common patterns and solutions
|
||||
static const Map<String, String> commonPatterns = {
|
||||
'Combining Providers': '''
|
||||
@riverpod
|
||||
String userDisplayName(UserDisplayNameRef ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final settings = ref.watch(userSettingsProvider);
|
||||
|
||||
return settings.showFullName
|
||||
? '\${user.firstName} \${user.lastName}'
|
||||
: user.username;
|
||||
}
|
||||
''',
|
||||
|
||||
'Error Handling': '''
|
||||
@riverpod
|
||||
class DataNotifier extends _\$DataNotifier {
|
||||
@override
|
||||
Future<Data> build() async {
|
||||
return await _loadData();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
final data = await _loadData();
|
||||
state = AsyncValue.data(data);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
// Log error for debugging
|
||||
ref.read(errorTrackerProvider.notifier).logError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
''',
|
||||
|
||||
'State Persistence': '''
|
||||
@riverpod
|
||||
class PersistentCounter extends _\$PersistentCounter {
|
||||
@override
|
||||
int build() {
|
||||
// Load from storage on build
|
||||
final storage = ref.watch(storageProvider);
|
||||
return storage.getInt('counter') ?? 0;
|
||||
}
|
||||
|
||||
void increment() {
|
||||
state = state + 1;
|
||||
// Persist state changes
|
||||
final storage = ref.read(storageProvider);
|
||||
storage.setInt('counter', state);
|
||||
}
|
||||
}
|
||||
''',
|
||||
};
|
||||
|
||||
/// Performance tips
|
||||
static const List<String> performanceTips = [
|
||||
'1. Use select() to watch specific parts of complex state',
|
||||
'2. Avoid creating providers in build methods',
|
||||
'3. Use autoDispose for temporary providers',
|
||||
'4. Keep provider state minimal and focused',
|
||||
'5. Use family providers for parameterized providers',
|
||||
'6. Implement proper caching for expensive operations',
|
||||
'7. Dispose of resources in ref.onDispose()',
|
||||
'8. Use keepAlive() for providers that should survive rebuilds',
|
||||
];
|
||||
}
|
||||
373
lib/core/providers/storage_providers.dart
Normal file
373
lib/core/providers/storage_providers.dart
Normal file
@@ -0,0 +1,373 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../database/hive_service.dart';
|
||||
import '../database/models/app_settings.dart';
|
||||
import '../database/models/cache_item.dart';
|
||||
import '../database/models/user_preferences.dart';
|
||||
|
||||
part 'storage_providers.g.dart';
|
||||
|
||||
/// Secure storage configuration
|
||||
const _secureStorageOptions = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
),
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
);
|
||||
|
||||
/// Secure storage provider
|
||||
@riverpod
|
||||
FlutterSecureStorage secureStorage(SecureStorageRef ref) {
|
||||
return _secureStorageOptions;
|
||||
}
|
||||
|
||||
/// Secure storage notifier for managing secure data
|
||||
@riverpod
|
||||
class SecureStorageNotifier extends _$SecureStorageNotifier {
|
||||
late FlutterSecureStorage _storage;
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> build() async {
|
||||
_storage = ref.read(secureStorageProvider);
|
||||
return await _loadAllSecureData();
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _loadAllSecureData() async {
|
||||
try {
|
||||
final allData = await _storage.readAll();
|
||||
return allData;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error loading secure storage data: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Store secure value
|
||||
Future<void> store(String key, String value) async {
|
||||
try {
|
||||
await _storage.write(key: key, value: value);
|
||||
state = AsyncValue.data({...state.value ?? {}, key: value});
|
||||
debugPrint('🔐 Securely stored: $key');
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('❌ Error storing secure value: $error');
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve secure value
|
||||
Future<String?> retrieve(String key) async {
|
||||
try {
|
||||
return await _storage.read(key: key);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error retrieving secure value: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete secure value
|
||||
Future<void> delete(String key) async {
|
||||
try {
|
||||
await _storage.delete(key: key);
|
||||
final currentData = Map<String, String>.from(state.value ?? {});
|
||||
currentData.remove(key);
|
||||
state = AsyncValue.data(currentData);
|
||||
debugPrint('🗑️ Deleted secure key: $key');
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('❌ Error deleting secure value: $error');
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all secure storage
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
await _storage.deleteAll();
|
||||
state = const AsyncValue.data({});
|
||||
debugPrint('🧹 Cleared all secure storage');
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint('❌ Error clearing secure storage: $error');
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if key exists
|
||||
Future<bool> containsKey(String key) async {
|
||||
try {
|
||||
return await _storage.containsKey(key: key);
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error checking key existence: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh storage data
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = AsyncValue.data(await _loadAllSecureData());
|
||||
}
|
||||
}
|
||||
|
||||
/// Hive storage providers
|
||||
@riverpod
|
||||
Box<AppSettings> appSettingsBox(AppSettingsBoxRef ref) {
|
||||
return HiveService.appSettingsBox;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Box<CacheItem> cacheBox(CacheBoxRef ref) {
|
||||
return HiveService.cacheBox;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Box<UserPreferences> userPreferencesBox(UserPreferencesBoxRef ref) {
|
||||
return HiveService.userDataBox;
|
||||
}
|
||||
|
||||
/// Hive storage notifier for managing Hive data
|
||||
@riverpod
|
||||
class HiveStorageNotifier extends _$HiveStorageNotifier {
|
||||
@override
|
||||
Map<String, dynamic> build() {
|
||||
final appSettingsBox = ref.watch(appSettingsBoxProvider);
|
||||
final cacheBox = ref.watch(cacheBoxProvider);
|
||||
final userPreferencesBox = ref.watch(userPreferencesBoxProvider);
|
||||
|
||||
return {
|
||||
'appSettingsCount': appSettingsBox.length,
|
||||
'cacheItemsCount': cacheBox.length,
|
||||
'userPreferencesCount': userPreferencesBox.length,
|
||||
'totalSize': _calculateTotalSize(),
|
||||
'lastUpdated': DateTime.now().toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
int _calculateTotalSize() {
|
||||
try {
|
||||
final appSettingsBox = ref.read(appSettingsBoxProvider);
|
||||
final cacheBox = ref.read(cacheBoxProvider);
|
||||
final userPreferencesBox = ref.read(userPreferencesBoxProvider);
|
||||
|
||||
// Rough estimation of storage size
|
||||
return appSettingsBox.length + cacheBox.length + userPreferencesBox.length;
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error calculating storage size: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact all Hive boxes
|
||||
Future<void> compactAll() async {
|
||||
try {
|
||||
final appSettingsBox = ref.read(appSettingsBoxProvider);
|
||||
final cacheBox = ref.read(cacheBoxProvider);
|
||||
final userPreferencesBox = ref.read(userPreferencesBoxProvider);
|
||||
|
||||
await Future.wait([
|
||||
appSettingsBox.compact(),
|
||||
cacheBox.compact(),
|
||||
userPreferencesBox.compact(),
|
||||
]);
|
||||
|
||||
_updateStats();
|
||||
debugPrint('🗜️ Compacted all Hive storage');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error compacting storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all cache data
|
||||
Future<void> clearCache() async {
|
||||
try {
|
||||
final cacheBox = ref.read(cacheBoxProvider);
|
||||
await cacheBox.clear();
|
||||
|
||||
_updateStats();
|
||||
debugPrint('🧹 Cleared all cache data');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error clearing cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Map<String, dynamic> getStorageStats() {
|
||||
try {
|
||||
final appSettingsBox = ref.read(appSettingsBoxProvider);
|
||||
final cacheBox = ref.read(cacheBoxProvider);
|
||||
final userPreferencesBox = ref.read(userPreferencesBoxProvider);
|
||||
|
||||
return {
|
||||
'appSettings': {
|
||||
'count': appSettingsBox.length,
|
||||
'keys': appSettingsBox.keys.toList(),
|
||||
'isEmpty': appSettingsBox.isEmpty,
|
||||
},
|
||||
'cache': {
|
||||
'count': cacheBox.length,
|
||||
'keys': cacheBox.keys.take(10).toList(), // Show only first 10 keys
|
||||
'isEmpty': cacheBox.isEmpty,
|
||||
},
|
||||
'userPreferences': {
|
||||
'count': userPreferencesBox.length,
|
||||
'keys': userPreferencesBox.keys.toList(),
|
||||
'isEmpty': userPreferencesBox.isEmpty,
|
||||
},
|
||||
'total': {
|
||||
'items': appSettingsBox.length + cacheBox.length + userPreferencesBox.length,
|
||||
'estimatedSize': _calculateTotalSize(),
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error getting storage stats: $e');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStats() {
|
||||
state = {
|
||||
...state,
|
||||
'appSettingsCount': ref.read(appSettingsBoxProvider).length,
|
||||
'cacheItemsCount': ref.read(cacheBoxProvider).length,
|
||||
'userPreferencesCount': ref.read(userPreferencesBoxProvider).length,
|
||||
'totalSize': _calculateTotalSize(),
|
||||
'lastUpdated': DateTime.now().toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage health monitor
|
||||
@riverpod
|
||||
class StorageHealthMonitor extends _$StorageHealthMonitor {
|
||||
@override
|
||||
Map<String, dynamic> build() {
|
||||
return {
|
||||
'isHealthy': true,
|
||||
'lastCheck': DateTime.now().toIso8601String(),
|
||||
'errors': <String>[],
|
||||
'warnings': <String>[],
|
||||
};
|
||||
}
|
||||
|
||||
/// Perform storage health check
|
||||
Future<void> performHealthCheck() async {
|
||||
final errors = <String>[];
|
||||
final warnings = <String>[];
|
||||
|
||||
try {
|
||||
// Check secure storage
|
||||
final secureStorage = ref.read(secureStorageProvider);
|
||||
try {
|
||||
await secureStorage.containsKey(key: 'health_check');
|
||||
} catch (e) {
|
||||
errors.add('Secure storage error: $e');
|
||||
}
|
||||
|
||||
// Check Hive boxes
|
||||
try {
|
||||
final appSettingsBox = ref.read(appSettingsBoxProvider);
|
||||
final cacheBox = ref.read(cacheBoxProvider);
|
||||
final userPreferencesBox = ref.read(userPreferencesBoxProvider);
|
||||
|
||||
if (!appSettingsBox.isOpen) errors.add('App settings box is not open');
|
||||
if (!cacheBox.isOpen) errors.add('Cache box is not open');
|
||||
if (!userPreferencesBox.isOpen) errors.add('User preferences box is not open');
|
||||
|
||||
// Check for large cache
|
||||
if (cacheBox.length > 1000) {
|
||||
warnings.add('Cache has more than 1000 items, consider cleanup');
|
||||
}
|
||||
} catch (e) {
|
||||
errors.add('Hive storage error: $e');
|
||||
}
|
||||
|
||||
state = {
|
||||
'isHealthy': errors.isEmpty,
|
||||
'lastCheck': DateTime.now().toIso8601String(),
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
};
|
||||
|
||||
debugPrint('🏥 Storage health check completed: ${errors.isEmpty ? '✅ Healthy' : '❌ Issues found'}');
|
||||
} catch (e) {
|
||||
state = {
|
||||
'isHealthy': false,
|
||||
'lastCheck': DateTime.now().toIso8601String(),
|
||||
'errors': ['Health check failed: $e'],
|
||||
'warnings': warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Get health status
|
||||
bool get isHealthy => state['isHealthy'] ?? false;
|
||||
|
||||
/// Get errors
|
||||
List<String> get errors => List<String>.from(state['errors'] ?? []);
|
||||
|
||||
/// Get warnings
|
||||
List<String> get warnings => List<String>.from(state['warnings'] ?? []);
|
||||
}
|
||||
|
||||
/// Unified storage manager
|
||||
@riverpod
|
||||
class StorageManager extends _$StorageManager {
|
||||
@override
|
||||
Map<String, dynamic> build() {
|
||||
final hiveStats = ref.watch(hiveStorageNotifierProvider);
|
||||
final secureStorageAsync = ref.watch(secureStorageNotifierProvider);
|
||||
final healthStatus = ref.watch(storageHealthMonitorProvider);
|
||||
|
||||
return {
|
||||
'hive': hiveStats,
|
||||
'secureStorage': secureStorageAsync.when(
|
||||
data: (data) => {
|
||||
'keyCount': data.length,
|
||||
'isAvailable': true,
|
||||
},
|
||||
loading: () => {'isAvailable': false, 'isLoading': true},
|
||||
error: (_, __) => {'isAvailable': false, 'hasError': true},
|
||||
),
|
||||
'health': healthStatus,
|
||||
'lastUpdated': DateTime.now().toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Clear all storage
|
||||
Future<void> clearAllStorage() async {
|
||||
try {
|
||||
// Clear secure storage
|
||||
await ref.read(secureStorageNotifierProvider.notifier).clearAll();
|
||||
|
||||
// Clear Hive storage
|
||||
await ref.read(hiveStorageNotifierProvider.notifier).clearCache();
|
||||
|
||||
debugPrint('🧹 Cleared all storage');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error clearing all storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform maintenance
|
||||
Future<void> performMaintenance() async {
|
||||
try {
|
||||
// Compact storage
|
||||
await ref.read(hiveStorageNotifierProvider.notifier).compactAll();
|
||||
|
||||
// Perform health check
|
||||
await ref.read(storageHealthMonitorProvider.notifier).performHealthCheck();
|
||||
|
||||
debugPrint('🔧 Storage maintenance completed');
|
||||
} catch (e) {
|
||||
debugPrint('❌ Error during maintenance: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage overview
|
||||
Map<String, dynamic> getStorageOverview() {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
151
lib/core/providers/storage_providers.g.dart
Normal file
151
lib/core/providers/storage_providers.g.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'storage_providers.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$secureStorageHash() => r'9cd02a4033a37568df4c1778f34709abb5853782';
|
||||
|
||||
/// Secure storage provider
|
||||
///
|
||||
/// Copied from [secureStorage].
|
||||
@ProviderFor(secureStorage)
|
||||
final secureStorageProvider =
|
||||
AutoDisposeProvider<FlutterSecureStorage>.internal(
|
||||
secureStorage,
|
||||
name: r'secureStorageProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$secureStorageHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef SecureStorageRef = AutoDisposeProviderRef<FlutterSecureStorage>;
|
||||
String _$appSettingsBoxHash() => r'9e348c0084f7f23850f09adb2e6496fdbf8f2bdf';
|
||||
|
||||
/// Hive storage providers
|
||||
///
|
||||
/// Copied from [appSettingsBox].
|
||||
@ProviderFor(appSettingsBox)
|
||||
final appSettingsBoxProvider = AutoDisposeProvider<Box<AppSettings>>.internal(
|
||||
appSettingsBox,
|
||||
name: r'appSettingsBoxProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$appSettingsBoxHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef AppSettingsBoxRef = AutoDisposeProviderRef<Box<AppSettings>>;
|
||||
String _$cacheBoxHash() => r'949b55a2b7423b7fa7182b8e45adf02367ab8c7c';
|
||||
|
||||
/// See also [cacheBox].
|
||||
@ProviderFor(cacheBox)
|
||||
final cacheBoxProvider = AutoDisposeProvider<Box<CacheItem>>.internal(
|
||||
cacheBox,
|
||||
name: r'cacheBoxProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$cacheBoxHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef CacheBoxRef = AutoDisposeProviderRef<Box<CacheItem>>;
|
||||
String _$userPreferencesBoxHash() =>
|
||||
r'38e2eab12afb00cca5ad2f48bf1f9ec76cc962c8';
|
||||
|
||||
/// See also [userPreferencesBox].
|
||||
@ProviderFor(userPreferencesBox)
|
||||
final userPreferencesBoxProvider =
|
||||
AutoDisposeProvider<Box<UserPreferences>>.internal(
|
||||
userPreferencesBox,
|
||||
name: r'userPreferencesBoxProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$userPreferencesBoxHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef UserPreferencesBoxRef = AutoDisposeProviderRef<Box<UserPreferences>>;
|
||||
String _$secureStorageNotifierHash() =>
|
||||
r'08d6cb392865d7483027fde37192c07cb944c45f';
|
||||
|
||||
/// Secure storage notifier for managing secure data
|
||||
///
|
||||
/// Copied from [SecureStorageNotifier].
|
||||
@ProviderFor(SecureStorageNotifier)
|
||||
final secureStorageNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
||||
SecureStorageNotifier, Map<String, String>>.internal(
|
||||
SecureStorageNotifier.new,
|
||||
name: r'secureStorageNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$secureStorageNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$SecureStorageNotifier = AutoDisposeAsyncNotifier<Map<String, String>>;
|
||||
String _$hiveStorageNotifierHash() =>
|
||||
r'5d91bf162282fcfbef13aa7296255bb87640af51';
|
||||
|
||||
/// Hive storage notifier for managing Hive data
|
||||
///
|
||||
/// Copied from [HiveStorageNotifier].
|
||||
@ProviderFor(HiveStorageNotifier)
|
||||
final hiveStorageNotifierProvider = AutoDisposeNotifierProvider<
|
||||
HiveStorageNotifier, Map<String, dynamic>>.internal(
|
||||
HiveStorageNotifier.new,
|
||||
name: r'hiveStorageNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$hiveStorageNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$HiveStorageNotifier = AutoDisposeNotifier<Map<String, dynamic>>;
|
||||
String _$storageHealthMonitorHash() =>
|
||||
r'1d52e331a84bd59a36055f5e8963eaa996f9c235';
|
||||
|
||||
/// Storage health monitor
|
||||
///
|
||||
/// Copied from [StorageHealthMonitor].
|
||||
@ProviderFor(StorageHealthMonitor)
|
||||
final storageHealthMonitorProvider = AutoDisposeNotifierProvider<
|
||||
StorageHealthMonitor, Map<String, dynamic>>.internal(
|
||||
StorageHealthMonitor.new,
|
||||
name: r'storageHealthMonitorProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$storageHealthMonitorHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$StorageHealthMonitor = AutoDisposeNotifier<Map<String, dynamic>>;
|
||||
String _$storageManagerHash() => r'8e017d34c8c574dd2777d6478af3cd921448b080';
|
||||
|
||||
/// Unified storage manager
|
||||
///
|
||||
/// Copied from [StorageManager].
|
||||
@ProviderFor(StorageManager)
|
||||
final storageManagerProvider =
|
||||
AutoDisposeNotifierProvider<StorageManager, Map<String, dynamic>>.internal(
|
||||
StorageManager.new,
|
||||
name: r'storageManagerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$storageManagerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$StorageManager = AutoDisposeNotifier<Map<String, dynamic>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
231
lib/core/providers/theme_providers.dart
Normal file
231
lib/core/providers/theme_providers.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../database/models/app_settings.dart';
|
||||
import '../database/repositories/settings_repository.dart';
|
||||
|
||||
part 'theme_providers.g.dart';
|
||||
|
||||
/// Theme mode enumeration
|
||||
enum AppThemeMode {
|
||||
light('light'),
|
||||
dark('dark'),
|
||||
system('system');
|
||||
|
||||
const AppThemeMode(this.value);
|
||||
final String value;
|
||||
|
||||
static AppThemeMode fromString(String value) {
|
||||
return AppThemeMode.values.firstWhere(
|
||||
(mode) => mode.value == value,
|
||||
orElse: () => AppThemeMode.system,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings repository provider
|
||||
@riverpod
|
||||
SettingsRepository settingsRepository(SettingsRepositoryRef ref) {
|
||||
return SettingsRepository();
|
||||
}
|
||||
|
||||
/// Current app settings provider
|
||||
@riverpod
|
||||
class AppSettingsNotifier extends _$AppSettingsNotifier {
|
||||
late SettingsRepository _repository;
|
||||
|
||||
@override
|
||||
Future<AppSettings> build() async {
|
||||
_repository = ref.read(settingsRepositoryProvider);
|
||||
return _repository.getSettings();
|
||||
}
|
||||
|
||||
/// Update theme mode
|
||||
Future<void> updateThemeMode(AppThemeMode mode) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
await _repository.updateThemeMode(mode.value);
|
||||
final updatedSettings = _repository.getSettings();
|
||||
state = AsyncValue.data(updatedSettings);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update locale
|
||||
Future<void> updateLocale(String locale) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
await _repository.updateLocale(locale);
|
||||
final updatedSettings = _repository.getSettings();
|
||||
state = AsyncValue.data(updatedSettings);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update notifications enabled
|
||||
Future<void> updateNotificationsEnabled(bool enabled) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
await _repository.updateNotificationsEnabled(enabled);
|
||||
final updatedSettings = _repository.getSettings();
|
||||
state = AsyncValue.data(updatedSettings);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update analytics enabled
|
||||
Future<void> updateAnalyticsEnabled(bool enabled) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
await _repository.updateAnalyticsEnabled(enabled);
|
||||
final updatedSettings = _repository.getSettings();
|
||||
state = AsyncValue.data(updatedSettings);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set custom setting
|
||||
Future<void> setCustomSetting(String key, dynamic value) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
await _repository.setCustomSetting(key, value);
|
||||
final updatedSettings = _repository.getSettings();
|
||||
state = AsyncValue.data(updatedSettings);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to default settings
|
||||
Future<void> resetToDefault() async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
await _repository.resetToDefault();
|
||||
final updatedSettings = _repository.getSettings();
|
||||
state = AsyncValue.data(updatedSettings);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh settings from storage
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
final settings = _repository.getSettings();
|
||||
state = AsyncValue.data(settings);
|
||||
} catch (error, stackTrace) {
|
||||
state = AsyncValue.error(error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current theme mode provider
|
||||
@riverpod
|
||||
AppThemeMode currentThemeMode(CurrentThemeModeRef ref) {
|
||||
final settingsAsync = ref.watch(appSettingsNotifierProvider);
|
||||
|
||||
return settingsAsync.when(
|
||||
data: (settings) => AppThemeMode.fromString(settings.themeMode),
|
||||
loading: () => AppThemeMode.system,
|
||||
error: (_, __) => AppThemeMode.system,
|
||||
);
|
||||
}
|
||||
|
||||
/// Effective theme mode provider (resolves system theme)
|
||||
@riverpod
|
||||
ThemeMode effectiveThemeMode(EffectiveThemeModeRef ref) {
|
||||
final themeMode = ref.watch(currentThemeModeProvider);
|
||||
|
||||
switch (themeMode) {
|
||||
case AppThemeMode.light:
|
||||
return ThemeMode.light;
|
||||
case AppThemeMode.dark:
|
||||
return ThemeMode.dark;
|
||||
case AppThemeMode.system:
|
||||
return ThemeMode.system;
|
||||
}
|
||||
}
|
||||
|
||||
/// Is dark mode active provider
|
||||
@riverpod
|
||||
bool isDarkMode(IsDarkModeRef ref) {
|
||||
final themeMode = ref.watch(currentThemeModeProvider);
|
||||
|
||||
switch (themeMode) {
|
||||
case AppThemeMode.light:
|
||||
return false;
|
||||
case AppThemeMode.dark:
|
||||
return true;
|
||||
case AppThemeMode.system:
|
||||
// Get platform brightness from MediaQuery
|
||||
// This will be handled at the widget level
|
||||
return false; // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Current locale provider
|
||||
@riverpod
|
||||
Locale currentLocale(CurrentLocaleRef ref) {
|
||||
final settingsAsync = ref.watch(appSettingsNotifierProvider);
|
||||
|
||||
return settingsAsync.when(
|
||||
data: (settings) => Locale(settings.locale),
|
||||
loading: () => const Locale('en'),
|
||||
error: (_, __) => const Locale('en'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Settings stream provider for reactive updates
|
||||
@riverpod
|
||||
class SettingsStreamNotifier extends _$SettingsStreamNotifier {
|
||||
@override
|
||||
Stream<AppSettings> build() {
|
||||
final repository = ref.read(settingsRepositoryProvider);
|
||||
return repository.watchSettings();
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme preferences provider for quick access
|
||||
@riverpod
|
||||
class ThemePreferences extends _$ThemePreferences {
|
||||
@override
|
||||
Map<String, dynamic> build() {
|
||||
final settingsAsync = ref.watch(appSettingsNotifierProvider);
|
||||
|
||||
return settingsAsync.when(
|
||||
data: (settings) => {
|
||||
'themeMode': settings.themeMode,
|
||||
'isDarkMode': AppThemeMode.fromString(settings.themeMode) == AppThemeMode.dark,
|
||||
'locale': settings.locale,
|
||||
'analyticsEnabled': settings.analyticsEnabled,
|
||||
'notificationsEnabled': settings.notificationsEnabled,
|
||||
},
|
||||
loading: () => {
|
||||
'themeMode': 'system',
|
||||
'isDarkMode': false,
|
||||
'locale': 'en',
|
||||
'analyticsEnabled': false,
|
||||
'notificationsEnabled': true,
|
||||
},
|
||||
error: (_, __) => {
|
||||
'themeMode': 'system',
|
||||
'isDarkMode': false,
|
||||
'locale': 'en',
|
||||
'analyticsEnabled': false,
|
||||
'notificationsEnabled': true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/core/providers/theme_providers.g.dart
Normal file
153
lib/core/providers/theme_providers.g.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'theme_providers.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$settingsRepositoryHash() =>
|
||||
r'0203e31bb994214ce864bf95a7afa14a8a14b812';
|
||||
|
||||
/// Settings repository provider
|
||||
///
|
||||
/// Copied from [settingsRepository].
|
||||
@ProviderFor(settingsRepository)
|
||||
final settingsRepositoryProvider =
|
||||
AutoDisposeProvider<SettingsRepository>.internal(
|
||||
settingsRepository,
|
||||
name: r'settingsRepositoryProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$settingsRepositoryHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef SettingsRepositoryRef = AutoDisposeProviderRef<SettingsRepository>;
|
||||
String _$currentThemeModeHash() => r'6cd4101e1d0f6cbd7851f117872cd49253fe0564';
|
||||
|
||||
/// Current theme mode provider
|
||||
///
|
||||
/// Copied from [currentThemeMode].
|
||||
@ProviderFor(currentThemeMode)
|
||||
final currentThemeModeProvider = AutoDisposeProvider<AppThemeMode>.internal(
|
||||
currentThemeMode,
|
||||
name: r'currentThemeModeProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$currentThemeModeHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef CurrentThemeModeRef = AutoDisposeProviderRef<AppThemeMode>;
|
||||
String _$effectiveThemeModeHash() =>
|
||||
r'd747fdd8489857c595ae766ee6a9497c4ad360c0';
|
||||
|
||||
/// Effective theme mode provider (resolves system theme)
|
||||
///
|
||||
/// Copied from [effectiveThemeMode].
|
||||
@ProviderFor(effectiveThemeMode)
|
||||
final effectiveThemeModeProvider = AutoDisposeProvider<ThemeMode>.internal(
|
||||
effectiveThemeMode,
|
||||
name: r'effectiveThemeModeProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$effectiveThemeModeHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef EffectiveThemeModeRef = AutoDisposeProviderRef<ThemeMode>;
|
||||
String _$isDarkModeHash() => r'e76c5818694a33e63bd0a8ba0b7494d7ee12cff5';
|
||||
|
||||
/// Is dark mode active provider
|
||||
///
|
||||
/// Copied from [isDarkMode].
|
||||
@ProviderFor(isDarkMode)
|
||||
final isDarkModeProvider = AutoDisposeProvider<bool>.internal(
|
||||
isDarkMode,
|
||||
name: r'isDarkModeProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$isDarkModeHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef IsDarkModeRef = AutoDisposeProviderRef<bool>;
|
||||
String _$currentLocaleHash() => r'c3cb4000a5eefa748ca41e50818b27323e61605a';
|
||||
|
||||
/// Current locale provider
|
||||
///
|
||||
/// Copied from [currentLocale].
|
||||
@ProviderFor(currentLocale)
|
||||
final currentLocaleProvider = AutoDisposeProvider<Locale>.internal(
|
||||
currentLocale,
|
||||
name: r'currentLocaleProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$currentLocaleHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef CurrentLocaleRef = AutoDisposeProviderRef<Locale>;
|
||||
String _$appSettingsNotifierHash() =>
|
||||
r'3a66de82c9b8f75bf34ffc7755b145a6d1e9c21e';
|
||||
|
||||
/// Current app settings provider
|
||||
///
|
||||
/// Copied from [AppSettingsNotifier].
|
||||
@ProviderFor(AppSettingsNotifier)
|
||||
final appSettingsNotifierProvider =
|
||||
AutoDisposeAsyncNotifierProvider<AppSettingsNotifier, AppSettings>.internal(
|
||||
AppSettingsNotifier.new,
|
||||
name: r'appSettingsNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$appSettingsNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AppSettingsNotifier = AutoDisposeAsyncNotifier<AppSettings>;
|
||||
String _$settingsStreamNotifierHash() =>
|
||||
r'1c1e31439ee63edc3217a20c0198bbb2aff6e033';
|
||||
|
||||
/// Settings stream provider for reactive updates
|
||||
///
|
||||
/// Copied from [SettingsStreamNotifier].
|
||||
@ProviderFor(SettingsStreamNotifier)
|
||||
final settingsStreamNotifierProvider = AutoDisposeStreamNotifierProvider<
|
||||
SettingsStreamNotifier, AppSettings>.internal(
|
||||
SettingsStreamNotifier.new,
|
||||
name: r'settingsStreamNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$settingsStreamNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$SettingsStreamNotifier = AutoDisposeStreamNotifier<AppSettings>;
|
||||
String _$themePreferencesHash() => r'71778e4afc614e1566d4a15131e2ab5d2302e57b';
|
||||
|
||||
/// Theme preferences provider for quick access
|
||||
///
|
||||
/// Copied from [ThemePreferences].
|
||||
@ProviderFor(ThemePreferences)
|
||||
final themePreferencesProvider = AutoDisposeNotifierProvider<ThemePreferences,
|
||||
Map<String, dynamic>>.internal(
|
||||
ThemePreferences.new,
|
||||
name: r'themePreferencesProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$themePreferencesHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ThemePreferences = AutoDisposeNotifier<Map<String, dynamic>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
413
lib/core/routing/app_router.dart
Normal file
413
lib/core/routing/app_router.dart
Normal file
@@ -0,0 +1,413 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'route_names.dart';
|
||||
import 'route_paths.dart';
|
||||
import 'route_guards.dart';
|
||||
import 'error_page.dart';
|
||||
import '../../features/home/presentation/pages/home_page.dart';
|
||||
import '../../features/settings/presentation/pages/settings_page.dart';
|
||||
import '../../features/todos/presentation/screens/home_screen.dart';
|
||||
|
||||
/// GoRouter provider for the entire app
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
|
||||
return GoRouter(
|
||||
debugLogDiagnostics: true,
|
||||
initialLocation: RoutePaths.home,
|
||||
redirect: (context, state) {
|
||||
return RouteGuard.getRedirectPath(state.fullPath ?? '', authState);
|
||||
},
|
||||
routes: [
|
||||
// Home route
|
||||
GoRoute(
|
||||
path: RoutePaths.home,
|
||||
name: RouteNames.home,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const HomePage(),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
|
||||
// Settings routes with nested navigation
|
||||
GoRoute(
|
||||
path: RoutePaths.settings,
|
||||
name: RouteNames.settings,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const SettingsPage(),
|
||||
state: state,
|
||||
),
|
||||
routes: [
|
||||
// Settings sub-pages
|
||||
GoRoute(
|
||||
path: '/theme',
|
||||
name: RouteNames.settingsTheme,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const ThemeSettingsPage(),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/general',
|
||||
name: RouteNames.settingsGeneral,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const _PlaceholderPage(title: 'General Settings'),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/privacy',
|
||||
name: RouteNames.settingsPrivacy,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const _PlaceholderPage(title: 'Privacy Settings'),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/notifications',
|
||||
name: RouteNames.settingsNotifications,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const _PlaceholderPage(title: 'Notification Settings'),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Profile route
|
||||
GoRoute(
|
||||
path: RoutePaths.profile,
|
||||
name: RouteNames.profile,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const _PlaceholderPage(title: 'Profile'),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
|
||||
// About route
|
||||
GoRoute(
|
||||
path: RoutePaths.about,
|
||||
name: RouteNames.about,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const _AboutPage(),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
|
||||
// Auth routes
|
||||
GoRoute(
|
||||
path: RoutePaths.login,
|
||||
name: RouteNames.login,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const _PlaceholderPage(title: 'Login'),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: RoutePaths.register,
|
||||
name: RouteNames.register,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const _PlaceholderPage(title: 'Register'),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: RoutePaths.forgotPassword,
|
||||
name: RouteNames.forgotPassword,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const _PlaceholderPage(title: 'Forgot Password'),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
|
||||
// Todo routes (keeping existing functionality)
|
||||
GoRoute(
|
||||
path: RoutePaths.todos,
|
||||
name: RouteNames.todos,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const HomeScreen(), // Using existing TodoScreen
|
||||
state: state,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/add',
|
||||
name: RouteNames.addTodo,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const _PlaceholderPage(title: 'Add Todo'),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/:id',
|
||||
name: RouteNames.todoDetails,
|
||||
pageBuilder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return _buildPageWithTransition(
|
||||
child: _PlaceholderPage(title: 'Todo Details: $id'),
|
||||
state: state,
|
||||
);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/edit',
|
||||
name: RouteNames.editTodo,
|
||||
pageBuilder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
return _buildPageWithTransition(
|
||||
child: _PlaceholderPage(title: 'Edit Todo: $id'),
|
||||
state: state,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Onboarding routes
|
||||
GoRoute(
|
||||
path: RoutePaths.onboarding,
|
||||
name: RouteNames.onboarding,
|
||||
pageBuilder: (context, state) => _buildPageWithTransition(
|
||||
child: const _PlaceholderPage(title: 'Onboarding'),
|
||||
state: state,
|
||||
),
|
||||
),
|
||||
],
|
||||
errorPageBuilder: (context, state) => MaterialPage<void>(
|
||||
key: state.pageKey,
|
||||
child: ErrorPage(
|
||||
error: state.error.toString(),
|
||||
path: state.fullPath,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
/// Helper function to build pages with transitions
|
||||
Page<T> _buildPageWithTransition<T>({
|
||||
required Widget child,
|
||||
required GoRouterState state,
|
||||
Duration transitionDuration = const Duration(milliseconds: 250),
|
||||
}) {
|
||||
return CustomTransitionPage<T>(
|
||||
key: state.pageKey,
|
||||
child: child,
|
||||
transitionDuration: transitionDuration,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
// Slide transition from right to left
|
||||
const begin = Offset(1.0, 0.0);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.ease;
|
||||
|
||||
var tween = Tween(begin: begin, end: end).chain(
|
||||
CurveTween(curve: curve),
|
||||
);
|
||||
|
||||
return SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Extension methods for GoRouter navigation
|
||||
extension GoRouterExtension on GoRouter {
|
||||
/// Navigate to a named route with parameters
|
||||
void goNamed(
|
||||
String name, {
|
||||
Map<String, String> pathParameters = const {},
|
||||
Map<String, dynamic> queryParameters = const {},
|
||||
Object? extra,
|
||||
}) {
|
||||
pushNamed(
|
||||
name,
|
||||
pathParameters: pathParameters,
|
||||
queryParameters: queryParameters,
|
||||
extra: extra,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if we can pop the current route
|
||||
bool get canPop => routerDelegate.currentConfiguration.matches.length > 1;
|
||||
}
|
||||
|
||||
/// Extension methods for BuildContext navigation
|
||||
extension BuildContextGoRouterExtension on BuildContext {
|
||||
/// Get the GoRouter instance
|
||||
GoRouter get router => GoRouter.of(this);
|
||||
|
||||
/// Navigate with typed route names
|
||||
void goToHome() => go(RoutePaths.home);
|
||||
void goToSettings() => go(RoutePaths.settings);
|
||||
void goToLogin() => go(RoutePaths.login);
|
||||
void goToProfile() => go(RoutePaths.profile);
|
||||
|
||||
/// Navigate to todo details with ID
|
||||
void goToTodoDetails(String id) => go(RoutePaths.todoDetailsPath(id));
|
||||
|
||||
/// Navigate to edit todo with ID
|
||||
void goToEditTodo(String id) => go(RoutePaths.editTodoPath(id));
|
||||
|
||||
/// Push with typed route names
|
||||
void pushHome() => push(RoutePaths.home);
|
||||
void pushSettings() => push(RoutePaths.settings);
|
||||
void pushLogin() => push(RoutePaths.login);
|
||||
void pushProfile() => push(RoutePaths.profile);
|
||||
|
||||
/// Get current route information
|
||||
String get currentPath => GoRouterState.of(this).fullPath ?? '/';
|
||||
String get currentName => GoRouterState.of(this).name ?? '';
|
||||
Map<String, String> get pathParameters => GoRouterState.of(this).pathParameters;
|
||||
Map<String, dynamic> get queryParameters => GoRouterState.of(this).uri.queryParameters;
|
||||
}
|
||||
|
||||
/// About page implementation
|
||||
class _AboutPage extends StatelessWidget {
|
||||
const _AboutPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('About'),
|
||||
),
|
||||
body: const SingleChildScrollView(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.flutter_dash,
|
||||
size: 80,
|
||||
color: Colors.blue,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Base Flutter App',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'Version 1.0.0+1',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 32),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'About This App',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'A foundational Flutter application with clean architecture, '
|
||||
'state management using Riverpod, local storage with Hive, '
|
||||
'and navigation using GoRouter.',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Features',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text('• Clean Architecture with feature-first structure'),
|
||||
Text('• State management with Riverpod'),
|
||||
Text('• Local storage with Hive'),
|
||||
Text('• Navigation with GoRouter'),
|
||||
Text('• Material 3 design system'),
|
||||
Text('• Theme switching (Light/Dark/System)'),
|
||||
Text('• Secure storage for sensitive data'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Placeholder page for routes that aren't implemented yet
|
||||
class _PlaceholderPage extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _PlaceholderPage({
|
||||
required this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.construction,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'This page is coming soon!',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('Go Back'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
271
lib/core/routing/error_page.dart
Normal file
271
lib/core/routing/error_page.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'route_paths.dart';
|
||||
|
||||
/// 404 Error page widget
|
||||
class ErrorPage extends StatelessWidget {
|
||||
final String? error;
|
||||
final String? path;
|
||||
|
||||
const ErrorPage({
|
||||
super.key,
|
||||
this.error,
|
||||
this.path,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Page Not Found'),
|
||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Error illustration
|
||||
Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
size: 96,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Error title
|
||||
Text(
|
||||
'404',
|
||||
style: Theme.of(context).textTheme.displayLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Error message
|
||||
Text(
|
||||
'Page Not Found',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Error description
|
||||
Text(
|
||||
'The page you are looking for doesn\'t exist or has been moved.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
if (path != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Requested Path:',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
path!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (error != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ExpansionTile(
|
||||
title: Text(
|
||||
'Error Details',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
error!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Go back button
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go(RoutePaths.home);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('Go Back'),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Home button
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.go(RoutePaths.home),
|
||||
icon: const Icon(Icons.home),
|
||||
label: const Text('Home'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Help text
|
||||
Text(
|
||||
'If this problem persists, please contact support.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Network error page widget
|
||||
class NetworkErrorPage extends StatelessWidget {
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const NetworkErrorPage({
|
||||
super.key,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Connection Error'),
|
||||
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Error illustration
|
||||
Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.wifi_off,
|
||||
size: 96,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Error title
|
||||
Text(
|
||||
'No Connection',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Error description
|
||||
Text(
|
||||
'Please check your internet connection and try again.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Action buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (onRetry != null) ...[
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go(RoutePaths.home),
|
||||
icon: const Icon(Icons.home),
|
||||
label: const Text('Home'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
312
lib/core/routing/navigation_shell.dart
Normal file
312
lib/core/routing/navigation_shell.dart
Normal file
@@ -0,0 +1,312 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'route_paths.dart';
|
||||
import 'route_guards.dart';
|
||||
|
||||
/// Navigation shell with bottom navigation or navigation drawer
|
||||
class NavigationShell extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
final GoRouterState state;
|
||||
|
||||
const NavigationShell({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.state,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<NavigationShell> createState() => _NavigationShellState();
|
||||
}
|
||||
|
||||
class _NavigationShellState extends ConsumerState<NavigationShell> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
final currentPath = widget.state.fullPath ?? '/';
|
||||
|
||||
// Determine if we should show bottom navigation
|
||||
final showBottomNav = _shouldShowBottomNavigation(currentPath, authState);
|
||||
|
||||
if (!showBottomNav) {
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
// Get current navigation index
|
||||
final currentIndex = _getCurrentNavigationIndex(currentPath);
|
||||
|
||||
return Scaffold(
|
||||
body: widget.child,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (index) => _onNavigationTapped(context, index),
|
||||
destinations: _getNavigationDestinations(authState),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Determine if bottom navigation should be shown
|
||||
bool _shouldShowBottomNavigation(String path, AuthState authState) {
|
||||
// Don't show on auth pages
|
||||
if (RoutePaths.isAuthPath(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show on onboarding
|
||||
if (path.startsWith(RoutePaths.onboarding) || path.startsWith(RoutePaths.welcome)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show on error pages
|
||||
if (path.startsWith(RoutePaths.error) || path.startsWith(RoutePaths.notFound)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show on specific detail pages
|
||||
final hideOnPaths = [
|
||||
'/todos/add',
|
||||
'/todos/',
|
||||
'/settings/',
|
||||
];
|
||||
|
||||
for (final hidePath in hideOnPaths) {
|
||||
if (path.contains(hidePath) && path != RoutePaths.todos && path != RoutePaths.settings) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Get current navigation index based on path
|
||||
int _getCurrentNavigationIndex(String path) {
|
||||
if (path.startsWith(RoutePaths.todos)) {
|
||||
return 1;
|
||||
} else if (path.startsWith(RoutePaths.settings)) {
|
||||
return 2;
|
||||
} else if (path.startsWith(RoutePaths.profile)) {
|
||||
return 3;
|
||||
}
|
||||
return 0; // Home
|
||||
}
|
||||
|
||||
/// Get navigation destinations based on auth state
|
||||
List<NavigationDestination> _getNavigationDestinations(AuthState authState) {
|
||||
return [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.check_circle_outline),
|
||||
selectedIcon: Icon(Icons.check_circle),
|
||||
label: 'Todos',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
if (authState == AuthState.authenticated)
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: 'Profile',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Handle navigation tap
|
||||
void _onNavigationTapped(BuildContext context, int index) {
|
||||
final authState = ref.read(authStateProvider);
|
||||
|
||||
switch (index) {
|
||||
case 0:
|
||||
if (GoRouterState.of(context).fullPath != RoutePaths.home) {
|
||||
context.go(RoutePaths.home);
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
if (!GoRouterState.of(context).fullPath!.startsWith(RoutePaths.todos)) {
|
||||
context.go(RoutePaths.todos);
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
if (!GoRouterState.of(context).fullPath!.startsWith(RoutePaths.settings)) {
|
||||
context.go(RoutePaths.settings);
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
if (authState == AuthState.authenticated) {
|
||||
if (GoRouterState.of(context).fullPath != RoutePaths.profile) {
|
||||
context.go(RoutePaths.profile);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adaptive navigation shell that changes based on screen size
|
||||
class AdaptiveNavigationShell extends ConsumerWidget {
|
||||
final Widget child;
|
||||
final GoRouterState state;
|
||||
|
||||
const AdaptiveNavigationShell({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.state,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
// Use rail navigation on tablets and desktop
|
||||
if (screenWidth >= 840) {
|
||||
return _NavigationRailShell(
|
||||
child: child,
|
||||
state: state,
|
||||
);
|
||||
}
|
||||
|
||||
// Use bottom navigation on mobile
|
||||
return NavigationShell(
|
||||
child: child,
|
||||
state: state,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation rail for larger screens
|
||||
class _NavigationRailShell extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
final GoRouterState state;
|
||||
|
||||
const _NavigationRailShell({
|
||||
required this.child,
|
||||
required this.state,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_NavigationRailShell> createState() => _NavigationRailShellState();
|
||||
}
|
||||
|
||||
class _NavigationRailShellState extends ConsumerState<_NavigationRailShell> {
|
||||
bool isExtended = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
final currentPath = widget.state.fullPath ?? '/';
|
||||
|
||||
// Determine if we should show navigation rail
|
||||
final showNavRail = _shouldShowNavigationRail(currentPath, authState);
|
||||
|
||||
if (!showNavRail) {
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
// Get current navigation index
|
||||
final currentIndex = _getCurrentNavigationIndex(currentPath);
|
||||
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
extended: isExtended,
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: (index) => _onNavigationTapped(context, index),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
isExtended = !isExtended;
|
||||
});
|
||||
},
|
||||
),
|
||||
destinations: _getNavigationDestinations(authState),
|
||||
),
|
||||
const VerticalDivider(thickness: 1, width: 1),
|
||||
Expanded(child: widget.child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _shouldShowNavigationRail(String path, AuthState authState) {
|
||||
// Same logic as bottom navigation
|
||||
if (RoutePaths.isAuthPath(path)) return false;
|
||||
if (path.startsWith(RoutePaths.onboarding) || path.startsWith(RoutePaths.welcome)) return false;
|
||||
if (path.startsWith(RoutePaths.error) || path.startsWith(RoutePaths.notFound)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int _getCurrentNavigationIndex(String path) {
|
||||
if (path.startsWith(RoutePaths.todos)) {
|
||||
return 1;
|
||||
} else if (path.startsWith(RoutePaths.settings)) {
|
||||
return 2;
|
||||
} else if (path.startsWith(RoutePaths.profile)) {
|
||||
return 3;
|
||||
}
|
||||
return 0; // Home
|
||||
}
|
||||
|
||||
List<NavigationRailDestination> _getNavigationDestinations(AuthState authState) {
|
||||
return [
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: Text('Home'),
|
||||
),
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(Icons.check_circle_outline),
|
||||
selectedIcon: Icon(Icons.check_circle),
|
||||
label: Text('Todos'),
|
||||
),
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: Text('Settings'),
|
||||
),
|
||||
if (authState == AuthState.authenticated)
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: Text('Profile'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
void _onNavigationTapped(BuildContext context, int index) {
|
||||
final authState = ref.read(authStateProvider);
|
||||
|
||||
switch (index) {
|
||||
case 0:
|
||||
if (GoRouterState.of(context).fullPath != RoutePaths.home) {
|
||||
context.go(RoutePaths.home);
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
if (!GoRouterState.of(context).fullPath!.startsWith(RoutePaths.todos)) {
|
||||
context.go(RoutePaths.todos);
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
if (!GoRouterState.of(context).fullPath!.startsWith(RoutePaths.settings)) {
|
||||
context.go(RoutePaths.settings);
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
if (authState == AuthState.authenticated) {
|
||||
if (GoRouterState.of(context).fullPath != RoutePaths.profile) {
|
||||
context.go(RoutePaths.profile);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
168
lib/core/routing/route_guards.dart
Normal file
168
lib/core/routing/route_guards.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'route_paths.dart';
|
||||
|
||||
/// Authentication state provider
|
||||
final authStateProvider = StateNotifierProvider<AuthStateNotifier, AuthState>(
|
||||
(ref) => AuthStateNotifier(),
|
||||
);
|
||||
|
||||
/// Authentication state
|
||||
enum AuthState {
|
||||
unknown,
|
||||
authenticated,
|
||||
unauthenticated,
|
||||
}
|
||||
|
||||
/// Authentication state notifier
|
||||
class AuthStateNotifier extends StateNotifier<AuthState> {
|
||||
AuthStateNotifier() : super(AuthState.unknown) {
|
||||
_checkInitialAuth();
|
||||
}
|
||||
|
||||
Future<void> _checkInitialAuth() async {
|
||||
// TODO: Implement actual auth check logic
|
||||
// For now, simulate checking stored auth token
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Mock authentication check
|
||||
// In a real app, you would check secure storage for auth token
|
||||
state = AuthState.unauthenticated;
|
||||
}
|
||||
|
||||
Future<void> login(String email, String password) async {
|
||||
// TODO: Implement actual login logic
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
state = AuthState.authenticated;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
// TODO: Implement actual logout logic
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
state = AuthState.unauthenticated;
|
||||
}
|
||||
|
||||
Future<void> register(String email, String password) async {
|
||||
// TODO: Implement actual registration logic
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
state = AuthState.authenticated;
|
||||
}
|
||||
}
|
||||
|
||||
/// Route guard utility class
|
||||
class RouteGuard {
|
||||
/// Check if user can access the given route
|
||||
static bool canAccess(String path, AuthState authState) {
|
||||
// Allow access during unknown state (loading)
|
||||
if (authState == AuthState.unknown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if route requires authentication
|
||||
final requiresAuth = RoutePaths.requiresAuth(path);
|
||||
final isAuthenticated = authState == AuthState.authenticated;
|
||||
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent authenticated users from accessing auth pages
|
||||
if (RoutePaths.isAuthPath(path) && isAuthenticated) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Get redirect path based on current route and auth state
|
||||
static String? getRedirectPath(String path, AuthState authState) {
|
||||
if (authState == AuthState.unknown) {
|
||||
return null; // Don't redirect during loading
|
||||
}
|
||||
|
||||
final requiresAuth = RoutePaths.requiresAuth(path);
|
||||
final isAuthenticated = authState == AuthState.authenticated;
|
||||
|
||||
// Redirect unauthenticated users to login
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
return RoutePaths.login;
|
||||
}
|
||||
|
||||
// Redirect authenticated users away from auth pages
|
||||
if (RoutePaths.isAuthPath(path) && isAuthenticated) {
|
||||
return RoutePaths.home;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Onboarding state provider
|
||||
final onboardingStateProvider = StateNotifierProvider<OnboardingStateNotifier, bool>(
|
||||
(ref) => OnboardingStateNotifier(),
|
||||
);
|
||||
|
||||
/// Onboarding state notifier
|
||||
class OnboardingStateNotifier extends StateNotifier<bool> {
|
||||
OnboardingStateNotifier() : super(true) {
|
||||
_checkOnboardingStatus();
|
||||
}
|
||||
|
||||
Future<void> _checkOnboardingStatus() async {
|
||||
// TODO: Check if user has completed onboarding
|
||||
// For now, simulate that onboarding is not completed
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
state = false; // false means onboarding is completed
|
||||
}
|
||||
|
||||
void completeOnboarding() {
|
||||
state = false;
|
||||
// TODO: Save onboarding completion status to storage
|
||||
}
|
||||
}
|
||||
|
||||
/// Permission types
|
||||
enum Permission {
|
||||
camera,
|
||||
microphone,
|
||||
location,
|
||||
storage,
|
||||
notifications,
|
||||
}
|
||||
|
||||
/// Permission state provider
|
||||
final permissionStateProvider = StateNotifierProvider<PermissionStateNotifier, Map<Permission, bool>>(
|
||||
(ref) => PermissionStateNotifier(),
|
||||
);
|
||||
|
||||
/// Permission state notifier
|
||||
class PermissionStateNotifier extends StateNotifier<Map<Permission, bool>> {
|
||||
PermissionStateNotifier() : super({}) {
|
||||
_initializePermissions();
|
||||
}
|
||||
|
||||
void _initializePermissions() {
|
||||
// Initialize all permissions as not granted
|
||||
state = {
|
||||
for (final permission in Permission.values) permission: false,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> requestPermission(Permission permission) async {
|
||||
// TODO: Implement actual permission request logic
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Mock permission grant
|
||||
state = {
|
||||
...state,
|
||||
permission: true,
|
||||
};
|
||||
}
|
||||
|
||||
bool hasPermission(Permission permission) {
|
||||
return state[permission] ?? false;
|
||||
}
|
||||
|
||||
bool hasAllPermissions(List<Permission> permissions) {
|
||||
return permissions.every((permission) => hasPermission(permission));
|
||||
}
|
||||
}
|
||||
37
lib/core/routing/route_names.dart
Normal file
37
lib/core/routing/route_names.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
/// Route name constants for type-safe navigation
|
||||
class RouteNames {
|
||||
RouteNames._();
|
||||
|
||||
// Main routes
|
||||
static const String home = 'home';
|
||||
static const String settings = 'settings';
|
||||
static const String profile = 'profile';
|
||||
static const String about = 'about';
|
||||
|
||||
// Auth routes
|
||||
static const String login = 'login';
|
||||
static const String register = 'register';
|
||||
static const String forgotPassword = 'forgot_password';
|
||||
static const String resetPassword = 'reset_password';
|
||||
static const String verifyEmail = 'verify_email';
|
||||
|
||||
// Onboarding routes
|
||||
static const String onboarding = 'onboarding';
|
||||
static const String welcome = 'welcome';
|
||||
|
||||
// Todo routes (keeping existing feature)
|
||||
static const String todos = 'todos';
|
||||
static const String todoDetails = 'todo_details';
|
||||
static const String addTodo = 'add_todo';
|
||||
static const String editTodo = 'edit_todo';
|
||||
|
||||
// Error routes
|
||||
static const String error = 'error';
|
||||
static const String notFound = '404';
|
||||
|
||||
// Nested routes
|
||||
static const String settingsGeneral = 'settings_general';
|
||||
static const String settingsPrivacy = 'settings_privacy';
|
||||
static const String settingsNotifications = 'settings_notifications';
|
||||
static const String settingsTheme = 'settings_theme';
|
||||
}
|
||||
70
lib/core/routing/route_paths.dart
Normal file
70
lib/core/routing/route_paths.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
/// Route path constants for URL patterns
|
||||
class RoutePaths {
|
||||
RoutePaths._();
|
||||
|
||||
// Main routes
|
||||
static const String home = '/';
|
||||
static const String settings = '/settings';
|
||||
static const String profile = '/profile';
|
||||
static const String about = '/about';
|
||||
|
||||
// Auth routes
|
||||
static const String login = '/auth/login';
|
||||
static const String register = '/auth/register';
|
||||
static const String forgotPassword = '/auth/forgot-password';
|
||||
static const String resetPassword = '/auth/reset-password';
|
||||
static const String verifyEmail = '/auth/verify-email';
|
||||
|
||||
// Onboarding routes
|
||||
static const String onboarding = '/onboarding';
|
||||
static const String welcome = '/welcome';
|
||||
|
||||
// Todo routes (keeping existing feature)
|
||||
static const String todos = '/todos';
|
||||
static const String todoDetails = '/todos/:id';
|
||||
static const String addTodo = '/todos/add';
|
||||
static const String editTodo = '/todos/:id/edit';
|
||||
|
||||
// Error routes
|
||||
static const String error = '/error';
|
||||
static const String notFound = '/404';
|
||||
|
||||
// Nested settings routes
|
||||
static const String settingsGeneral = '/settings/general';
|
||||
static const String settingsPrivacy = '/settings/privacy';
|
||||
static const String settingsNotifications = '/settings/notifications';
|
||||
static const String settingsTheme = '/settings/theme';
|
||||
|
||||
/// Helper method to build paths with parameters
|
||||
static String todoDetailsPath(String id) => '/todos/$id';
|
||||
static String editTodoPath(String id) => '/todos/$id/edit';
|
||||
|
||||
/// Helper method to check if path requires authentication
|
||||
static bool requiresAuth(String path) {
|
||||
const publicPaths = [
|
||||
home,
|
||||
login,
|
||||
register,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
onboarding,
|
||||
welcome,
|
||||
error,
|
||||
notFound,
|
||||
];
|
||||
return !publicPaths.contains(path);
|
||||
}
|
||||
|
||||
/// Helper method to check if path is auth related
|
||||
static bool isAuthPath(String path) {
|
||||
const authPaths = [
|
||||
login,
|
||||
register,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
];
|
||||
return authPaths.contains(path);
|
||||
}
|
||||
}
|
||||
12
lib/core/routing/routing.dart
Normal file
12
lib/core/routing/routing.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
/// Routing module barrel file
|
||||
///
|
||||
/// This file exports all routing-related classes and functions
|
||||
/// for easier imports throughout the application.
|
||||
library routing;
|
||||
|
||||
export 'app_router.dart';
|
||||
export 'route_names.dart';
|
||||
export 'route_paths.dart';
|
||||
export 'route_guards.dart';
|
||||
export 'error_page.dart';
|
||||
export 'navigation_shell.dart';
|
||||
237
lib/core/services/api_service.dart
Normal file
237
lib/core/services/api_service.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../network/dio_client.dart';
|
||||
import '../network/models/api_response.dart';
|
||||
|
||||
/// Base API service class that provides common functionality for all API services
|
||||
abstract class BaseApiService {
|
||||
final DioClient _dioClient;
|
||||
|
||||
BaseApiService(this._dioClient);
|
||||
|
||||
/// Handle API response and extract data
|
||||
T handleApiResponse<T>(
|
||||
Response response,
|
||||
T Function(dynamic) fromJson,
|
||||
) {
|
||||
if (response.statusCode != null && response.statusCode! >= 200 && response.statusCode! < 300) {
|
||||
try {
|
||||
// If the response data is already the expected type
|
||||
if (response.data is T) {
|
||||
return response.data as T;
|
||||
}
|
||||
|
||||
// If the response data is a Map, try to parse it
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
return fromJson(response.data);
|
||||
}
|
||||
|
||||
// If the response data is a List, try to parse each item
|
||||
if (response.data is List && T.toString().contains('List')) {
|
||||
return response.data as T;
|
||||
}
|
||||
|
||||
return fromJson(response.data);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to parse response: $e');
|
||||
}
|
||||
} else {
|
||||
throw Exception('API request failed with status: ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle API response with ApiResponse wrapper
|
||||
T handleWrappedApiResponse<T>(
|
||||
Response response,
|
||||
T Function(dynamic) fromJson,
|
||||
) {
|
||||
try {
|
||||
if (response.statusCode != null && response.statusCode! >= 200 && response.statusCode! < 300) {
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
final apiResponse = ApiResponse<T>.fromJson(
|
||||
response.data as Map<String, dynamic>,
|
||||
fromJson,
|
||||
);
|
||||
|
||||
if (apiResponse.success && apiResponse.data != null) {
|
||||
return apiResponse.data!;
|
||||
} else {
|
||||
throw Exception(apiResponse.message);
|
||||
}
|
||||
}
|
||||
return fromJson(response.data);
|
||||
} else {
|
||||
throw Exception('API request failed with status: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to handle API response: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a request with error handling
|
||||
Future<T> executeRequest<T>(
|
||||
Future<Response> Function() request,
|
||||
T Function(dynamic) fromJson, {
|
||||
bool useWrapper = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await request();
|
||||
|
||||
if (useWrapper) {
|
||||
return handleWrappedApiResponse<T>(response, fromJson);
|
||||
} else {
|
||||
return handleApiResponse<T>(response, fromJson);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
// The error interceptor will have already processed this
|
||||
if (e.error is NetworkFailure) {
|
||||
final failure = e.error as NetworkFailure;
|
||||
throw Exception(failure.message);
|
||||
} else {
|
||||
throw Exception(e.message ?? 'Network error occurred');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Unexpected error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the underlying dio client for advanced usage
|
||||
DioClient get dioClient => _dioClient;
|
||||
}
|
||||
|
||||
/// Example API service for demonstration
|
||||
class ExampleApiService extends BaseApiService {
|
||||
ExampleApiService(super.dioClient);
|
||||
|
||||
/// Example: Get user profile
|
||||
Future<Map<String, dynamic>> getUserProfile(String userId) async {
|
||||
return executeRequest(
|
||||
() => dioClient.get('/users/$userId'),
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Example: Create a new post
|
||||
Future<Map<String, dynamic>> createPost(Map<String, dynamic> postData) async {
|
||||
return executeRequest(
|
||||
() => dioClient.post('/posts', data: postData),
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Example: Get posts with pagination
|
||||
Future<List<Map<String, dynamic>>> getPosts({
|
||||
int page = 1,
|
||||
int limit = 10,
|
||||
}) async {
|
||||
return executeRequest(
|
||||
() => dioClient.get(
|
||||
'/posts',
|
||||
queryParameters: {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
},
|
||||
),
|
||||
(data) => (data as List).cast<Map<String, dynamic>>(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Example: Upload file
|
||||
Future<Map<String, dynamic>> uploadFile(
|
||||
String filePath,
|
||||
String filename,
|
||||
) async {
|
||||
try {
|
||||
// Note: This is a placeholder. In real implementation, you would use:
|
||||
// final response = await dioClient.uploadFile(
|
||||
// '/upload',
|
||||
// File(filePath),
|
||||
// filename: filename,
|
||||
// );
|
||||
|
||||
final response = await dioClient.post('/upload', data: {
|
||||
'filename': filename,
|
||||
'path': filePath,
|
||||
});
|
||||
|
||||
return handleApiResponse(
|
||||
response,
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
} catch (e) {
|
||||
throw Exception('File upload failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Example: Download file
|
||||
Future<void> downloadFile(String url, String savePath) async {
|
||||
try {
|
||||
await dioClient.downloadFile(url, savePath);
|
||||
} catch (e) {
|
||||
throw Exception('File download failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Example: Test network connectivity
|
||||
Future<bool> testConnection() async {
|
||||
try {
|
||||
await dioClient.get('/health');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication API service
|
||||
class AuthApiService extends BaseApiService {
|
||||
AuthApiService(super.dioClient);
|
||||
|
||||
/// Login with credentials
|
||||
Future<Map<String, dynamic>> login(String email, String password) async {
|
||||
return executeRequest(
|
||||
() => dioClient.post('/auth/login', data: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
}),
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Logout
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await dioClient.post('/auth/logout');
|
||||
await dioClient.authInterceptor.logout();
|
||||
} catch (e) {
|
||||
// Even if the API call fails, clear local tokens
|
||||
await dioClient.authInterceptor.logout();
|
||||
}
|
||||
}
|
||||
|
||||
/// Register new user
|
||||
Future<Map<String, dynamic>> register(Map<String, dynamic> userData) async {
|
||||
return executeRequest(
|
||||
() => dioClient.post('/auth/register', data: userData),
|
||||
(data) => data as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if user is authenticated
|
||||
Future<bool> isAuthenticated() async {
|
||||
return await dioClient.authInterceptor.isAuthenticated();
|
||||
}
|
||||
|
||||
/// Store authentication tokens
|
||||
Future<void> storeTokens({
|
||||
required String accessToken,
|
||||
String? refreshToken,
|
||||
int? expiresIn,
|
||||
}) async {
|
||||
await dioClient.authInterceptor.storeTokens(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresIn: expiresIn,
|
||||
);
|
||||
}
|
||||
}
|
||||
288
lib/core/theme/README.md
Normal file
288
lib/core/theme/README.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# Material 3 Theme System
|
||||
|
||||
A comprehensive Material 3 (Material You) design system implementation for Flutter applications.
|
||||
|
||||
## Overview
|
||||
|
||||
This theme system provides:
|
||||
|
||||
- **Complete Material 3 design system** with proper color schemes, typography, and spacing
|
||||
- **Dynamic color support** for Material You theming
|
||||
- **Light and dark theme configurations** with accessibility-compliant colors
|
||||
- **Responsive typography** that scales based on screen size
|
||||
- **Consistent spacing system** following Material Design guidelines
|
||||
- **Custom component themes** for buttons, cards, inputs, and more
|
||||
- **Theme switching widgets** with smooth animations
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Import the theme system
|
||||
|
||||
```dart
|
||||
import 'package:base_flutter/core/theme/theme.dart';
|
||||
```
|
||||
|
||||
### 2. Apply themes to your app
|
||||
|
||||
```dart
|
||||
class MyApp extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeControllerProvider);
|
||||
|
||||
return MaterialApp(
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: themeMode,
|
||||
// ... rest of your app
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Add theme switching capability
|
||||
|
||||
```dart
|
||||
// Simple toggle button
|
||||
ThemeToggleIconButton()
|
||||
|
||||
// Segmented control
|
||||
ThemeModeSwitch(
|
||||
style: ThemeSwitchStyle.segmented,
|
||||
showLabel: true,
|
||||
)
|
||||
|
||||
// Animated switch
|
||||
AnimatedThemeModeSwitch()
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### AppTheme
|
||||
Main theme configuration class containing Material 3 light and dark themes.
|
||||
|
||||
```dart
|
||||
// Get themes
|
||||
ThemeData lightTheme = AppTheme.lightTheme;
|
||||
ThemeData darkTheme = AppTheme.darkTheme;
|
||||
|
||||
// Responsive theme
|
||||
ThemeData responsiveTheme = AppTheme.responsiveTheme(context, isDark: false);
|
||||
|
||||
// Dynamic colors (Material You)
|
||||
ColorScheme? dynamicLight = AppTheme.dynamicLightColorScheme(context);
|
||||
ThemeData dynamicTheme = AppTheme.createDynamicTheme(
|
||||
colorScheme: dynamicLight!,
|
||||
isDark: false,
|
||||
);
|
||||
```
|
||||
|
||||
### AppColors
|
||||
Material 3 color system with semantic colors.
|
||||
|
||||
```dart
|
||||
// Color schemes
|
||||
ColorScheme lightScheme = AppColors.lightScheme;
|
||||
ColorScheme darkScheme = AppColors.darkScheme;
|
||||
|
||||
// Semantic colors
|
||||
Color success = AppColors.success;
|
||||
Color warning = AppColors.warning;
|
||||
Color info = AppColors.info;
|
||||
|
||||
// Surface colors with elevation
|
||||
Color elevatedSurface = AppColors.getSurfaceColor(2, false);
|
||||
|
||||
// Accessibility check
|
||||
bool isAccessible = AppColors.isAccessible(backgroundColor, textColor);
|
||||
```
|
||||
|
||||
### AppTypography
|
||||
Material 3 typography system with responsive scaling.
|
||||
|
||||
```dart
|
||||
// Typography styles
|
||||
TextStyle displayLarge = AppTypography.displayLarge;
|
||||
TextStyle headlineMedium = AppTypography.headlineMedium;
|
||||
TextStyle bodyLarge = AppTypography.bodyLarge;
|
||||
|
||||
// Responsive typography
|
||||
TextTheme responsiveTheme = AppTypography.responsiveTextTheme(context);
|
||||
|
||||
// Semantic text styles
|
||||
TextStyle errorStyle = AppTypography.error(context);
|
||||
TextStyle successStyle = AppTypography.success(context);
|
||||
```
|
||||
|
||||
### AppSpacing
|
||||
Consistent spacing system based on Material Design grid.
|
||||
|
||||
```dart
|
||||
// Spacing values
|
||||
double small = AppSpacing.sm; // 8dp
|
||||
double medium = AppSpacing.md; // 12dp
|
||||
double large = AppSpacing.lg; // 16dp
|
||||
|
||||
// EdgeInsets presets
|
||||
EdgeInsets padding = AppSpacing.paddingLG;
|
||||
EdgeInsets screenPadding = AppSpacing.screenPaddingAll;
|
||||
|
||||
// SizedBox presets
|
||||
SizedBox verticalSpace = AppSpacing.verticalSpaceMD;
|
||||
SizedBox horizontalSpace = AppSpacing.horizontalSpaceLG;
|
||||
|
||||
// Responsive padding
|
||||
EdgeInsets responsivePadding = AppSpacing.responsivePadding(context);
|
||||
|
||||
// Border radius
|
||||
BorderRadius cardRadius = AppSpacing.cardRadius;
|
||||
BorderRadius buttonRadius = AppSpacing.buttonRadius;
|
||||
|
||||
// Screen size checks
|
||||
bool isMobile = AppSpacing.isMobile(context);
|
||||
bool isTablet = AppSpacing.isTablet(context);
|
||||
```
|
||||
|
||||
## Theme Extensions
|
||||
|
||||
### AppColorsExtension
|
||||
Additional semantic colors not covered by Material 3 ColorScheme.
|
||||
|
||||
```dart
|
||||
// Access theme extension
|
||||
final colors = Theme.of(context).extension<AppColorsExtension>();
|
||||
|
||||
// Use semantic colors
|
||||
Color successColor = colors?.success ?? Colors.green;
|
||||
Color warningColor = colors?.warning ?? Colors.orange;
|
||||
Color infoColor = colors?.info ?? Colors.blue;
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Using Colors
|
||||
```dart
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
'Hello World',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### Using Typography
|
||||
```dart
|
||||
Column(
|
||||
children: [
|
||||
Text('Headline', style: Theme.of(context).textTheme.headlineMedium),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text('Body text', style: Theme.of(context).textTheme.bodyLarge),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### Using Spacing
|
||||
```dart
|
||||
Padding(
|
||||
padding: AppSpacing.responsivePadding(context),
|
||||
child: Column(
|
||||
children: [
|
||||
Card(),
|
||||
AppSpacing.verticalSpaceLG,
|
||||
ElevatedButton(),
|
||||
],
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### Theme-aware Widgets
|
||||
```dart
|
||||
class ThemedCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: AppSpacing.paddingLG,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Card Title',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text(
|
||||
'Card content that adapts to the current theme.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Material 3 Design System
|
||||
- Complete Material 3 color roles and palettes
|
||||
- Proper elevation handling with surface tints
|
||||
- Accessibility-compliant color combinations
|
||||
- Support for dynamic colors (Material You)
|
||||
|
||||
### ✅ Responsive Design
|
||||
- Typography that scales based on screen size
|
||||
- Responsive padding and margins
|
||||
- Adaptive layouts for mobile, tablet, and desktop
|
||||
- Screen size utilities and breakpoints
|
||||
|
||||
### ✅ Theme Management
|
||||
- State management integration with Riverpod
|
||||
- Theme mode persistence support
|
||||
- Smooth theme transitions
|
||||
- Multiple theme switching UI options
|
||||
|
||||
### ✅ Developer Experience
|
||||
- Type-safe theme access
|
||||
- Consistent spacing system
|
||||
- Semantic color names
|
||||
- Comprehensive documentation
|
||||
|
||||
## Customization
|
||||
|
||||
### Custom Colors
|
||||
```dart
|
||||
// Extend AppColors for your brand colors
|
||||
class BrandColors extends AppColors {
|
||||
static const Color brandPrimary = Color(0xFF1976D2);
|
||||
static const Color brandSecondary = Color(0xFF0288D1);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Typography
|
||||
```dart
|
||||
// Override typography for custom fonts
|
||||
class CustomTypography extends AppTypography {
|
||||
static const String fontFamily = 'CustomFont';
|
||||
|
||||
static TextStyle get customStyle => const TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Showcase
|
||||
|
||||
See `theme_showcase.dart` for a comprehensive demo of all theme components and how they work together.
|
||||
|
||||
---
|
||||
|
||||
This theme system provides a solid foundation for building beautiful, consistent Flutter applications that follow Material 3 design principles while remaining flexible and customizable for your specific needs.
|
||||
148
lib/core/theme/app_colors.dart
Normal file
148
lib/core/theme/app_colors.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// App color schemes for light and dark modes following Material 3 guidelines
|
||||
class AppColors {
|
||||
// Prevent instantiation
|
||||
AppColors._();
|
||||
|
||||
/// Light theme color scheme
|
||||
static const ColorScheme lightScheme = ColorScheme(
|
||||
brightness: Brightness.light,
|
||||
// Primary colors
|
||||
primary: Color(0xFF1976D2),
|
||||
onPrimary: Color(0xFFFFFFFF),
|
||||
primaryContainer: Color(0xFFBBDEFB),
|
||||
onPrimaryContainer: Color(0xFF0D47A1),
|
||||
// Secondary colors
|
||||
secondary: Color(0xFF0288D1),
|
||||
onSecondary: Color(0xFFFFFFFF),
|
||||
secondaryContainer: Color(0xFFB3E5FC),
|
||||
onSecondaryContainer: Color(0xFF006064),
|
||||
// Tertiary colors
|
||||
tertiary: Color(0xFF7B1FA2),
|
||||
onTertiary: Color(0xFFFFFFFF),
|
||||
tertiaryContainer: Color(0xFFE1BEE7),
|
||||
onTertiaryContainer: Color(0xFF4A148C),
|
||||
// Error colors
|
||||
error: Color(0xFFD32F2F),
|
||||
onError: Color(0xFFFFFFFF),
|
||||
errorContainer: Color(0xFFFFCDD2),
|
||||
onErrorContainer: Color(0xFFB71C1C),
|
||||
// Background colors
|
||||
surface: Color(0xFFFFFFFF),
|
||||
onSurface: Color(0xFF212121),
|
||||
surfaceContainerHighest: Color(0xFFF5F5F5),
|
||||
onSurfaceVariant: Color(0xFF757575),
|
||||
// Outline colors
|
||||
outline: Color(0xFFBDBDBD),
|
||||
outlineVariant: Color(0xFFE0E0E0),
|
||||
// Shadow and scrim
|
||||
shadow: Color(0xFF000000),
|
||||
scrim: Color(0xFF000000),
|
||||
// Inverse colors
|
||||
inverseSurface: Color(0xFF303030),
|
||||
onInverseSurface: Color(0xFFFFFFFF),
|
||||
inversePrimary: Color(0xFF90CAF9),
|
||||
);
|
||||
|
||||
/// Dark theme color scheme
|
||||
static const ColorScheme darkScheme = ColorScheme(
|
||||
brightness: Brightness.dark,
|
||||
// Primary colors
|
||||
primary: Color(0xFF90CAF9),
|
||||
onPrimary: Color(0xFF0D47A1),
|
||||
primaryContainer: Color(0xFF1565C0),
|
||||
onPrimaryContainer: Color(0xFFE3F2FD),
|
||||
// Secondary colors
|
||||
secondary: Color(0xFF81D4FA),
|
||||
onSecondary: Color(0xFF006064),
|
||||
secondaryContainer: Color(0xFF0277BD),
|
||||
onSecondaryContainer: Color(0xFFE0F7FA),
|
||||
// Tertiary colors
|
||||
tertiary: Color(0xFFCE93D8),
|
||||
onTertiary: Color(0xFF4A148C),
|
||||
tertiaryContainer: Color(0xFF8E24AA),
|
||||
onTertiaryContainer: Color(0xFFF3E5F5),
|
||||
// Error colors
|
||||
error: Color(0xFFEF5350),
|
||||
onError: Color(0xFFB71C1C),
|
||||
errorContainer: Color(0xFFC62828),
|
||||
onErrorContainer: Color(0xFFFFEBEE),
|
||||
// Background colors
|
||||
surface: Color(0xFF121212),
|
||||
onSurface: Color(0xFFFFFFFF),
|
||||
surfaceContainerHighest: Color(0xFF1E1E1E),
|
||||
onSurfaceVariant: Color(0xFFBDBDBD),
|
||||
// Outline colors
|
||||
outline: Color(0xFF616161),
|
||||
outlineVariant: Color(0xFF424242),
|
||||
// Shadow and scrim
|
||||
shadow: Color(0xFF000000),
|
||||
scrim: Color(0xFF000000),
|
||||
// Inverse colors
|
||||
inverseSurface: Color(0xFFE0E0E0),
|
||||
onInverseSurface: Color(0xFF303030),
|
||||
inversePrimary: Color(0xFF1976D2),
|
||||
);
|
||||
|
||||
/// Semantic colors for common use cases
|
||||
static const Color success = Color(0xFF4CAF50);
|
||||
static const Color onSuccess = Color(0xFFFFFFFF);
|
||||
static const Color successContainer = Color(0xFFC8E6C9);
|
||||
static const Color onSuccessContainer = Color(0xFF1B5E20);
|
||||
|
||||
static const Color warning = Color(0xFFFF9800);
|
||||
static const Color onWarning = Color(0xFF000000);
|
||||
static const Color warningContainer = Color(0xFFFFE0B2);
|
||||
static const Color onWarningContainer = Color(0xFFE65100);
|
||||
|
||||
static const Color info = Color(0xFF2196F3);
|
||||
static const Color onInfo = Color(0xFFFFFFFF);
|
||||
static const Color infoContainer = Color(0xFFBBDEFB);
|
||||
static const Color onInfoContainer = Color(0xFF0D47A1);
|
||||
|
||||
/// Surface elevation tints for Material 3
|
||||
static const List<Color> surfaceTintLight = [
|
||||
Color(0xFFFFFFFF), // elevation 0
|
||||
Color(0xFFFCFCFC), // elevation 1
|
||||
Color(0xFFF8F8F8), // elevation 2
|
||||
Color(0xFFF5F5F5), // elevation 3
|
||||
Color(0xFFF2F2F2), // elevation 4
|
||||
Color(0xFFEFEFEF), // elevation 5
|
||||
];
|
||||
|
||||
static const List<Color> surfaceTintDark = [
|
||||
Color(0xFF121212), // elevation 0
|
||||
Color(0xFF1E1E1E), // elevation 1
|
||||
Color(0xFF232323), // elevation 2
|
||||
Color(0xFF252525), // elevation 3
|
||||
Color(0xFF272727), // elevation 4
|
||||
Color(0xFF2C2C2C), // elevation 5
|
||||
];
|
||||
|
||||
/// Get surface color with elevation tint
|
||||
static Color getSurfaceColor(int elevation, bool isDark) {
|
||||
final tints = isDark ? surfaceTintDark : surfaceTintLight;
|
||||
final index = elevation.clamp(0, tints.length - 1);
|
||||
return tints[index];
|
||||
}
|
||||
|
||||
/// Accessibility compliant color pairs
|
||||
static final Map<Color, Color> accessiblePairs = {
|
||||
// High contrast pairs for better accessibility
|
||||
const Color(0xFF000000): const Color(0xFFFFFFFF),
|
||||
const Color(0xFFFFFFFF): const Color(0xFF000000),
|
||||
const Color(0xFF1976D2): const Color(0xFFFFFFFF),
|
||||
const Color(0xFFD32F2F): const Color(0xFFFFFFFF),
|
||||
const Color(0xFF4CAF50): const Color(0xFFFFFFFF),
|
||||
const Color(0xFFFF9800): const Color(0xFF000000),
|
||||
};
|
||||
|
||||
/// Check if color combination meets WCAG AA standards
|
||||
static bool isAccessible(Color background, Color foreground) {
|
||||
final bgLuminance = background.computeLuminance();
|
||||
final fgLuminance = foreground.computeLuminance();
|
||||
final ratio = (bgLuminance + 0.05) / (fgLuminance + 0.05);
|
||||
return ratio >= 4.5 || (1 / ratio) >= 4.5;
|
||||
}
|
||||
}
|
||||
229
lib/core/theme/app_spacing.dart
Normal file
229
lib/core/theme/app_spacing.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Consistent spacing system following Material 3 guidelines
|
||||
class AppSpacing {
|
||||
// Prevent instantiation
|
||||
AppSpacing._();
|
||||
|
||||
/// Base spacing unit (4dp in Material Design)
|
||||
static const double base = 4.0;
|
||||
|
||||
/// Spacing scale based on Material 3 8dp grid system
|
||||
static const double xs = base; // 4dp
|
||||
static const double sm = base * 2; // 8dp
|
||||
static const double md = base * 3; // 12dp
|
||||
static const double lg = base * 4; // 16dp
|
||||
static const double xl = base * 5; // 20dp
|
||||
static const double xxl = base * 6; // 24dp
|
||||
static const double xxxl = base * 8; // 32dp
|
||||
|
||||
/// Semantic spacing values
|
||||
static const double tiny = xs; // 4dp
|
||||
static const double small = sm; // 8dp
|
||||
static const double medium = lg; // 16dp
|
||||
static const double large = xxl; // 24dp
|
||||
static const double huge = xxxl; // 32dp
|
||||
|
||||
/// Screen margins and padding
|
||||
static const double screenPadding = lg; // 16dp
|
||||
static const double screenPaddingLarge = xxl; // 24dp
|
||||
static const double screenMargin = lg; // 16dp
|
||||
static const double screenMarginLarge = xxl; // 24dp
|
||||
|
||||
/// Card and container spacing
|
||||
static const double cardPadding = lg; // 16dp
|
||||
static const double cardPaddingLarge = xxl; // 24dp
|
||||
static const double cardMargin = sm; // 8dp
|
||||
static const double cardBorderRadius = md; // 12dp
|
||||
static const double cardBorderRadiusLarge = lg; // 16dp
|
||||
|
||||
/// Button spacing
|
||||
static const double buttonPadding = lg; // 16dp
|
||||
static const double buttonPaddingVertical = md; // 12dp
|
||||
static const double buttonPaddingHorizontal = xxl; // 24dp
|
||||
static const double buttonSpacing = sm; // 8dp
|
||||
static const double buttonBorderRadius = md; // 12dp
|
||||
|
||||
/// Icon spacing
|
||||
static const double iconSpacing = sm; // 8dp
|
||||
static const double iconMargin = xs; // 4dp
|
||||
|
||||
/// List item spacing
|
||||
static const double listItemPadding = lg; // 16dp
|
||||
static const double listItemSpacing = sm; // 8dp
|
||||
static const double listItemMargin = xs; // 4dp
|
||||
|
||||
/// Form field spacing
|
||||
static const double fieldSpacing = lg; // 16dp
|
||||
static const double fieldPadding = lg; // 16dp
|
||||
static const double fieldBorderRadius = sm; // 8dp
|
||||
|
||||
/// Component spacing
|
||||
static const double componentSpacing = sm; // 8dp
|
||||
static const double componentMargin = xs; // 4dp
|
||||
static const double componentPadding = md; // 12dp
|
||||
|
||||
/// Divider and border spacing
|
||||
static const double dividerSpacing = lg; // 16dp
|
||||
static const double borderWidth = 1.0;
|
||||
static const double borderWidthThin = 0.5;
|
||||
static const double borderWidthThick = 2.0;
|
||||
|
||||
/// Common EdgeInsets presets
|
||||
static const EdgeInsets paddingXS = EdgeInsets.all(xs);
|
||||
static const EdgeInsets paddingSM = EdgeInsets.all(sm);
|
||||
static const EdgeInsets paddingMD = EdgeInsets.all(md);
|
||||
static const EdgeInsets paddingLG = EdgeInsets.all(lg);
|
||||
static const EdgeInsets paddingXL = EdgeInsets.all(xl);
|
||||
static const EdgeInsets paddingXXL = EdgeInsets.all(xxl);
|
||||
static const EdgeInsets paddingXXXL = EdgeInsets.all(xxxl);
|
||||
|
||||
/// Horizontal padding presets
|
||||
static const EdgeInsets horizontalXS = EdgeInsets.symmetric(horizontal: xs);
|
||||
static const EdgeInsets horizontalSM = EdgeInsets.symmetric(horizontal: sm);
|
||||
static const EdgeInsets horizontalMD = EdgeInsets.symmetric(horizontal: md);
|
||||
static const EdgeInsets horizontalLG = EdgeInsets.symmetric(horizontal: lg);
|
||||
static const EdgeInsets horizontalXL = EdgeInsets.symmetric(horizontal: xl);
|
||||
static const EdgeInsets horizontalXXL = EdgeInsets.symmetric(horizontal: xxl);
|
||||
|
||||
/// Vertical padding presets
|
||||
static const EdgeInsets verticalXS = EdgeInsets.symmetric(vertical: xs);
|
||||
static const EdgeInsets verticalSM = EdgeInsets.symmetric(vertical: sm);
|
||||
static const EdgeInsets verticalMD = EdgeInsets.symmetric(vertical: md);
|
||||
static const EdgeInsets verticalLG = EdgeInsets.symmetric(vertical: lg);
|
||||
static const EdgeInsets verticalXL = EdgeInsets.symmetric(vertical: xl);
|
||||
static const EdgeInsets verticalXXL = EdgeInsets.symmetric(vertical: xxl);
|
||||
|
||||
/// Screen padding presets
|
||||
static const EdgeInsets screenPaddingAll = EdgeInsets.all(screenPadding);
|
||||
static const EdgeInsets screenPaddingHorizontal = EdgeInsets.symmetric(horizontal: screenPadding);
|
||||
static const EdgeInsets screenPaddingVertical = EdgeInsets.symmetric(vertical: screenPadding);
|
||||
|
||||
/// Safe area padding that respects system UI
|
||||
static EdgeInsets safeAreaPadding(BuildContext context) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
return EdgeInsets.only(
|
||||
top: mediaQuery.padding.top + screenPadding,
|
||||
bottom: mediaQuery.padding.bottom + screenPadding,
|
||||
left: mediaQuery.padding.left + screenPadding,
|
||||
right: mediaQuery.padding.right + screenPadding,
|
||||
);
|
||||
}
|
||||
|
||||
/// Responsive padding based on screen size
|
||||
static EdgeInsets responsivePadding(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
if (size.width < 600) {
|
||||
return screenPaddingAll; // Phone
|
||||
} else if (size.width < 840) {
|
||||
return const EdgeInsets.all(screenPaddingLarge); // Large phone / Small tablet
|
||||
} else {
|
||||
return const EdgeInsets.all(xxxl); // Tablet and larger
|
||||
}
|
||||
}
|
||||
|
||||
/// Responsive horizontal padding
|
||||
static EdgeInsets responsiveHorizontalPadding(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
if (size.width < 600) {
|
||||
return screenPaddingHorizontal; // Phone
|
||||
} else if (size.width < 840) {
|
||||
return const EdgeInsets.symmetric(horizontal: screenPaddingLarge); // Large phone / Small tablet
|
||||
} else {
|
||||
return const EdgeInsets.symmetric(horizontal: xxxl); // Tablet and larger
|
||||
}
|
||||
}
|
||||
|
||||
/// Common SizedBox presets for spacing
|
||||
static const SizedBox spaceXS = SizedBox(height: xs, width: xs);
|
||||
static const SizedBox spaceSM = SizedBox(height: sm, width: sm);
|
||||
static const SizedBox spaceMD = SizedBox(height: md, width: md);
|
||||
static const SizedBox spaceLG = SizedBox(height: lg, width: lg);
|
||||
static const SizedBox spaceXL = SizedBox(height: xl, width: xl);
|
||||
static const SizedBox spaceXXL = SizedBox(height: xxl, width: xxl);
|
||||
static const SizedBox spaceXXXL = SizedBox(height: xxxl, width: xxxl);
|
||||
|
||||
/// Vertical spacing
|
||||
static const SizedBox verticalSpaceXS = SizedBox(height: xs);
|
||||
static const SizedBox verticalSpaceSM = SizedBox(height: sm);
|
||||
static const SizedBox verticalSpaceMD = SizedBox(height: md);
|
||||
static const SizedBox verticalSpaceLG = SizedBox(height: lg);
|
||||
static const SizedBox verticalSpaceXL = SizedBox(height: xl);
|
||||
static const SizedBox verticalSpaceXXL = SizedBox(height: xxl);
|
||||
static const SizedBox verticalSpaceXXXL = SizedBox(height: xxxl);
|
||||
|
||||
/// Horizontal spacing
|
||||
static const SizedBox horizontalSpaceXS = SizedBox(width: xs);
|
||||
static const SizedBox horizontalSpaceSM = SizedBox(width: sm);
|
||||
static const SizedBox horizontalSpaceMD = SizedBox(width: md);
|
||||
static const SizedBox horizontalSpaceLG = SizedBox(width: lg);
|
||||
static const SizedBox horizontalSpaceXL = SizedBox(width: xl);
|
||||
static const SizedBox horizontalSpaceXXL = SizedBox(width: xxl);
|
||||
static const SizedBox horizontalSpaceXXXL = SizedBox(width: xxxl);
|
||||
|
||||
/// Border radius presets
|
||||
static BorderRadius radiusXS = BorderRadius.circular(xs);
|
||||
static BorderRadius radiusSM = BorderRadius.circular(sm);
|
||||
static BorderRadius radiusMD = BorderRadius.circular(md);
|
||||
static BorderRadius radiusLG = BorderRadius.circular(lg);
|
||||
static BorderRadius radiusXL = BorderRadius.circular(xl);
|
||||
static BorderRadius radiusXXL = BorderRadius.circular(xxl);
|
||||
|
||||
/// Component-specific border radius
|
||||
static BorderRadius get cardRadius => radiusMD;
|
||||
static BorderRadius get buttonRadius => radiusMD;
|
||||
static BorderRadius get fieldRadius => radiusSM;
|
||||
static BorderRadius get dialogRadius => radiusLG;
|
||||
static BorderRadius get sheetRadius => radiusXL;
|
||||
|
||||
/// Animation durations following Material 3
|
||||
static const Duration animationFast = Duration(milliseconds: 100);
|
||||
static const Duration animationNormal = Duration(milliseconds: 200);
|
||||
static const Duration animationSlow = Duration(milliseconds: 300);
|
||||
static const Duration animationSlower = Duration(milliseconds: 500);
|
||||
|
||||
/// Elevation values for Material 3
|
||||
static const double elevationNone = 0;
|
||||
static const double elevationLow = 1;
|
||||
static const double elevationMedium = 3;
|
||||
static const double elevationHigh = 6;
|
||||
static const double elevationHigher = 12;
|
||||
static const double elevationHighest = 24;
|
||||
|
||||
/// Icon sizes
|
||||
static const double iconXS = 16;
|
||||
static const double iconSM = 20;
|
||||
static const double iconMD = 24;
|
||||
static const double iconLG = 32;
|
||||
static const double iconXL = 40;
|
||||
static const double iconXXL = 48;
|
||||
|
||||
/// Button heights following Material 3
|
||||
static const double buttonHeightSmall = 32;
|
||||
static const double buttonHeight = 40;
|
||||
static const double buttonHeightLarge = 56;
|
||||
|
||||
/// Minimum touch target size (accessibility)
|
||||
static const double minTouchTarget = 48;
|
||||
|
||||
/// Breakpoints for responsive design
|
||||
static const double mobileBreakpoint = 600;
|
||||
static const double tabletBreakpoint = 840;
|
||||
static const double desktopBreakpoint = 1200;
|
||||
|
||||
/// Check if screen is mobile
|
||||
static bool isMobile(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width < mobileBreakpoint;
|
||||
}
|
||||
|
||||
/// Check if screen is tablet
|
||||
static bool isTablet(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
return width >= mobileBreakpoint && width < desktopBreakpoint;
|
||||
}
|
||||
|
||||
/// Check if screen is desktop
|
||||
static bool isDesktop(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width >= desktopBreakpoint;
|
||||
}
|
||||
}
|
||||
509
lib/core/theme/app_theme.dart
Normal file
509
lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,509 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'app_colors.dart';
|
||||
import 'app_typography.dart';
|
||||
import 'app_spacing.dart';
|
||||
|
||||
/// Main theme configuration with Material 3 design system
|
||||
class AppTheme {
|
||||
// Prevent instantiation
|
||||
AppTheme._();
|
||||
|
||||
/// Light theme configuration
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
// Material 3 configuration
|
||||
useMaterial3: true,
|
||||
colorScheme: AppColors.lightScheme,
|
||||
|
||||
// Typography
|
||||
textTheme: AppTypography.textTheme,
|
||||
|
||||
// App bar theme
|
||||
appBarTheme: _lightAppBarTheme,
|
||||
|
||||
// Card theme
|
||||
cardTheme: CardThemeData(
|
||||
elevation: AppSpacing.elevationLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.cardRadius,
|
||||
),
|
||||
margin: const EdgeInsets.all(AppSpacing.cardMargin),
|
||||
),
|
||||
|
||||
// Button themes
|
||||
elevatedButtonTheme: _elevatedButtonTheme,
|
||||
filledButtonTheme: _filledButtonTheme,
|
||||
outlinedButtonTheme: _outlinedButtonTheme,
|
||||
textButtonTheme: _textButtonTheme,
|
||||
iconButtonTheme: _iconButtonTheme,
|
||||
floatingActionButtonTheme: _lightFabTheme,
|
||||
|
||||
// Input field themes
|
||||
inputDecorationTheme: _inputDecorationTheme,
|
||||
|
||||
// Other component themes
|
||||
bottomNavigationBarTheme: _lightBottomNavTheme,
|
||||
navigationBarTheme: _lightNavigationBarTheme,
|
||||
navigationRailTheme: _lightNavigationRailTheme,
|
||||
drawerTheme: _lightDrawerTheme,
|
||||
dialogTheme: DialogThemeData(
|
||||
elevation: AppSpacing.elevationHigher,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.dialogRadius,
|
||||
),
|
||||
titleTextStyle: AppTypography.headlineSmall,
|
||||
contentTextStyle: AppTypography.bodyMedium,
|
||||
),
|
||||
bottomSheetTheme: _bottomSheetTheme,
|
||||
snackBarTheme: _snackBarTheme,
|
||||
chipTheme: _lightChipTheme,
|
||||
dividerTheme: _dividerTheme,
|
||||
listTileTheme: _listTileTheme,
|
||||
switchTheme: _lightSwitchTheme,
|
||||
checkboxTheme: _lightCheckboxTheme,
|
||||
radioTheme: _lightRadioTheme,
|
||||
sliderTheme: _lightSliderTheme,
|
||||
progressIndicatorTheme: _progressIndicatorTheme,
|
||||
|
||||
// Extensions
|
||||
extensions: const [
|
||||
AppColorsExtension.light,
|
||||
],
|
||||
|
||||
// Visual density
|
||||
visualDensity: VisualDensity.adaptivePlatformDensity,
|
||||
|
||||
// Material tap target size
|
||||
materialTapTargetSize: MaterialTapTargetSize.padded,
|
||||
|
||||
// Page transitions
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
|
||||
// Splash factory
|
||||
splashFactory: InkRipple.splashFactory,
|
||||
);
|
||||
}
|
||||
|
||||
/// Dark theme configuration
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
// Material 3 configuration
|
||||
useMaterial3: true,
|
||||
colorScheme: AppColors.darkScheme,
|
||||
|
||||
// Typography
|
||||
textTheme: AppTypography.textTheme,
|
||||
|
||||
// App bar theme
|
||||
appBarTheme: _darkAppBarTheme,
|
||||
|
||||
// Card theme
|
||||
cardTheme: CardThemeData(
|
||||
elevation: AppSpacing.elevationLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.cardRadius,
|
||||
),
|
||||
margin: const EdgeInsets.all(AppSpacing.cardMargin),
|
||||
),
|
||||
|
||||
// Button themes
|
||||
elevatedButtonTheme: _elevatedButtonTheme,
|
||||
filledButtonTheme: _filledButtonTheme,
|
||||
outlinedButtonTheme: _outlinedButtonTheme,
|
||||
textButtonTheme: _textButtonTheme,
|
||||
iconButtonTheme: _iconButtonTheme,
|
||||
floatingActionButtonTheme: _darkFabTheme,
|
||||
|
||||
// Input field themes
|
||||
inputDecorationTheme: _inputDecorationTheme,
|
||||
|
||||
// Other component themes
|
||||
bottomNavigationBarTheme: _darkBottomNavTheme,
|
||||
navigationBarTheme: _darkNavigationBarTheme,
|
||||
navigationRailTheme: _darkNavigationRailTheme,
|
||||
drawerTheme: _darkDrawerTheme,
|
||||
dialogTheme: DialogThemeData(
|
||||
elevation: AppSpacing.elevationHigher,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.dialogRadius,
|
||||
),
|
||||
titleTextStyle: AppTypography.headlineSmall,
|
||||
contentTextStyle: AppTypography.bodyMedium,
|
||||
),
|
||||
bottomSheetTheme: _bottomSheetTheme,
|
||||
snackBarTheme: _snackBarTheme,
|
||||
chipTheme: _darkChipTheme,
|
||||
dividerTheme: _dividerTheme,
|
||||
listTileTheme: _listTileTheme,
|
||||
switchTheme: _darkSwitchTheme,
|
||||
checkboxTheme: _darkCheckboxTheme,
|
||||
radioTheme: _darkRadioTheme,
|
||||
sliderTheme: _darkSliderTheme,
|
||||
progressIndicatorTheme: _progressIndicatorTheme,
|
||||
|
||||
// Extensions
|
||||
extensions: const [
|
||||
AppColorsExtension.dark,
|
||||
],
|
||||
|
||||
// Visual density
|
||||
visualDensity: VisualDensity.adaptivePlatformDensity,
|
||||
|
||||
// Material tap target size
|
||||
materialTapTargetSize: MaterialTapTargetSize.padded,
|
||||
|
||||
// Page transitions
|
||||
pageTransitionsTheme: _pageTransitionsTheme,
|
||||
|
||||
// Splash factory
|
||||
splashFactory: InkRipple.splashFactory,
|
||||
);
|
||||
}
|
||||
|
||||
/// System UI overlay styles
|
||||
static const SystemUiOverlayStyle lightSystemUiOverlay = SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Colors.white,
|
||||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
);
|
||||
|
||||
static const SystemUiOverlayStyle darkSystemUiOverlay = SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
systemNavigationBarColor: Color(0xFF121212),
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
);
|
||||
|
||||
// Private theme components
|
||||
|
||||
static AppBarTheme get _lightAppBarTheme => const AppBarTheme(
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.black87,
|
||||
centerTitle: false,
|
||||
systemOverlayStyle: lightSystemUiOverlay,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black87,
|
||||
),
|
||||
);
|
||||
|
||||
static AppBarTheme get _darkAppBarTheme => const AppBarTheme(
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: Colors.white,
|
||||
centerTitle: false,
|
||||
systemOverlayStyle: darkSystemUiOverlay,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
|
||||
static ElevatedButtonThemeData get _elevatedButtonTheme => ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(0, AppSpacing.buttonHeight),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.buttonPaddingHorizontal,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.buttonRadius,
|
||||
),
|
||||
textStyle: AppTypography.buttonText,
|
||||
elevation: AppSpacing.elevationLow,
|
||||
),
|
||||
);
|
||||
|
||||
static FilledButtonThemeData get _filledButtonTheme => FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, AppSpacing.buttonHeight),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.buttonPaddingHorizontal,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.buttonRadius,
|
||||
),
|
||||
textStyle: AppTypography.buttonText,
|
||||
),
|
||||
);
|
||||
|
||||
static OutlinedButtonThemeData get _outlinedButtonTheme => OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, AppSpacing.buttonHeight),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.buttonPaddingHorizontal,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.buttonRadius,
|
||||
),
|
||||
textStyle: AppTypography.buttonText,
|
||||
side: const BorderSide(width: AppSpacing.borderWidth),
|
||||
),
|
||||
);
|
||||
|
||||
static TextButtonThemeData get _textButtonTheme => TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(0, AppSpacing.buttonHeight),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.buttonPaddingHorizontal,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.buttonRadius,
|
||||
),
|
||||
textStyle: AppTypography.buttonText,
|
||||
),
|
||||
);
|
||||
|
||||
static IconButtonThemeData get _iconButtonTheme => IconButtonThemeData(
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
|
||||
iconSize: AppSpacing.iconMD,
|
||||
),
|
||||
);
|
||||
|
||||
static FloatingActionButtonThemeData get _lightFabTheme => FloatingActionButtonThemeData(
|
||||
elevation: AppSpacing.elevationMedium,
|
||||
highlightElevation: AppSpacing.elevationHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.radiusLG,
|
||||
),
|
||||
);
|
||||
|
||||
static FloatingActionButtonThemeData get _darkFabTheme => FloatingActionButtonThemeData(
|
||||
elevation: AppSpacing.elevationMedium,
|
||||
highlightElevation: AppSpacing.elevationHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.radiusLG,
|
||||
),
|
||||
);
|
||||
|
||||
static InputDecorationTheme get _inputDecorationTheme => InputDecorationTheme(
|
||||
filled: true,
|
||||
contentPadding: const EdgeInsets.all(AppSpacing.fieldPadding),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: AppSpacing.fieldRadius,
|
||||
borderSide: const BorderSide(width: AppSpacing.borderWidth),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: AppSpacing.fieldRadius,
|
||||
borderSide: const BorderSide(width: AppSpacing.borderWidth),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: AppSpacing.fieldRadius,
|
||||
borderSide: const BorderSide(width: AppSpacing.borderWidthThick),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: AppSpacing.fieldRadius,
|
||||
borderSide: const BorderSide(width: AppSpacing.borderWidth),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: AppSpacing.fieldRadius,
|
||||
borderSide: const BorderSide(width: AppSpacing.borderWidthThick),
|
||||
),
|
||||
errorStyle: AppTypography.errorText,
|
||||
hintStyle: AppTypography.hintText,
|
||||
labelStyle: AppTypography.bodyMedium,
|
||||
);
|
||||
|
||||
static BottomNavigationBarThemeData get _lightBottomNavTheme => const BottomNavigationBarThemeData(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: AppSpacing.elevationMedium,
|
||||
selectedLabelStyle: AppTypography.labelSmall,
|
||||
unselectedLabelStyle: AppTypography.labelSmall,
|
||||
);
|
||||
|
||||
static BottomNavigationBarThemeData get _darkBottomNavTheme => const BottomNavigationBarThemeData(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: AppSpacing.elevationMedium,
|
||||
selectedLabelStyle: AppTypography.labelSmall,
|
||||
unselectedLabelStyle: AppTypography.labelSmall,
|
||||
);
|
||||
|
||||
static NavigationBarThemeData get _lightNavigationBarTheme => NavigationBarThemeData(
|
||||
height: 80,
|
||||
elevation: AppSpacing.elevationMedium,
|
||||
labelTextStyle: WidgetStateProperty.all(AppTypography.labelSmall),
|
||||
);
|
||||
|
||||
static NavigationBarThemeData get _darkNavigationBarTheme => NavigationBarThemeData(
|
||||
height: 80,
|
||||
elevation: AppSpacing.elevationMedium,
|
||||
labelTextStyle: WidgetStateProperty.all(AppTypography.labelSmall),
|
||||
);
|
||||
|
||||
static NavigationRailThemeData get _lightNavigationRailTheme => const NavigationRailThemeData(
|
||||
elevation: AppSpacing.elevationMedium,
|
||||
labelType: NavigationRailLabelType.selected,
|
||||
);
|
||||
|
||||
static NavigationRailThemeData get _darkNavigationRailTheme => const NavigationRailThemeData(
|
||||
elevation: AppSpacing.elevationMedium,
|
||||
labelType: NavigationRailLabelType.selected,
|
||||
);
|
||||
|
||||
static DrawerThemeData get _lightDrawerTheme => DrawerThemeData(
|
||||
elevation: AppSpacing.elevationHigh,
|
||||
shape: const RoundedRectangleBorder(),
|
||||
);
|
||||
|
||||
static DrawerThemeData get _darkDrawerTheme => DrawerThemeData(
|
||||
elevation: AppSpacing.elevationHigh,
|
||||
shape: const RoundedRectangleBorder(),
|
||||
);
|
||||
|
||||
static BottomSheetThemeData get _bottomSheetTheme => BottomSheetThemeData(
|
||||
elevation: AppSpacing.elevationHigh,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(AppSpacing.sheetRadius.topLeft.x),
|
||||
topRight: Radius.circular(AppSpacing.sheetRadius.topRight.x),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
static SnackBarThemeData get _snackBarTheme => SnackBarThemeData(
|
||||
elevation: AppSpacing.elevationMedium,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.radiusSM,
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
contentTextStyle: AppTypography.bodyMedium,
|
||||
);
|
||||
|
||||
static ChipThemeData get _lightChipTheme => ChipThemeData(
|
||||
elevation: AppSpacing.elevationLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.radiusSM,
|
||||
),
|
||||
labelStyle: AppTypography.labelMedium,
|
||||
);
|
||||
|
||||
static ChipThemeData get _darkChipTheme => ChipThemeData(
|
||||
elevation: AppSpacing.elevationLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.radiusSM,
|
||||
),
|
||||
labelStyle: AppTypography.labelMedium,
|
||||
);
|
||||
|
||||
static const DividerThemeData _dividerTheme = DividerThemeData(
|
||||
thickness: AppSpacing.borderWidth,
|
||||
space: AppSpacing.dividerSpacing,
|
||||
);
|
||||
|
||||
static ListTileThemeData get _listTileTheme => const ListTileThemeData(
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.listItemPadding,
|
||||
vertical: AppSpacing.listItemMargin,
|
||||
),
|
||||
titleTextStyle: AppTypography.titleMedium,
|
||||
subtitleTextStyle: AppTypography.bodyMedium,
|
||||
);
|
||||
|
||||
static SwitchThemeData get _lightSwitchTheme => SwitchThemeData(
|
||||
thumbIcon: WidgetStateProperty.resolveWith<Icon?>(
|
||||
(Set<WidgetState> states) => null,
|
||||
),
|
||||
);
|
||||
|
||||
static SwitchThemeData get _darkSwitchTheme => SwitchThemeData(
|
||||
thumbIcon: WidgetStateProperty.resolveWith<Icon?>(
|
||||
(Set<WidgetState> states) => null,
|
||||
),
|
||||
);
|
||||
|
||||
static CheckboxThemeData get _lightCheckboxTheme => CheckboxThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.radiusXS,
|
||||
),
|
||||
);
|
||||
|
||||
static CheckboxThemeData get _darkCheckboxTheme => CheckboxThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.radiusXS,
|
||||
),
|
||||
);
|
||||
|
||||
static RadioThemeData get _lightRadioTheme => const RadioThemeData();
|
||||
|
||||
static RadioThemeData get _darkRadioTheme => const RadioThemeData();
|
||||
|
||||
static SliderThemeData get _lightSliderTheme => const SliderThemeData(
|
||||
trackHeight: 4,
|
||||
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 10),
|
||||
);
|
||||
|
||||
static SliderThemeData get _darkSliderTheme => const SliderThemeData(
|
||||
trackHeight: 4,
|
||||
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 10),
|
||||
);
|
||||
|
||||
static const ProgressIndicatorThemeData _progressIndicatorTheme = ProgressIndicatorThemeData(
|
||||
linearTrackColor: Colors.transparent,
|
||||
);
|
||||
|
||||
static const PageTransitionsTheme _pageTransitionsTheme = PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: PredictiveBackPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
|
||||
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
|
||||
},
|
||||
);
|
||||
|
||||
/// Create responsive theme based on screen size
|
||||
static ThemeData responsiveTheme(BuildContext context, {required bool isDark}) {
|
||||
final baseTheme = isDark ? darkTheme : lightTheme;
|
||||
final responsiveTextTheme = AppTypography.responsiveTextTheme(context);
|
||||
|
||||
return baseTheme.copyWith(
|
||||
textTheme: responsiveTextTheme,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get appropriate system UI overlay style based on theme
|
||||
static SystemUiOverlayStyle getSystemUiOverlayStyle(bool isDark) {
|
||||
return isDark ? darkSystemUiOverlay : lightSystemUiOverlay;
|
||||
}
|
||||
|
||||
/// Dynamic color scheme support
|
||||
static ColorScheme? dynamicLightColorScheme(BuildContext context) {
|
||||
try {
|
||||
return ColorScheme.fromSeed(
|
||||
seedColor: AppColors.lightScheme.primary,
|
||||
brightness: Brightness.light,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static ColorScheme? dynamicDarkColorScheme(BuildContext context) {
|
||||
try {
|
||||
return ColorScheme.fromSeed(
|
||||
seedColor: AppColors.darkScheme.primary,
|
||||
brightness: Brightness.dark,
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create theme with dynamic colors
|
||||
static ThemeData createDynamicTheme({
|
||||
required ColorScheme colorScheme,
|
||||
required bool isDark,
|
||||
}) {
|
||||
final baseTheme = isDark ? darkTheme : lightTheme;
|
||||
return baseTheme.copyWith(colorScheme: colorScheme);
|
||||
}
|
||||
}
|
||||
381
lib/core/theme/app_typography.dart
Normal file
381
lib/core/theme/app_typography.dart
Normal file
@@ -0,0 +1,381 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Typography system following Material 3 guidelines
|
||||
class AppTypography {
|
||||
// Prevent instantiation
|
||||
AppTypography._();
|
||||
|
||||
/// Base font family
|
||||
static const String fontFamily = 'Roboto';
|
||||
|
||||
/// Display styles - Largest, reserved for short, important text
|
||||
static const TextStyle displayLarge = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 57,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: -0.25,
|
||||
height: 1.12,
|
||||
);
|
||||
|
||||
static const TextStyle displayMedium = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 45,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
height: 1.16,
|
||||
);
|
||||
|
||||
static const TextStyle displaySmall = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
height: 1.22,
|
||||
);
|
||||
|
||||
/// Headline styles - High-emphasis text for headings
|
||||
static const TextStyle headlineLarge = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
height: 1.25,
|
||||
);
|
||||
|
||||
static const TextStyle headlineMedium = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
height: 1.29,
|
||||
);
|
||||
|
||||
static const TextStyle headlineSmall = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
height: 1.33,
|
||||
);
|
||||
|
||||
/// Title styles - Medium-emphasis text for titles
|
||||
static const TextStyle titleLarge = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0,
|
||||
height: 1.27,
|
||||
);
|
||||
|
||||
static const TextStyle titleMedium = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.15,
|
||||
height: 1.50,
|
||||
);
|
||||
|
||||
static const TextStyle titleSmall = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.43,
|
||||
);
|
||||
|
||||
/// Label styles - Small text for labels and captions
|
||||
static const TextStyle labelLarge = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.43,
|
||||
);
|
||||
|
||||
static const TextStyle labelMedium = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.33,
|
||||
);
|
||||
|
||||
static const TextStyle labelSmall = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.5,
|
||||
height: 1.45,
|
||||
);
|
||||
|
||||
/// Body styles - Used for long-form writing
|
||||
static const TextStyle bodyLarge = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.15,
|
||||
height: 1.50,
|
||||
);
|
||||
|
||||
static const TextStyle bodyMedium = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.25,
|
||||
height: 1.43,
|
||||
);
|
||||
|
||||
static const TextStyle bodySmall = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.4,
|
||||
height: 1.33,
|
||||
);
|
||||
|
||||
/// Create a complete TextTheme for the app
|
||||
static TextTheme textTheme = const TextTheme(
|
||||
// Display styles
|
||||
displayLarge: displayLarge,
|
||||
displayMedium: displayMedium,
|
||||
displaySmall: displaySmall,
|
||||
// Headline styles
|
||||
headlineLarge: headlineLarge,
|
||||
headlineMedium: headlineMedium,
|
||||
headlineSmall: headlineSmall,
|
||||
// Title styles
|
||||
titleLarge: titleLarge,
|
||||
titleMedium: titleMedium,
|
||||
titleSmall: titleSmall,
|
||||
// Label styles
|
||||
labelLarge: labelLarge,
|
||||
labelMedium: labelMedium,
|
||||
labelSmall: labelSmall,
|
||||
// Body styles
|
||||
bodyLarge: bodyLarge,
|
||||
bodyMedium: bodyMedium,
|
||||
bodySmall: bodySmall,
|
||||
);
|
||||
|
||||
/// Responsive typography that scales based on screen size
|
||||
static TextTheme responsiveTextTheme(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final scaleFactor = _getScaleFactor(size.width);
|
||||
|
||||
return TextTheme(
|
||||
displayLarge: displayLarge.copyWith(fontSize: displayLarge.fontSize! * scaleFactor),
|
||||
displayMedium: displayMedium.copyWith(fontSize: displayMedium.fontSize! * scaleFactor),
|
||||
displaySmall: displaySmall.copyWith(fontSize: displaySmall.fontSize! * scaleFactor),
|
||||
headlineLarge: headlineLarge.copyWith(fontSize: headlineLarge.fontSize! * scaleFactor),
|
||||
headlineMedium: headlineMedium.copyWith(fontSize: headlineMedium.fontSize! * scaleFactor),
|
||||
headlineSmall: headlineSmall.copyWith(fontSize: headlineSmall.fontSize! * scaleFactor),
|
||||
titleLarge: titleLarge.copyWith(fontSize: titleLarge.fontSize! * scaleFactor),
|
||||
titleMedium: titleMedium.copyWith(fontSize: titleMedium.fontSize! * scaleFactor),
|
||||
titleSmall: titleSmall.copyWith(fontSize: titleSmall.fontSize! * scaleFactor),
|
||||
labelLarge: labelLarge.copyWith(fontSize: labelLarge.fontSize! * scaleFactor),
|
||||
labelMedium: labelMedium.copyWith(fontSize: labelMedium.fontSize! * scaleFactor),
|
||||
labelSmall: labelSmall.copyWith(fontSize: labelSmall.fontSize! * scaleFactor),
|
||||
bodyLarge: bodyLarge.copyWith(fontSize: bodyLarge.fontSize! * scaleFactor),
|
||||
bodyMedium: bodyMedium.copyWith(fontSize: bodyMedium.fontSize! * scaleFactor),
|
||||
bodySmall: bodySmall.copyWith(fontSize: bodySmall.fontSize! * scaleFactor),
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate scale factor based on screen width
|
||||
static double _getScaleFactor(double width) {
|
||||
if (width < 360) {
|
||||
return 0.9; // Small phones
|
||||
} else if (width < 600) {
|
||||
return 1.0; // Normal phones
|
||||
} else if (width < 840) {
|
||||
return 1.1; // Large phones / small tablets
|
||||
} else {
|
||||
return 1.2; // Tablets and larger
|
||||
}
|
||||
}
|
||||
|
||||
/// Font weight extensions for better readability
|
||||
static const FontWeight thin = FontWeight.w100;
|
||||
static const FontWeight extraLight = FontWeight.w200;
|
||||
static const FontWeight light = FontWeight.w300;
|
||||
static const FontWeight regular = FontWeight.w400;
|
||||
static const FontWeight medium = FontWeight.w500;
|
||||
static const FontWeight semiBold = FontWeight.w600;
|
||||
static const FontWeight bold = FontWeight.w700;
|
||||
static const FontWeight extraBold = FontWeight.w800;
|
||||
static const FontWeight black = FontWeight.w900;
|
||||
|
||||
/// Common text styles for specific use cases
|
||||
static const TextStyle buttonText = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.43,
|
||||
);
|
||||
|
||||
static const TextStyle captionText = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.4,
|
||||
height: 1.33,
|
||||
);
|
||||
|
||||
static const TextStyle overlineText = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 1.5,
|
||||
height: 1.6,
|
||||
);
|
||||
|
||||
static const TextStyle errorText = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.4,
|
||||
height: 1.33,
|
||||
);
|
||||
|
||||
static const TextStyle hintText = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.15,
|
||||
height: 1.50,
|
||||
);
|
||||
|
||||
/// Text styles with semantic colors
|
||||
static TextStyle success(BuildContext context) => bodyMedium.copyWith(
|
||||
color: Theme.of(context).extension<AppColorsExtension>()?.success,
|
||||
);
|
||||
|
||||
static TextStyle warning(BuildContext context) => bodyMedium.copyWith(
|
||||
color: Theme.of(context).extension<AppColorsExtension>()?.warning,
|
||||
);
|
||||
|
||||
static TextStyle error(BuildContext context) => bodyMedium.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
);
|
||||
|
||||
static TextStyle info(BuildContext context) => bodyMedium.copyWith(
|
||||
color: Theme.of(context).extension<AppColorsExtension>()?.info,
|
||||
);
|
||||
}
|
||||
|
||||
/// Theme extension for custom semantic colors
|
||||
@immutable
|
||||
class AppColorsExtension extends ThemeExtension<AppColorsExtension> {
|
||||
final Color? success;
|
||||
final Color? onSuccess;
|
||||
final Color? successContainer;
|
||||
final Color? onSuccessContainer;
|
||||
final Color? warning;
|
||||
final Color? onWarning;
|
||||
final Color? warningContainer;
|
||||
final Color? onWarningContainer;
|
||||
final Color? info;
|
||||
final Color? onInfo;
|
||||
final Color? infoContainer;
|
||||
final Color? onInfoContainer;
|
||||
|
||||
const AppColorsExtension({
|
||||
this.success,
|
||||
this.onSuccess,
|
||||
this.successContainer,
|
||||
this.onSuccessContainer,
|
||||
this.warning,
|
||||
this.onWarning,
|
||||
this.warningContainer,
|
||||
this.onWarningContainer,
|
||||
this.info,
|
||||
this.onInfo,
|
||||
this.infoContainer,
|
||||
this.onInfoContainer,
|
||||
});
|
||||
|
||||
@override
|
||||
AppColorsExtension copyWith({
|
||||
Color? success,
|
||||
Color? onSuccess,
|
||||
Color? successContainer,
|
||||
Color? onSuccessContainer,
|
||||
Color? warning,
|
||||
Color? onWarning,
|
||||
Color? warningContainer,
|
||||
Color? onWarningContainer,
|
||||
Color? info,
|
||||
Color? onInfo,
|
||||
Color? infoContainer,
|
||||
Color? onInfoContainer,
|
||||
}) {
|
||||
return AppColorsExtension(
|
||||
success: success ?? this.success,
|
||||
onSuccess: onSuccess ?? this.onSuccess,
|
||||
successContainer: successContainer ?? this.successContainer,
|
||||
onSuccessContainer: onSuccessContainer ?? this.onSuccessContainer,
|
||||
warning: warning ?? this.warning,
|
||||
onWarning: onWarning ?? this.onWarning,
|
||||
warningContainer: warningContainer ?? this.warningContainer,
|
||||
onWarningContainer: onWarningContainer ?? this.onWarningContainer,
|
||||
info: info ?? this.info,
|
||||
onInfo: onInfo ?? this.onInfo,
|
||||
infoContainer: infoContainer ?? this.infoContainer,
|
||||
onInfoContainer: onInfoContainer ?? this.onInfoContainer,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AppColorsExtension lerp(covariant ThemeExtension<AppColorsExtension>? other, double t) {
|
||||
if (other is! AppColorsExtension) {
|
||||
return this;
|
||||
}
|
||||
return AppColorsExtension(
|
||||
success: Color.lerp(success, other.success, t),
|
||||
onSuccess: Color.lerp(onSuccess, other.onSuccess, t),
|
||||
successContainer: Color.lerp(successContainer, other.successContainer, t),
|
||||
onSuccessContainer: Color.lerp(onSuccessContainer, other.onSuccessContainer, t),
|
||||
warning: Color.lerp(warning, other.warning, t),
|
||||
onWarning: Color.lerp(onWarning, other.onWarning, t),
|
||||
warningContainer: Color.lerp(warningContainer, other.warningContainer, t),
|
||||
onWarningContainer: Color.lerp(onWarningContainer, other.onWarningContainer, t),
|
||||
info: Color.lerp(info, other.info, t),
|
||||
onInfo: Color.lerp(onInfo, other.onInfo, t),
|
||||
infoContainer: Color.lerp(infoContainer, other.infoContainer, t),
|
||||
onInfoContainer: Color.lerp(onInfoContainer, other.onInfoContainer, t),
|
||||
);
|
||||
}
|
||||
|
||||
/// Light theme extension
|
||||
static const AppColorsExtension light = AppColorsExtension(
|
||||
success: Color(0xFF4CAF50),
|
||||
onSuccess: Color(0xFFFFFFFF),
|
||||
successContainer: Color(0xFFC8E6C9),
|
||||
onSuccessContainer: Color(0xFF1B5E20),
|
||||
warning: Color(0xFFFF9800),
|
||||
onWarning: Color(0xFF000000),
|
||||
warningContainer: Color(0xFFFFE0B2),
|
||||
onWarningContainer: Color(0xFFE65100),
|
||||
info: Color(0xFF2196F3),
|
||||
onInfo: Color(0xFFFFFFFF),
|
||||
infoContainer: Color(0xFFBBDEFB),
|
||||
onInfoContainer: Color(0xFF0D47A1),
|
||||
);
|
||||
|
||||
/// Dark theme extension
|
||||
static const AppColorsExtension dark = AppColorsExtension(
|
||||
success: Color(0xFF66BB6A),
|
||||
onSuccess: Color(0xFF1B5E20),
|
||||
successContainer: Color(0xFF2E7D32),
|
||||
onSuccessContainer: Color(0xFFC8E6C9),
|
||||
warning: Color(0xFFFFB74D),
|
||||
onWarning: Color(0xFFE65100),
|
||||
warningContainer: Color(0xFFF57C00),
|
||||
onWarningContainer: Color(0xFFFFE0B2),
|
||||
info: Color(0xFF42A5F5),
|
||||
onInfo: Color(0xFF0D47A1),
|
||||
infoContainer: Color(0xFF1976D2),
|
||||
onInfoContainer: Color(0xFFBBDEFB),
|
||||
);
|
||||
}
|
||||
19
lib/core/theme/theme.dart
Normal file
19
lib/core/theme/theme.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
// Theme system barrel file for easy imports
|
||||
//
|
||||
// This file exports all theme-related components for the Material 3 design system.
|
||||
// Import this file to get access to all theme utilities, colors, typography, and spacing.
|
||||
|
||||
// Core theme configuration
|
||||
export 'app_theme.dart';
|
||||
|
||||
// Color system
|
||||
export 'app_colors.dart';
|
||||
|
||||
// Typography system
|
||||
export 'app_typography.dart';
|
||||
|
||||
// Spacing and layout system
|
||||
export 'app_spacing.dart';
|
||||
|
||||
// Theme widgets
|
||||
export 'widgets/theme_mode_switch.dart';
|
||||
398
lib/core/theme/theme_showcase.dart
Normal file
398
lib/core/theme/theme_showcase.dart
Normal file
@@ -0,0 +1,398 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Theme showcase page demonstrating Material 3 design system
|
||||
class ThemeShowcasePage extends ConsumerWidget {
|
||||
/// Creates a theme showcase page
|
||||
const ThemeShowcasePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final textTheme = theme.textTheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Material 3 Theme Showcase'),
|
||||
actions: const [
|
||||
ThemeToggleIconButton(),
|
||||
SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: AppSpacing.responsivePadding(context),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Theme Mode Switch Section
|
||||
_buildSection(
|
||||
context,
|
||||
'Theme Mode Switch',
|
||||
Column(
|
||||
children: [
|
||||
const ThemeModeSwitch(
|
||||
style: ThemeSwitchStyle.segmented,
|
||||
showLabel: true,
|
||||
),
|
||||
AppSpacing.verticalSpaceLG,
|
||||
const AnimatedThemeModeSwitch(),
|
||||
AppSpacing.verticalSpaceLG,
|
||||
const ThemeModeSwitch(
|
||||
style: ThemeSwitchStyle.toggle,
|
||||
showLabel: true,
|
||||
labelText: 'Dark Mode',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Typography Section
|
||||
_buildSection(
|
||||
context,
|
||||
'Typography',
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Display Large', style: textTheme.displayLarge),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text('Display Medium', style: textTheme.displayMedium),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text('Display Small', style: textTheme.displaySmall),
|
||||
AppSpacing.verticalSpaceMD,
|
||||
Text('Headline Large', style: textTheme.headlineLarge),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text('Headline Medium', style: textTheme.headlineMedium),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text('Headline Small', style: textTheme.headlineSmall),
|
||||
AppSpacing.verticalSpaceMD,
|
||||
Text('Title Large', style: textTheme.titleLarge),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text('Title Medium', style: textTheme.titleMedium),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text('Title Small', style: textTheme.titleSmall),
|
||||
AppSpacing.verticalSpaceMD,
|
||||
Text('Body Large', style: textTheme.bodyLarge),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text('Body Medium', style: textTheme.bodyMedium),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text('Body Small', style: textTheme.bodySmall),
|
||||
AppSpacing.verticalSpaceMD,
|
||||
Text('Label Large', style: textTheme.labelLarge),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text('Label Medium', style: textTheme.labelMedium),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text('Label Small', style: textTheme.labelSmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Color Palette Section
|
||||
_buildSection(
|
||||
context,
|
||||
'Color Palette',
|
||||
Column(
|
||||
children: [
|
||||
_buildColorRow(
|
||||
context,
|
||||
'Primary',
|
||||
colorScheme.primary,
|
||||
colorScheme.onPrimary,
|
||||
),
|
||||
_buildColorRow(
|
||||
context,
|
||||
'Primary Container',
|
||||
colorScheme.primaryContainer,
|
||||
colorScheme.onPrimaryContainer,
|
||||
),
|
||||
_buildColorRow(
|
||||
context,
|
||||
'Secondary',
|
||||
colorScheme.secondary,
|
||||
colorScheme.onSecondary,
|
||||
),
|
||||
_buildColorRow(
|
||||
context,
|
||||
'Secondary Container',
|
||||
colorScheme.secondaryContainer,
|
||||
colorScheme.onSecondaryContainer,
|
||||
),
|
||||
_buildColorRow(
|
||||
context,
|
||||
'Tertiary',
|
||||
colorScheme.tertiary,
|
||||
colorScheme.onTertiary,
|
||||
),
|
||||
_buildColorRow(
|
||||
context,
|
||||
'Error',
|
||||
colorScheme.error,
|
||||
colorScheme.onError,
|
||||
),
|
||||
_buildColorRow(
|
||||
context,
|
||||
'Surface',
|
||||
colorScheme.surface,
|
||||
colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Buttons Section
|
||||
_buildSection(
|
||||
context,
|
||||
'Buttons',
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Filled Button'),
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
ElevatedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Elevated Button'),
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Outlined Button'),
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Text Button'),
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.favorite),
|
||||
),
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
IconButton.filled(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.favorite),
|
||||
),
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
IconButton.outlined(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.favorite),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Form Components Section
|
||||
_buildSection(
|
||||
context,
|
||||
'Form Components',
|
||||
Column(
|
||||
children: [
|
||||
const TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Label',
|
||||
hintText: 'Hint text',
|
||||
helperText: 'Helper text',
|
||||
),
|
||||
),
|
||||
AppSpacing.verticalSpaceLG,
|
||||
const TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Error state',
|
||||
errorText: 'Error message',
|
||||
prefixIcon: Icon(Icons.error),
|
||||
),
|
||||
),
|
||||
AppSpacing.verticalSpaceLG,
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: true,
|
||||
onChanged: (value) {},
|
||||
),
|
||||
const Text('Checkbox'),
|
||||
AppSpacing.horizontalSpaceLG,
|
||||
Radio<bool>(
|
||||
value: true,
|
||||
groupValue: true,
|
||||
onChanged: null,
|
||||
),
|
||||
const Text('Radio'),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: true,
|
||||
onChanged: (value) {},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Cards Section
|
||||
_buildSection(
|
||||
context,
|
||||
'Cards',
|
||||
Column(
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: AppSpacing.paddingLG,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Card Title',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text(
|
||||
'Card content with some descriptive text that demonstrates how cards look in the Material 3 design system.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Action'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Card.filled(
|
||||
child: Padding(
|
||||
padding: AppSpacing.paddingLG,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Filled Card',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text(
|
||||
'This is a filled card variant.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Spacing Demonstration
|
||||
_buildSection(
|
||||
context,
|
||||
'Spacing System',
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('XS (4dp)', style: theme.textTheme.bodyMedium),
|
||||
Container(
|
||||
height: 2,
|
||||
width: AppSpacing.xs,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text('SM (8dp)', style: theme.textTheme.bodyMedium),
|
||||
Container(
|
||||
height: 2,
|
||||
width: AppSpacing.sm,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text('MD (12dp)', style: theme.textTheme.bodyMedium),
|
||||
Container(
|
||||
height: 2,
|
||||
width: AppSpacing.md,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text('LG (16dp)', style: theme.textTheme.bodyMedium),
|
||||
Container(
|
||||
height: 2,
|
||||
width: AppSpacing.lg,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text('XL (20dp)', style: theme.textTheme.bodyMedium),
|
||||
Container(
|
||||
height: 2,
|
||||
width: AppSpacing.xl,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
AppSpacing.verticalSpaceSM,
|
||||
Text('XXL (24dp)', style: theme.textTheme.bodyMedium),
|
||||
Container(
|
||||
height: 2,
|
||||
width: AppSpacing.xxl,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection(BuildContext context, String title, Widget content) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
AppSpacing.verticalSpaceLG,
|
||||
content,
|
||||
AppSpacing.verticalSpaceXXL,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildColorRow(
|
||||
BuildContext context,
|
||||
String label,
|
||||
Color color,
|
||||
Color onColor,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: AppSpacing.radiusSM,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: onColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
593
lib/core/theme/widgets/theme_mode_switch.dart
Normal file
593
lib/core/theme/widgets/theme_mode_switch.dart
Normal file
@@ -0,0 +1,593 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../app_spacing.dart';
|
||||
import '../app_typography.dart';
|
||||
|
||||
/// Theme mode switch widget with Material 3 design
|
||||
class ThemeModeSwitch extends ConsumerWidget {
|
||||
/// Creates a theme mode switch
|
||||
const ThemeModeSwitch({
|
||||
super.key,
|
||||
this.showLabel = true,
|
||||
this.labelText,
|
||||
this.onChanged,
|
||||
this.size = ThemeSwitchSize.medium,
|
||||
this.style = ThemeSwitchStyle.toggle,
|
||||
});
|
||||
|
||||
/// Whether to show the label
|
||||
final bool showLabel;
|
||||
|
||||
/// Custom label text
|
||||
final String? labelText;
|
||||
|
||||
/// Callback when theme mode changes
|
||||
final ValueChanged<ThemeMode>? onChanged;
|
||||
|
||||
/// Size of the switch
|
||||
final ThemeSwitchSize size;
|
||||
|
||||
/// Style of the switch
|
||||
final ThemeSwitchStyle style;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// For now, we'll use a simple state provider
|
||||
// In a real app, this would be connected to your theme provider
|
||||
final themeMode = ref.watch(_themeModeProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
switch (style) {
|
||||
case ThemeSwitchStyle.toggle:
|
||||
return _buildToggleSwitch(context, theme, themeMode, ref);
|
||||
case ThemeSwitchStyle.segmented:
|
||||
return _buildSegmentedSwitch(context, theme, themeMode, ref);
|
||||
case ThemeSwitchStyle.radio:
|
||||
return _buildRadioSwitch(context, theme, themeMode, ref);
|
||||
case ThemeSwitchStyle.dropdown:
|
||||
return _buildDropdownSwitch(context, theme, themeMode, ref);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildToggleSwitch(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ThemeMode themeMode,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
final isDark = themeMode == ThemeMode.dark;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showLabel) ...[
|
||||
Icon(
|
||||
Icons.light_mode,
|
||||
size: _getIconSize(),
|
||||
color: isDark ? theme.colorScheme.onSurface.withValues(alpha: 0.6) : theme.colorScheme.primary,
|
||||
),
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
],
|
||||
Switch(
|
||||
value: isDark,
|
||||
onChanged: (value) {
|
||||
final newMode = value ? ThemeMode.dark : ThemeMode.light;
|
||||
ref.read(_themeModeProvider.notifier).state = newMode;
|
||||
onChanged?.call(newMode);
|
||||
},
|
||||
thumbIcon: WidgetStateProperty.resolveWith<Icon?>(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return Icon(
|
||||
Icons.dark_mode,
|
||||
size: _getIconSize() * 0.7,
|
||||
);
|
||||
}
|
||||
return Icon(
|
||||
Icons.light_mode,
|
||||
size: _getIconSize() * 0.7,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (showLabel) ...[
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
Icon(
|
||||
Icons.dark_mode,
|
||||
size: _getIconSize(),
|
||||
color: isDark ? theme.colorScheme.primary : theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
],
|
||||
if (showLabel && labelText != null) ...[
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
Text(
|
||||
labelText!,
|
||||
style: _getLabelStyle(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSegmentedSwitch(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ThemeMode themeMode,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
return SegmentedButton<ThemeMode>(
|
||||
segments: [
|
||||
ButtonSegment<ThemeMode>(
|
||||
value: ThemeMode.light,
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.light_mode, size: _getIconSize()),
|
||||
if (showLabel) ...[
|
||||
AppSpacing.horizontalSpaceXS,
|
||||
Text('Light', style: _getLabelStyle()),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
ButtonSegment<ThemeMode>(
|
||||
value: ThemeMode.system,
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.brightness_auto, size: _getIconSize()),
|
||||
if (showLabel) ...[
|
||||
AppSpacing.horizontalSpaceXS,
|
||||
Text('Auto', style: _getLabelStyle()),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
ButtonSegment<ThemeMode>(
|
||||
value: ThemeMode.dark,
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.dark_mode, size: _getIconSize()),
|
||||
if (showLabel) ...[
|
||||
AppSpacing.horizontalSpaceXS,
|
||||
Text('Dark', style: _getLabelStyle()),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
selected: {themeMode},
|
||||
onSelectionChanged: (Set<ThemeMode> selection) {
|
||||
final newMode = selection.first;
|
||||
ref.read(_themeModeProvider.notifier).state = newMode;
|
||||
onChanged?.call(newMode);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRadioSwitch(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ThemeMode themeMode,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showLabel && labelText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||||
child: Text(
|
||||
labelText!,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
...ThemeMode.values.map((mode) {
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: size == ThemeSwitchSize.small,
|
||||
leading: Radio<ThemeMode>(
|
||||
value: mode,
|
||||
groupValue: themeMode,
|
||||
onChanged: (ThemeMode? value) {
|
||||
if (value != null) {
|
||||
ref.read(_themeModeProvider.notifier).state = value;
|
||||
onChanged?.call(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_getThemeModeIcon(mode), size: _getIconSize()),
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
Text(_getThemeModeLabel(mode), style: _getLabelStyle()),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
ref.read(_themeModeProvider.notifier).state = mode;
|
||||
onChanged?.call(mode);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdownSwitch(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ThemeMode themeMode,
|
||||
WidgetRef ref,
|
||||
) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showLabel && labelText != null) ...[
|
||||
Text(
|
||||
labelText!,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
],
|
||||
DropdownButton<ThemeMode>(
|
||||
value: themeMode,
|
||||
onChanged: (ThemeMode? value) {
|
||||
if (value != null) {
|
||||
ref.read(_themeModeProvider.notifier).state = value;
|
||||
onChanged?.call(value);
|
||||
}
|
||||
},
|
||||
items: ThemeMode.values.map((mode) {
|
||||
return DropdownMenuItem<ThemeMode>(
|
||||
value: mode,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_getThemeModeIcon(mode), size: _getIconSize()),
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
Text(_getThemeModeLabel(mode), style: _getLabelStyle()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
underline: const SizedBox(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
double _getIconSize() {
|
||||
switch (size) {
|
||||
case ThemeSwitchSize.small:
|
||||
return AppSpacing.iconSM;
|
||||
case ThemeSwitchSize.medium:
|
||||
return AppSpacing.iconMD;
|
||||
case ThemeSwitchSize.large:
|
||||
return AppSpacing.iconLG;
|
||||
}
|
||||
}
|
||||
|
||||
TextStyle _getLabelStyle() {
|
||||
switch (size) {
|
||||
case ThemeSwitchSize.small:
|
||||
return AppTypography.labelSmall;
|
||||
case ThemeSwitchSize.medium:
|
||||
return AppTypography.labelMedium;
|
||||
case ThemeSwitchSize.large:
|
||||
return AppTypography.labelLarge;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getThemeModeIcon(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light:
|
||||
return Icons.light_mode;
|
||||
case ThemeMode.dark:
|
||||
return Icons.dark_mode;
|
||||
case ThemeMode.system:
|
||||
return Icons.brightness_auto;
|
||||
}
|
||||
}
|
||||
|
||||
String _getThemeModeLabel(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light:
|
||||
return 'Light';
|
||||
case ThemeMode.dark:
|
||||
return 'Dark';
|
||||
case ThemeMode.system:
|
||||
return 'System';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme switch sizes
|
||||
enum ThemeSwitchSize {
|
||||
small,
|
||||
medium,
|
||||
large,
|
||||
}
|
||||
|
||||
/// Theme switch styles
|
||||
enum ThemeSwitchStyle {
|
||||
toggle,
|
||||
segmented,
|
||||
radio,
|
||||
dropdown,
|
||||
}
|
||||
|
||||
/// Animated theme mode switch with smooth transitions
|
||||
class AnimatedThemeModeSwitch extends ConsumerStatefulWidget {
|
||||
/// Creates an animated theme mode switch
|
||||
const AnimatedThemeModeSwitch({
|
||||
super.key,
|
||||
this.duration = const Duration(milliseconds: 300),
|
||||
this.curve = Curves.easeInOut,
|
||||
this.showIcon = true,
|
||||
this.iconSize = 24.0,
|
||||
});
|
||||
|
||||
/// Animation duration
|
||||
final Duration duration;
|
||||
|
||||
/// Animation curve
|
||||
final Curve curve;
|
||||
|
||||
/// Whether to show the theme icon
|
||||
final bool showIcon;
|
||||
|
||||
/// Size of the theme icon
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
ConsumerState<AnimatedThemeModeSwitch> createState() => _AnimatedThemeModeSwitchState();
|
||||
}
|
||||
|
||||
class _AnimatedThemeModeSwitchState extends ConsumerState<AnimatedThemeModeSwitch>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: widget.duration,
|
||||
vsync: this,
|
||||
);
|
||||
_animation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: widget.curve,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeMode = ref.watch(_themeModeProvider);
|
||||
final isDark = themeMode == ThemeMode.dark;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Update animation based on theme mode
|
||||
if (isDark) {
|
||||
_controller.forward();
|
||||
} else {
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final newMode = isDark ? ThemeMode.light : ThemeMode.dark;
|
||||
ref.read(_themeModeProvider.notifier).state = newMode;
|
||||
},
|
||||
child: AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: widget.iconSize * 2.5,
|
||||
height: widget.iconSize * 1.4,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.iconSize * 0.7),
|
||||
color: Color.lerp(
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.inverseSurface,
|
||||
_animation.value,
|
||||
),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Sun icon
|
||||
AnimatedPositioned(
|
||||
duration: widget.duration,
|
||||
curve: widget.curve,
|
||||
left: isDark ? -widget.iconSize : widget.iconSize * 0.2,
|
||||
top: widget.iconSize * 0.2,
|
||||
child: AnimatedOpacity(
|
||||
duration: widget.duration,
|
||||
opacity: isDark ? 0.0 : 1.0,
|
||||
child: Icon(
|
||||
Icons.light_mode,
|
||||
size: widget.iconSize,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Moon icon
|
||||
AnimatedPositioned(
|
||||
duration: widget.duration,
|
||||
curve: widget.curve,
|
||||
left: isDark ? widget.iconSize * 1.3 : widget.iconSize * 2.5,
|
||||
top: widget.iconSize * 0.2,
|
||||
child: AnimatedOpacity(
|
||||
duration: widget.duration,
|
||||
opacity: isDark ? 1.0 : 0.0,
|
||||
child: Icon(
|
||||
Icons.dark_mode,
|
||||
size: widget.iconSize,
|
||||
color: theme.colorScheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Sliding thumb
|
||||
AnimatedPositioned(
|
||||
duration: widget.duration,
|
||||
curve: widget.curve,
|
||||
left: isDark ? widget.iconSize * 1.1 : widget.iconSize * 0.1,
|
||||
top: widget.iconSize * 0.1,
|
||||
child: Container(
|
||||
width: widget.iconSize * 1.2,
|
||||
height: widget.iconSize * 1.2,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: theme.colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.shadow.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple theme toggle icon button
|
||||
class ThemeToggleIconButton extends ConsumerWidget {
|
||||
/// Creates a theme toggle icon button
|
||||
const ThemeToggleIconButton({
|
||||
super.key,
|
||||
this.tooltip,
|
||||
this.iconSize,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
/// Tooltip text
|
||||
final String? tooltip;
|
||||
|
||||
/// Icon size
|
||||
final double? iconSize;
|
||||
|
||||
/// Custom callback
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(_themeModeProvider);
|
||||
final isDark = themeMode == ThemeMode.dark;
|
||||
|
||||
return IconButton(
|
||||
onPressed: onPressed ??
|
||||
() {
|
||||
final newMode = isDark ? ThemeMode.light : ThemeMode.dark;
|
||||
ref.read(_themeModeProvider.notifier).state = newMode;
|
||||
},
|
||||
icon: AnimatedSwitcher(
|
||||
duration: AppSpacing.animationNormal,
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return RotationTransition(turns: animation, child: child);
|
||||
},
|
||||
child: Icon(
|
||||
isDark ? Icons.dark_mode : Icons.light_mode,
|
||||
key: ValueKey(isDark),
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
tooltip: tooltip ?? (isDark ? 'Switch to light mode' : 'Switch to dark mode'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme mode provider - replace with your actual theme provider
|
||||
final _themeModeProvider = StateProvider<ThemeMode>((ref) => ThemeMode.system);
|
||||
|
||||
/// Theme mode controller for managing theme state
|
||||
class ThemeModeController extends StateNotifier<ThemeMode> {
|
||||
ThemeModeController() : super(ThemeMode.system);
|
||||
|
||||
/// Switch to light mode
|
||||
void setLightMode() => state = ThemeMode.light;
|
||||
|
||||
/// Switch to dark mode
|
||||
void setDarkMode() => state = ThemeMode.dark;
|
||||
|
||||
/// Switch to system mode
|
||||
void setSystemMode() => state = ThemeMode.system;
|
||||
|
||||
/// Toggle between light and dark (ignoring system)
|
||||
void toggle() {
|
||||
state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
|
||||
}
|
||||
|
||||
/// Check if current mode is dark
|
||||
bool get isDark => state == ThemeMode.dark;
|
||||
|
||||
/// Check if current mode is light
|
||||
bool get isLight => state == ThemeMode.light;
|
||||
|
||||
/// Check if current mode is system
|
||||
bool get isSystem => state == ThemeMode.system;
|
||||
}
|
||||
|
||||
/// Provider for theme mode controller
|
||||
final themeModeControllerProvider = StateNotifierProvider<ThemeModeController, ThemeMode>((ref) {
|
||||
return ThemeModeController();
|
||||
});
|
||||
|
||||
/// Utility methods for theme mode
|
||||
extension ThemeModeExtensions on ThemeMode {
|
||||
/// Get the display name for the theme mode
|
||||
String get displayName {
|
||||
switch (this) {
|
||||
case ThemeMode.light:
|
||||
return 'Light';
|
||||
case ThemeMode.dark:
|
||||
return 'Dark';
|
||||
case ThemeMode.system:
|
||||
return 'System';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the icon for the theme mode
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case ThemeMode.light:
|
||||
return Icons.light_mode;
|
||||
case ThemeMode.dark:
|
||||
return Icons.dark_mode;
|
||||
case ThemeMode.system:
|
||||
return Icons.brightness_auto;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the description for the theme mode
|
||||
String get description {
|
||||
switch (this) {
|
||||
case ThemeMode.light:
|
||||
return 'Use light theme';
|
||||
case ThemeMode.dark:
|
||||
return 'Use dark theme';
|
||||
case ThemeMode.system:
|
||||
return 'Follow system setting';
|
||||
}
|
||||
}
|
||||
}
|
||||
137
lib/core/utils/extensions.dart
Normal file
137
lib/core/utils/extensions.dart
Normal file
@@ -0,0 +1,137 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Extensions for BuildContext
|
||||
extension BuildContextExtensions on BuildContext {
|
||||
/// Get the current theme
|
||||
ThemeData get theme => Theme.of(this);
|
||||
|
||||
/// Get the color scheme
|
||||
ColorScheme get colorScheme => theme.colorScheme;
|
||||
|
||||
/// Get text theme
|
||||
TextTheme get textTheme => theme.textTheme;
|
||||
|
||||
/// Get media query
|
||||
MediaQueryData get mediaQuery => MediaQuery.of(this);
|
||||
|
||||
/// Get screen size
|
||||
Size get screenSize => mediaQuery.size;
|
||||
|
||||
/// Get screen width
|
||||
double get screenWidth => screenSize.width;
|
||||
|
||||
/// Get screen height
|
||||
double get screenHeight => screenSize.height;
|
||||
|
||||
/// Check if device is in dark mode
|
||||
bool get isDarkMode => theme.brightness == Brightness.dark;
|
||||
|
||||
/// Show snackbar
|
||||
void showSnackBar(String message, {bool isError = false}) {
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: isError ? colorScheme.error : null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Hide keyboard
|
||||
void hideKeyboard() {
|
||||
FocusScope.of(this).unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensions for String
|
||||
extension StringExtensions on String {
|
||||
/// Check if string is email
|
||||
bool get isValidEmail {
|
||||
return RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(this);
|
||||
}
|
||||
|
||||
/// Check if string is empty or null
|
||||
bool get isEmptyOrNull {
|
||||
return isEmpty;
|
||||
}
|
||||
|
||||
/// Check if string is not empty and not null
|
||||
bool get isNotEmptyAndNotNull {
|
||||
return isNotEmpty;
|
||||
}
|
||||
|
||||
/// Capitalize first letter
|
||||
String get capitalize {
|
||||
if (isEmpty) return this;
|
||||
return '${this[0].toUpperCase()}${substring(1).toLowerCase()}';
|
||||
}
|
||||
|
||||
/// Title case
|
||||
String get titleCase {
|
||||
return split(' ').map((word) => word.capitalize).join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensions for DateTime
|
||||
extension DateTimeExtensions on DateTime {
|
||||
/// Check if date is today
|
||||
bool get isToday {
|
||||
final now = DateTime.now();
|
||||
return day == now.day && month == now.month && year == now.year;
|
||||
}
|
||||
|
||||
/// Check if date is yesterday
|
||||
bool get isYesterday {
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
return day == yesterday.day && month == yesterday.month && year == yesterday.year;
|
||||
}
|
||||
|
||||
/// Check if date is tomorrow
|
||||
bool get isTomorrow {
|
||||
final tomorrow = DateTime.now().add(const Duration(days: 1));
|
||||
return day == tomorrow.day && month == tomorrow.month && year == tomorrow.year;
|
||||
}
|
||||
|
||||
/// Get time ago string
|
||||
String get timeAgo {
|
||||
final now = DateTime.now();
|
||||
final difference = now.difference(this);
|
||||
|
||||
if (difference.inDays > 365) {
|
||||
return '${(difference.inDays / 365).floor()} year${(difference.inDays / 365).floor() == 1 ? '' : 's'} ago';
|
||||
} else if (difference.inDays > 30) {
|
||||
return '${(difference.inDays / 30).floor()} month${(difference.inDays / 30).floor() == 1 ? '' : 's'} ago';
|
||||
} else if (difference.inDays > 0) {
|
||||
return '${difference.inDays} day${difference.inDays == 1 ? '' : 's'} ago';
|
||||
} else if (difference.inHours > 0) {
|
||||
return '${difference.inHours} hour${difference.inHours == 1 ? '' : 's'} ago';
|
||||
} else if (difference.inMinutes > 0) {
|
||||
return '${difference.inMinutes} minute${difference.inMinutes == 1 ? '' : 's'} ago';
|
||||
} else {
|
||||
return 'Just now';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensions for List
|
||||
extension ListExtensions<T> on List<T> {
|
||||
/// Check if list is empty or null
|
||||
bool get isEmptyOrNull {
|
||||
return isEmpty;
|
||||
}
|
||||
|
||||
/// Check if list is not empty and not null
|
||||
bool get isNotEmptyAndNotNull {
|
||||
return isNotEmpty;
|
||||
}
|
||||
|
||||
/// Get first element or null
|
||||
T? get firstOrNull {
|
||||
return isEmpty ? null : first;
|
||||
}
|
||||
|
||||
/// Get last element or null
|
||||
T? get lastOrNull {
|
||||
return isEmpty ? null : last;
|
||||
}
|
||||
}
|
||||
16
lib/core/utils/typedef.dart
Normal file
16
lib/core/utils/typedef.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:fpdart/fpdart.dart';
|
||||
import '../errors/failures.dart';
|
||||
|
||||
/// Common type definitions used throughout the application
|
||||
|
||||
/// Result type for operations that can fail
|
||||
typedef Result<T> = Either<Failure, T>;
|
||||
|
||||
/// Async result type
|
||||
typedef AsyncResult<T> = Future<Result<T>>;
|
||||
|
||||
/// Data map type for JSON serialization
|
||||
typedef DataMap = Map<String, dynamic>;
|
||||
|
||||
/// Data list type for JSON serialization
|
||||
typedef DataList = List<DataMap>;
|
||||
3
lib/core/utils/utils.dart
Normal file
3
lib/core/utils/utils.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
// Barrel export file for utilities
|
||||
export 'extensions.dart';
|
||||
export 'typedef.dart';
|
||||
351
lib/core/widgets/app_button.dart
Normal file
351
lib/core/widgets/app_button.dart
Normal file
@@ -0,0 +1,351 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
import '../theme/app_typography.dart';
|
||||
|
||||
/// Customizable button component with multiple variants following Material 3 design
|
||||
///
|
||||
/// Supports filled, outlined, text, and icon variants with consistent theming
|
||||
/// and accessibility features.
|
||||
class AppButton extends StatelessWidget {
|
||||
/// Creates an app button with the specified variant and configuration
|
||||
const AppButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.onPressed,
|
||||
this.variant = AppButtonVariant.filled,
|
||||
this.size = AppButtonSize.medium,
|
||||
this.icon,
|
||||
this.isLoading = false,
|
||||
this.isFullWidth = false,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.borderColor,
|
||||
this.elevation,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// The text displayed on the button
|
||||
final String text;
|
||||
|
||||
/// Called when the button is pressed
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
/// The visual variant of the button
|
||||
final AppButtonVariant variant;
|
||||
|
||||
/// The size of the button
|
||||
final AppButtonSize size;
|
||||
|
||||
/// Optional icon to display alongside text
|
||||
final IconData? icon;
|
||||
|
||||
/// Whether to show a loading indicator
|
||||
final bool isLoading;
|
||||
|
||||
/// Whether the button should take full width
|
||||
final bool isFullWidth;
|
||||
|
||||
/// Custom background color override
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Custom foreground color override
|
||||
final Color? foregroundColor;
|
||||
|
||||
/// Custom border color override (for outlined variant)
|
||||
final Color? borderColor;
|
||||
|
||||
/// Custom elevation override
|
||||
final double? elevation;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Get button configuration based on size
|
||||
final buttonHeight = _getButtonHeight();
|
||||
final buttonPadding = _getButtonPadding();
|
||||
final textStyle = _getTextStyle();
|
||||
final iconSize = _getIconSize();
|
||||
|
||||
// Create button style
|
||||
final buttonStyle = _createButtonStyle(
|
||||
theme: theme,
|
||||
height: buttonHeight,
|
||||
padding: buttonPadding,
|
||||
);
|
||||
|
||||
// Handle loading state
|
||||
if (isLoading) {
|
||||
return _buildLoadingButton(
|
||||
context: context,
|
||||
style: buttonStyle,
|
||||
height: buttonHeight,
|
||||
);
|
||||
}
|
||||
|
||||
// Build appropriate button variant
|
||||
Widget button = switch (variant) {
|
||||
AppButtonVariant.filled => _buildFilledButton(
|
||||
context: context,
|
||||
style: buttonStyle,
|
||||
textStyle: textStyle,
|
||||
iconSize: iconSize,
|
||||
),
|
||||
AppButtonVariant.outlined => _buildOutlinedButton(
|
||||
context: context,
|
||||
style: buttonStyle,
|
||||
textStyle: textStyle,
|
||||
iconSize: iconSize,
|
||||
),
|
||||
AppButtonVariant.text => _buildTextButton(
|
||||
context: context,
|
||||
style: buttonStyle,
|
||||
textStyle: textStyle,
|
||||
iconSize: iconSize,
|
||||
),
|
||||
AppButtonVariant.icon => _buildIconButton(
|
||||
context: context,
|
||||
iconSize: iconSize,
|
||||
),
|
||||
};
|
||||
|
||||
// Apply full width if needed
|
||||
if (isFullWidth && variant != AppButtonVariant.icon) {
|
||||
button = SizedBox(
|
||||
width: double.infinity,
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
|
||||
// Add semantic label for accessibility
|
||||
if (semanticLabel != null) {
|
||||
button = Semantics(
|
||||
label: semanticLabel,
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
/// Build filled button variant
|
||||
Widget _buildFilledButton({
|
||||
required BuildContext context,
|
||||
required ButtonStyle style,
|
||||
required TextStyle textStyle,
|
||||
required double iconSize,
|
||||
}) {
|
||||
if (icon != null) {
|
||||
return FilledButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: iconSize),
|
||||
label: Text(text, style: textStyle),
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
|
||||
return FilledButton(
|
||||
onPressed: onPressed,
|
||||
style: style,
|
||||
child: Text(text, style: textStyle),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build outlined button variant
|
||||
Widget _buildOutlinedButton({
|
||||
required BuildContext context,
|
||||
required ButtonStyle style,
|
||||
required TextStyle textStyle,
|
||||
required double iconSize,
|
||||
}) {
|
||||
if (icon != null) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: iconSize),
|
||||
label: Text(text, style: textStyle),
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
|
||||
return OutlinedButton(
|
||||
onPressed: onPressed,
|
||||
style: style,
|
||||
child: Text(text, style: textStyle),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build text button variant
|
||||
Widget _buildTextButton({
|
||||
required BuildContext context,
|
||||
required ButtonStyle style,
|
||||
required TextStyle textStyle,
|
||||
required double iconSize,
|
||||
}) {
|
||||
if (icon != null) {
|
||||
return TextButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: iconSize),
|
||||
label: Text(text, style: textStyle),
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
|
||||
return TextButton(
|
||||
onPressed: onPressed,
|
||||
style: style,
|
||||
child: Text(text, style: textStyle),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build icon button variant
|
||||
Widget _buildIconButton({
|
||||
required BuildContext context,
|
||||
required double iconSize,
|
||||
}) {
|
||||
if (icon == null) {
|
||||
throw ArgumentError('Icon button requires an icon');
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: iconSize),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor ?? theme.colorScheme.primary,
|
||||
minimumSize: Size(_getButtonHeight(), _getButtonHeight()),
|
||||
maximumSize: Size(_getButtonHeight(), _getButtonHeight()),
|
||||
),
|
||||
tooltip: text,
|
||||
);
|
||||
}
|
||||
|
||||
/// Build loading button state
|
||||
Widget _buildLoadingButton({
|
||||
required BuildContext context,
|
||||
required ButtonStyle style,
|
||||
required double height,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return FilledButton(
|
||||
onPressed: null,
|
||||
style: style,
|
||||
child: SizedBox(
|
||||
height: height * 0.5,
|
||||
width: height * 0.5,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
foregroundColor ?? theme.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create button style based on variant and customizations
|
||||
ButtonStyle _createButtonStyle({
|
||||
required ThemeData theme,
|
||||
required double height,
|
||||
required EdgeInsets padding,
|
||||
}) {
|
||||
return ButtonStyle(
|
||||
minimumSize: WidgetStateProperty.all(Size(0, height)),
|
||||
padding: WidgetStateProperty.all(padding),
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.buttonRadius,
|
||||
),
|
||||
),
|
||||
backgroundColor: backgroundColor != null
|
||||
? WidgetStateProperty.all(backgroundColor)
|
||||
: null,
|
||||
foregroundColor: foregroundColor != null
|
||||
? WidgetStateProperty.all(foregroundColor)
|
||||
: null,
|
||||
side: borderColor != null && variant == AppButtonVariant.outlined
|
||||
? WidgetStateProperty.all(BorderSide(color: borderColor!))
|
||||
: null,
|
||||
elevation: elevation != null
|
||||
? WidgetStateProperty.all(elevation)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get button height based on size
|
||||
double _getButtonHeight() {
|
||||
return switch (size) {
|
||||
AppButtonSize.small => AppSpacing.buttonHeightSmall,
|
||||
AppButtonSize.medium => AppSpacing.buttonHeight,
|
||||
AppButtonSize.large => AppSpacing.buttonHeightLarge,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get button padding based on size
|
||||
EdgeInsets _getButtonPadding() {
|
||||
return switch (size) {
|
||||
AppButtonSize.small => const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
AppButtonSize.medium => const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.buttonPaddingHorizontal,
|
||||
vertical: AppSpacing.buttonPaddingVertical,
|
||||
),
|
||||
AppButtonSize.large => const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xxxl,
|
||||
vertical: AppSpacing.lg,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Get text style based on size
|
||||
TextStyle _getTextStyle() {
|
||||
return switch (size) {
|
||||
AppButtonSize.small => AppTypography.labelMedium,
|
||||
AppButtonSize.medium => AppTypography.buttonText,
|
||||
AppButtonSize.large => AppTypography.labelLarge,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get icon size based on button size
|
||||
double _getIconSize() {
|
||||
return switch (size) {
|
||||
AppButtonSize.small => AppSpacing.iconSM,
|
||||
AppButtonSize.medium => AppSpacing.iconMD,
|
||||
AppButtonSize.large => AppSpacing.iconLG,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Available button variants
|
||||
enum AppButtonVariant {
|
||||
/// Filled button with background color (primary action)
|
||||
filled,
|
||||
|
||||
/// Outlined button with border (secondary action)
|
||||
outlined,
|
||||
|
||||
/// Text button without background (tertiary action)
|
||||
text,
|
||||
|
||||
/// Icon-only button
|
||||
icon,
|
||||
}
|
||||
|
||||
/// Available button sizes
|
||||
enum AppButtonSize {
|
||||
/// Small button (32dp height)
|
||||
small,
|
||||
|
||||
/// Medium button (40dp height) - default
|
||||
medium,
|
||||
|
||||
/// Large button (56dp height)
|
||||
large,
|
||||
}
|
||||
538
lib/core/widgets/app_card.dart
Normal file
538
lib/core/widgets/app_card.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
|
||||
/// Reusable card component with Material 3 styling and customization options
|
||||
///
|
||||
/// Provides a consistent card design with support for different variants,
|
||||
/// elevation levels, and content layouts.
|
||||
class AppCard extends StatelessWidget {
|
||||
/// Creates a card with the specified content and styling options
|
||||
const AppCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.variant = AppCardVariant.elevated,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.backgroundColor,
|
||||
this.shadowColor,
|
||||
this.surfaceTintColor,
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.clipBehavior = Clip.none,
|
||||
this.onTap,
|
||||
this.semanticLabel,
|
||||
}) : title = null,
|
||||
subtitle = null,
|
||||
leading = null,
|
||||
trailing = null,
|
||||
actions = null,
|
||||
mediaWidget = null;
|
||||
|
||||
/// Creates a card with a title, subtitle, and optional actions
|
||||
const AppCard.titled({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.child,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.actions,
|
||||
this.variant = AppCardVariant.elevated,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.backgroundColor,
|
||||
this.shadowColor,
|
||||
this.surfaceTintColor,
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.clipBehavior = Clip.none,
|
||||
this.onTap,
|
||||
this.semanticLabel,
|
||||
}) : mediaWidget = null;
|
||||
|
||||
/// Creates a card optimized for displaying media content
|
||||
const AppCard.media({
|
||||
super.key,
|
||||
required this.mediaWidget,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.child,
|
||||
this.actions,
|
||||
this.variant = AppCardVariant.elevated,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.backgroundColor,
|
||||
this.shadowColor,
|
||||
this.surfaceTintColor,
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.clipBehavior = Clip.antiAlias,
|
||||
this.onTap,
|
||||
this.semanticLabel,
|
||||
}) : leading = null,
|
||||
trailing = null;
|
||||
|
||||
/// The content to display inside the card
|
||||
final Widget? child;
|
||||
|
||||
/// Title for titled cards
|
||||
final String? title;
|
||||
|
||||
/// Subtitle for titled cards
|
||||
final String? subtitle;
|
||||
|
||||
/// Leading widget for titled cards (typically an icon or avatar)
|
||||
final Widget? leading;
|
||||
|
||||
/// Trailing widget for titled cards (typically an icon or button)
|
||||
final Widget? trailing;
|
||||
|
||||
/// Action buttons displayed at the bottom of the card
|
||||
final List<Widget>? actions;
|
||||
|
||||
/// Media widget for media cards (typically an image or video)
|
||||
final Widget? mediaWidget;
|
||||
|
||||
/// The visual variant of the card
|
||||
final AppCardVariant variant;
|
||||
|
||||
/// Internal padding of the card content
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// External margin around the card
|
||||
final EdgeInsets? margin;
|
||||
|
||||
/// Background color override
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Shadow color override
|
||||
final Color? shadowColor;
|
||||
|
||||
/// Surface tint color for Material 3
|
||||
final Color? surfaceTintColor;
|
||||
|
||||
/// Elevation level override
|
||||
final double? elevation;
|
||||
|
||||
/// Shape override
|
||||
final ShapeBorder? shape;
|
||||
|
||||
/// Clipping behavior for card content
|
||||
final Clip clipBehavior;
|
||||
|
||||
/// Called when the card is tapped
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cardTheme = theme.cardTheme;
|
||||
|
||||
// Determine card content based on constructor used
|
||||
Widget content;
|
||||
if (title != null) {
|
||||
content = _buildTitledContent(context);
|
||||
} else if (mediaWidget != null) {
|
||||
content = _buildMediaContent(context);
|
||||
} else if (child != null) {
|
||||
content = child!;
|
||||
} else {
|
||||
content = const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Apply padding if specified
|
||||
if (padding != null) {
|
||||
content = Padding(
|
||||
padding: padding!,
|
||||
child: content,
|
||||
);
|
||||
} else if (title != null || mediaWidget != null) {
|
||||
// Apply default padding for titled/media cards
|
||||
content = Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.cardPadding),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
// Create the card widget
|
||||
Widget card = Card(
|
||||
elevation: _getElevation(theme),
|
||||
color: backgroundColor ?? cardTheme.color,
|
||||
shadowColor: shadowColor ?? cardTheme.shadowColor,
|
||||
surfaceTintColor: surfaceTintColor ?? cardTheme.surfaceTintColor,
|
||||
shape: shape ?? cardTheme.shape ??
|
||||
RoundedRectangleBorder(borderRadius: AppSpacing.cardRadius),
|
||||
clipBehavior: clipBehavior,
|
||||
margin: margin ?? cardTheme.margin ?? const EdgeInsets.all(AppSpacing.cardMargin),
|
||||
child: content,
|
||||
);
|
||||
|
||||
// Make card tappable if onTap is provided
|
||||
if (onTap != null) {
|
||||
card = InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: shape is RoundedRectangleBorder
|
||||
? (shape as RoundedRectangleBorder).borderRadius as BorderRadius?
|
||||
: AppSpacing.cardRadius,
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
// Add semantic label for accessibility
|
||||
if (semanticLabel != null) {
|
||||
card = Semantics(
|
||||
label: semanticLabel,
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
/// Build content for titled cards
|
||||
Widget _buildTitledContent(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final titleStyle = theme.textTheme.titleMedium;
|
||||
final subtitleStyle = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
|
||||
final List<Widget> children = [];
|
||||
|
||||
// Add header with title, subtitle, leading, and trailing
|
||||
if (title != null || leading != null || trailing != null) {
|
||||
children.add(
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
leading!,
|
||||
AppSpacing.horizontalSpaceMD,
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title != null)
|
||||
Text(
|
||||
title!,
|
||||
style: titleStyle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: subtitleStyle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
AppSpacing.horizontalSpaceXS,
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add main content
|
||||
if (child != null) {
|
||||
if (children.isNotEmpty) {
|
||||
children.add(AppSpacing.verticalSpaceLG);
|
||||
}
|
||||
children.add(child!);
|
||||
}
|
||||
|
||||
// Add actions
|
||||
if (actions != null && actions!.isNotEmpty) {
|
||||
if (children.isNotEmpty) {
|
||||
children.add(AppSpacing.verticalSpaceLG);
|
||||
}
|
||||
children.add(
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions!
|
||||
.expand((action) => [action, AppSpacing.horizontalSpaceSM])
|
||||
.take(actions!.length * 2 - 1)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
/// Build content for media cards
|
||||
Widget _buildMediaContent(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final titleStyle = theme.textTheme.titleMedium;
|
||||
final subtitleStyle = theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
|
||||
final List<Widget> children = [];
|
||||
|
||||
// Add media widget
|
||||
if (mediaWidget != null) {
|
||||
children.add(mediaWidget!);
|
||||
}
|
||||
|
||||
// Add title and subtitle
|
||||
if (title != null || subtitle != null) {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.cardPadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null)
|
||||
Text(
|
||||
title!,
|
||||
style: titleStyle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: subtitleStyle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add main content
|
||||
if (child != null) {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.cardPadding,
|
||||
).copyWith(
|
||||
bottom: AppSpacing.cardPadding,
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add actions
|
||||
if (actions != null && actions!.isNotEmpty) {
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.cardPadding,
|
||||
).copyWith(
|
||||
bottom: AppSpacing.cardPadding,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions!
|
||||
.expand((action) => [action, AppSpacing.horizontalSpaceSM])
|
||||
.take(actions!.length * 2 - 1)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get elevation based on variant and theme
|
||||
double _getElevation(ThemeData theme) {
|
||||
if (elevation != null) {
|
||||
return elevation!;
|
||||
}
|
||||
|
||||
return switch (variant) {
|
||||
AppCardVariant.elevated => theme.cardTheme.elevation ?? AppSpacing.elevationLow,
|
||||
AppCardVariant.filled => AppSpacing.elevationNone,
|
||||
AppCardVariant.outlined => AppSpacing.elevationNone,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Available card variants
|
||||
enum AppCardVariant {
|
||||
/// Elevated card with shadow (default)
|
||||
elevated,
|
||||
|
||||
/// Filled card with background color and no shadow
|
||||
filled,
|
||||
|
||||
/// Outlined card with border and no shadow
|
||||
outlined,
|
||||
}
|
||||
|
||||
/// Compact card for list items
|
||||
class AppListCard extends StatelessWidget {
|
||||
const AppListCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsets? padding;
|
||||
final EdgeInsets? margin;
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
variant: AppCardVariant.filled,
|
||||
padding: padding ?? const EdgeInsets.all(AppSpacing.md),
|
||||
margin: margin ?? const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPadding,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
onTap: onTap,
|
||||
semanticLabel: semanticLabel,
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
leading!,
|
||||
AppSpacing.horizontalSpaceMD,
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Action card for interactive content
|
||||
class AppActionCard extends StatelessWidget {
|
||||
const AppActionCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
this.subtitle,
|
||||
this.icon,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final IconData? icon;
|
||||
final VoidCallback onTap;
|
||||
final Color? backgroundColor;
|
||||
final Color? foregroundColor;
|
||||
final EdgeInsets? padding;
|
||||
final EdgeInsets? margin;
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return AppCard(
|
||||
variant: AppCardVariant.filled,
|
||||
backgroundColor: backgroundColor ?? theme.colorScheme.surfaceContainerHighest,
|
||||
padding: padding ?? const EdgeInsets.all(AppSpacing.cardPadding),
|
||||
margin: margin,
|
||||
onTap: onTap,
|
||||
semanticLabel: semanticLabel,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: AppSpacing.iconXL,
|
||||
color: foregroundColor ?? theme.colorScheme.primary,
|
||||
),
|
||||
AppSpacing.verticalSpaceMD,
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: foregroundColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: foregroundColor ?? theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
696
lib/core/widgets/app_dialog.dart
Normal file
696
lib/core/widgets/app_dialog.dart
Normal file
@@ -0,0 +1,696 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
import '../theme/app_typography.dart';
|
||||
import 'app_button.dart';
|
||||
|
||||
/// Custom dialog components with Material 3 styling and consistent design
|
||||
///
|
||||
/// Provides reusable dialog widgets for common use cases like confirmation,
|
||||
/// information display, and custom content dialogs.
|
||||
class AppDialog extends StatelessWidget {
|
||||
/// Creates a dialog with the specified content and actions
|
||||
const AppDialog({
|
||||
super.key,
|
||||
this.title,
|
||||
this.content,
|
||||
this.actions,
|
||||
this.icon,
|
||||
this.backgroundColor,
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.insetPadding,
|
||||
this.contentPadding,
|
||||
this.actionsPadding,
|
||||
this.semanticLabel,
|
||||
}) : onConfirm = null,
|
||||
onCancel = null,
|
||||
onOk = null,
|
||||
confirmText = '',
|
||||
cancelText = '',
|
||||
okText = '',
|
||||
isDestructive = false;
|
||||
|
||||
/// Creates a confirmation dialog
|
||||
const AppDialog.confirmation({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.icon,
|
||||
required this.onConfirm,
|
||||
required this.onCancel,
|
||||
this.confirmText = 'Confirm',
|
||||
this.cancelText = 'Cancel',
|
||||
this.isDestructive = false,
|
||||
this.backgroundColor,
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.insetPadding,
|
||||
this.contentPadding,
|
||||
this.actionsPadding,
|
||||
this.semanticLabel,
|
||||
}) : actions = null,
|
||||
onOk = null,
|
||||
okText = '';
|
||||
|
||||
/// Creates an information dialog
|
||||
const AppDialog.info({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.icon = Icons.info_outline,
|
||||
this.onOk,
|
||||
this.okText = 'OK',
|
||||
this.backgroundColor,
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.insetPadding,
|
||||
this.contentPadding,
|
||||
this.actionsPadding,
|
||||
this.semanticLabel,
|
||||
}) : actions = null,
|
||||
onConfirm = null,
|
||||
onCancel = null,
|
||||
confirmText = 'OK',
|
||||
cancelText = '',
|
||||
isDestructive = false;
|
||||
|
||||
/// Creates a warning dialog
|
||||
const AppDialog.warning({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.icon = Icons.warning_outlined,
|
||||
required this.onConfirm,
|
||||
required this.onCancel,
|
||||
this.confirmText = 'Continue',
|
||||
this.cancelText = 'Cancel',
|
||||
this.backgroundColor,
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.insetPadding,
|
||||
this.contentPadding,
|
||||
this.actionsPadding,
|
||||
this.semanticLabel,
|
||||
}) : actions = null,
|
||||
isDestructive = true,
|
||||
onOk = null,
|
||||
okText = '';
|
||||
|
||||
/// Creates an error dialog
|
||||
const AppDialog.error({
|
||||
super.key,
|
||||
this.title = 'Error',
|
||||
required this.content,
|
||||
this.icon = Icons.error_outline,
|
||||
this.onOk,
|
||||
this.okText = 'OK',
|
||||
this.backgroundColor,
|
||||
this.elevation,
|
||||
this.shape,
|
||||
this.insetPadding,
|
||||
this.contentPadding,
|
||||
this.actionsPadding,
|
||||
this.semanticLabel,
|
||||
}) : actions = null,
|
||||
onConfirm = null,
|
||||
onCancel = null,
|
||||
confirmText = 'OK',
|
||||
cancelText = '',
|
||||
isDestructive = false;
|
||||
|
||||
/// Dialog title
|
||||
final String? title;
|
||||
|
||||
/// Dialog content (text or widget)
|
||||
final dynamic content;
|
||||
|
||||
/// Custom action widgets
|
||||
final List<Widget>? actions;
|
||||
|
||||
/// Dialog icon
|
||||
final IconData? icon;
|
||||
|
||||
/// Confirm button callback
|
||||
final VoidCallback? onConfirm;
|
||||
|
||||
/// Cancel button callback
|
||||
final VoidCallback? onCancel;
|
||||
|
||||
/// OK button callback (for info/error dialogs)
|
||||
final VoidCallback? onOk;
|
||||
|
||||
/// Confirm button text
|
||||
final String confirmText;
|
||||
|
||||
/// Cancel button text
|
||||
final String cancelText;
|
||||
|
||||
/// OK button text
|
||||
final String okText;
|
||||
|
||||
/// Whether the confirm action is destructive
|
||||
final bool isDestructive;
|
||||
|
||||
/// Background color override
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Elevation override
|
||||
final double? elevation;
|
||||
|
||||
/// Shape override
|
||||
final ShapeBorder? shape;
|
||||
|
||||
/// Inset padding override
|
||||
final EdgeInsets? insetPadding;
|
||||
|
||||
/// Content padding override
|
||||
final EdgeInsets? contentPadding;
|
||||
|
||||
/// Actions padding override
|
||||
final EdgeInsets? actionsPadding;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
Widget? titleWidget;
|
||||
if (title != null || icon != null) {
|
||||
titleWidget = _buildTitle(context);
|
||||
}
|
||||
|
||||
Widget? contentWidget;
|
||||
if (content != null) {
|
||||
contentWidget = _buildContent(context);
|
||||
}
|
||||
|
||||
List<Widget>? actionWidgets;
|
||||
if (actions != null) {
|
||||
actionWidgets = actions;
|
||||
} else {
|
||||
actionWidgets = _buildDefaultActions(context);
|
||||
}
|
||||
|
||||
Widget dialog = AlertDialog(
|
||||
title: titleWidget,
|
||||
content: contentWidget,
|
||||
actions: actionWidgets,
|
||||
backgroundColor: backgroundColor,
|
||||
elevation: elevation,
|
||||
shape: shape ??
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.dialogRadius,
|
||||
),
|
||||
insetPadding: insetPadding ??
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPadding,
|
||||
vertical: AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
contentPadding: contentPadding ??
|
||||
const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.sm,
|
||||
),
|
||||
actionsPadding: actionsPadding ??
|
||||
const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPaddingLarge,
|
||||
0,
|
||||
AppSpacing.screenPaddingLarge,
|
||||
AppSpacing.lg,
|
||||
),
|
||||
);
|
||||
|
||||
// Add semantic label for accessibility
|
||||
if (semanticLabel != null) {
|
||||
dialog = Semantics(
|
||||
label: semanticLabel,
|
||||
child: dialog,
|
||||
);
|
||||
}
|
||||
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/// Build dialog title with optional icon
|
||||
Widget _buildTitle(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
if (icon != null && title != null) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: AppSpacing.iconMD,
|
||||
color: _getIconColor(colorScheme),
|
||||
),
|
||||
AppSpacing.horizontalSpaceMD,
|
||||
Expanded(
|
||||
child: Text(
|
||||
title!,
|
||||
style: theme.dialogTheme.titleTextStyle ??
|
||||
AppTypography.headlineSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (icon != null) {
|
||||
return Icon(
|
||||
icon,
|
||||
size: AppSpacing.iconLG,
|
||||
color: _getIconColor(colorScheme),
|
||||
);
|
||||
} else if (title != null) {
|
||||
return Text(
|
||||
title!,
|
||||
style: theme.dialogTheme.titleTextStyle ??
|
||||
AppTypography.headlineSmall,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// Build dialog content
|
||||
Widget _buildContent(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (content is Widget) {
|
||||
return content as Widget;
|
||||
} else if (content is String) {
|
||||
return Text(
|
||||
content as String,
|
||||
style: theme.dialogTheme.contentTextStyle ??
|
||||
AppTypography.bodyMedium,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
/// Build default action buttons based on dialog type
|
||||
List<Widget>? _buildDefaultActions(BuildContext context) {
|
||||
// Info/Error dialog with single OK button
|
||||
if (onOk != null) {
|
||||
return [
|
||||
AppButton(
|
||||
text: okText,
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onOk?.call();
|
||||
},
|
||||
variant: AppButtonVariant.text,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Confirmation dialog with Cancel and Confirm buttons
|
||||
if (onConfirm != null && onCancel != null) {
|
||||
return [
|
||||
AppButton(
|
||||
text: cancelText,
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
onCancel?.call();
|
||||
},
|
||||
variant: AppButtonVariant.text,
|
||||
),
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
AppButton(
|
||||
text: confirmText,
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(true);
|
||||
onConfirm?.call();
|
||||
},
|
||||
variant: isDestructive
|
||||
? AppButtonVariant.filled
|
||||
: AppButtonVariant.filled,
|
||||
backgroundColor: isDestructive
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get appropriate icon color based on dialog type
|
||||
Color _getIconColor(ColorScheme colorScheme) {
|
||||
if (icon == Icons.error_outline) {
|
||||
return colorScheme.error;
|
||||
} else if (icon == Icons.warning_outlined) {
|
||||
return colorScheme.error;
|
||||
} else if (icon == Icons.info_outline) {
|
||||
return colorScheme.primary;
|
||||
}
|
||||
return colorScheme.onSurfaceVariant;
|
||||
}
|
||||
|
||||
/// Show this dialog
|
||||
static Future<T?> show<T extends Object?>({
|
||||
required BuildContext context,
|
||||
required AppDialog dialog,
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return showDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (context) => dialog,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple confirmation dialog helper
|
||||
class AppConfirmDialog {
|
||||
/// Show a confirmation dialog
|
||||
static Future<bool?> show({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = 'Confirm',
|
||||
String cancelText = 'Cancel',
|
||||
bool isDestructive = false,
|
||||
IconData? icon,
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return AppDialog.show<bool>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
dialog: AppDialog.confirmation(
|
||||
title: title,
|
||||
content: message,
|
||||
icon: icon,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
isDestructive: isDestructive,
|
||||
onConfirm: () {},
|
||||
onCancel: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a delete confirmation dialog
|
||||
static Future<bool?> showDelete({
|
||||
required BuildContext context,
|
||||
String title = 'Delete Item',
|
||||
String message = 'Are you sure you want to delete this item? This action cannot be undone.',
|
||||
String confirmText = 'Delete',
|
||||
String cancelText = 'Cancel',
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return show(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
isDestructive: true,
|
||||
icon: Icons.delete_outline,
|
||||
barrierDismissible: barrierDismissible,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple info dialog helper
|
||||
class AppInfoDialog {
|
||||
/// Show an information dialog
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String message,
|
||||
String okText = 'OK',
|
||||
IconData icon = Icons.info_outline,
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return AppDialog.show<void>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
dialog: AppDialog.info(
|
||||
title: title,
|
||||
content: message,
|
||||
icon: icon,
|
||||
okText: okText,
|
||||
onOk: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a success dialog
|
||||
static Future<void> showSuccess({
|
||||
required BuildContext context,
|
||||
String title = 'Success',
|
||||
required String message,
|
||||
String okText = 'OK',
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return show(
|
||||
context: context,
|
||||
title: title,
|
||||
message: message,
|
||||
okText: okText,
|
||||
icon: Icons.check_circle_outline,
|
||||
barrierDismissible: barrierDismissible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show an error dialog
|
||||
static Future<void> showError({
|
||||
required BuildContext context,
|
||||
String title = 'Error',
|
||||
required String message,
|
||||
String okText = 'OK',
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return AppDialog.show<void>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
dialog: AppDialog.error(
|
||||
title: title,
|
||||
content: message,
|
||||
okText: okText,
|
||||
onOk: () {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loading dialog that shows a progress indicator
|
||||
class AppLoadingDialog extends StatelessWidget {
|
||||
/// Creates a loading dialog
|
||||
const AppLoadingDialog({
|
||||
super.key,
|
||||
this.message = 'Loading...',
|
||||
this.showProgress = false,
|
||||
this.progress,
|
||||
this.barrierDismissible = false,
|
||||
});
|
||||
|
||||
/// Loading message
|
||||
final String message;
|
||||
|
||||
/// Whether to show determinate progress
|
||||
final bool showProgress;
|
||||
|
||||
/// Progress value (0.0 to 1.0)
|
||||
final double? progress;
|
||||
|
||||
/// Whether the dialog can be dismissed
|
||||
final bool barrierDismissible;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.dialogRadius,
|
||||
),
|
||||
content: IntrinsicHeight(
|
||||
child: Column(
|
||||
children: [
|
||||
if (showProgress && progress != null)
|
||||
LinearProgressIndicator(value: progress)
|
||||
else
|
||||
const CircularProgressIndicator(),
|
||||
AppSpacing.verticalSpaceLG,
|
||||
Text(
|
||||
message,
|
||||
style: AppTypography.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show loading dialog
|
||||
static Future<T> show<T>({
|
||||
required BuildContext context,
|
||||
required Future<T> future,
|
||||
String message = 'Loading...',
|
||||
bool showProgress = false,
|
||||
bool barrierDismissible = false,
|
||||
}) async {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (context) => AppLoadingDialog(
|
||||
message: message,
|
||||
showProgress: showProgress,
|
||||
barrierDismissible: barrierDismissible,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await future;
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom bottom sheet dialog
|
||||
class AppBottomSheetDialog extends StatelessWidget {
|
||||
/// Creates a bottom sheet dialog
|
||||
const AppBottomSheetDialog({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.child,
|
||||
this.actions,
|
||||
this.showDragHandle = true,
|
||||
this.isScrollControlled = false,
|
||||
this.maxHeight,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// Dialog title
|
||||
final String? title;
|
||||
|
||||
/// Dialog content
|
||||
final Widget child;
|
||||
|
||||
/// Action buttons
|
||||
final List<Widget>? actions;
|
||||
|
||||
/// Whether to show drag handle
|
||||
final bool showDragHandle;
|
||||
|
||||
/// Whether the sheet should be full screen
|
||||
final bool isScrollControlled;
|
||||
|
||||
/// Maximum height of the sheet
|
||||
final double? maxHeight;
|
||||
|
||||
/// Content padding
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final defaultMaxHeight = mediaQuery.size.height * 0.9;
|
||||
|
||||
Widget content = Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: maxHeight ?? defaultMaxHeight,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.bottomSheetTheme.backgroundColor ??
|
||||
theme.colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showDragHandle)
|
||||
Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.onSurfaceVariant.withOpacity(0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
|
||||
child: Text(
|
||||
title!,
|
||||
style: AppTypography.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: padding ??
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.screenPaddingLarge,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (actions != null && actions!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions!
|
||||
.expand((action) => [action, AppSpacing.horizontalSpaceSM])
|
||||
.take(actions!.length * 2 - 1)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Add semantic label for accessibility
|
||||
if (semanticLabel != null) {
|
||||
content = Semantics(
|
||||
label: semanticLabel,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/// Show bottom sheet dialog
|
||||
static Future<T?> show<T>({
|
||||
required BuildContext context,
|
||||
required AppBottomSheetDialog dialog,
|
||||
bool isDismissible = true,
|
||||
bool enableDrag = true,
|
||||
}) {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: (context) => dialog,
|
||||
isScrollControlled: dialog.isScrollControlled,
|
||||
isDismissible: isDismissible,
|
||||
enableDrag: enableDrag,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(28),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
501
lib/core/widgets/app_empty_state.dart
Normal file
501
lib/core/widgets/app_empty_state.dart
Normal file
@@ -0,0 +1,501 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
import '../theme/app_typography.dart';
|
||||
import 'app_button.dart';
|
||||
|
||||
/// Empty state widget with icon, message, and optional action button
|
||||
///
|
||||
/// Provides a consistent empty state design with customizable content
|
||||
/// and actions for when no data is available.
|
||||
class AppEmptyState extends StatelessWidget {
|
||||
/// Creates an empty state widget with the specified content
|
||||
const AppEmptyState({
|
||||
super.key,
|
||||
this.icon,
|
||||
this.title,
|
||||
this.message,
|
||||
this.actionText,
|
||||
this.onActionPressed,
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryActionPressed,
|
||||
this.illustration,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// Creates a "no data" empty state
|
||||
const AppEmptyState.noData({
|
||||
super.key,
|
||||
this.title = 'No data available',
|
||||
this.message = 'There is no data to display at the moment.',
|
||||
this.actionText,
|
||||
this.onActionPressed,
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryActionPressed,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
}) : icon = Icons.inbox_outlined,
|
||||
illustration = null;
|
||||
|
||||
/// Creates a "no search results" empty state
|
||||
const AppEmptyState.noSearchResults({
|
||||
super.key,
|
||||
this.title = 'No results found',
|
||||
this.message = 'Try adjusting your search criteria or filters.',
|
||||
this.actionText = 'Clear filters',
|
||||
this.onActionPressed,
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryActionPressed,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
}) : icon = Icons.search_off_outlined,
|
||||
illustration = null;
|
||||
|
||||
/// Creates a "no network" empty state
|
||||
const AppEmptyState.noNetwork({
|
||||
super.key,
|
||||
this.title = 'No internet connection',
|
||||
this.message = 'Please check your network connection and try again.',
|
||||
this.actionText = 'Retry',
|
||||
this.onActionPressed,
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryActionPressed,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
}) : icon = Icons.wifi_off_outlined,
|
||||
illustration = null;
|
||||
|
||||
/// Creates an "error" empty state
|
||||
const AppEmptyState.error({
|
||||
super.key,
|
||||
this.title = 'Something went wrong',
|
||||
this.message = 'An error occurred while loading the data.',
|
||||
this.actionText = 'Try again',
|
||||
this.onActionPressed,
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryActionPressed,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
}) : icon = Icons.error_outline,
|
||||
illustration = null;
|
||||
|
||||
/// Creates a "coming soon" empty state
|
||||
const AppEmptyState.comingSoon({
|
||||
super.key,
|
||||
this.title = 'Coming soon',
|
||||
this.message = 'This feature is currently under development.',
|
||||
this.actionText,
|
||||
this.onActionPressed,
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryActionPressed,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
}) : icon = Icons.construction_outlined,
|
||||
illustration = null;
|
||||
|
||||
/// Creates an "access denied" empty state
|
||||
const AppEmptyState.accessDenied({
|
||||
super.key,
|
||||
this.title = 'Access denied',
|
||||
this.message = 'You do not have permission to view this content.',
|
||||
this.actionText = 'Request access',
|
||||
this.onActionPressed,
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryActionPressed,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
}) : icon = Icons.lock_outline,
|
||||
illustration = null;
|
||||
|
||||
/// Icon to display (ignored if illustration is provided)
|
||||
final IconData? icon;
|
||||
|
||||
/// Custom illustration widget
|
||||
final Widget? illustration;
|
||||
|
||||
/// Main title text
|
||||
final String? title;
|
||||
|
||||
/// Descriptive message text
|
||||
final String? message;
|
||||
|
||||
/// Primary action button text
|
||||
final String? actionText;
|
||||
|
||||
/// Callback for primary action
|
||||
final VoidCallback? onActionPressed;
|
||||
|
||||
/// Secondary action button text
|
||||
final String? secondaryActionText;
|
||||
|
||||
/// Callback for secondary action
|
||||
final VoidCallback? onSecondaryActionPressed;
|
||||
|
||||
/// Maximum width of the empty state content
|
||||
final double maxWidth;
|
||||
|
||||
/// Padding around the content
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
Widget content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Icon or illustration
|
||||
if (illustration != null)
|
||||
illustration!
|
||||
else if (icon != null)
|
||||
Icon(
|
||||
icon,
|
||||
size: AppSpacing.iconXXL * 2, // 96dp
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
|
||||
AppSpacing.verticalSpaceXXL,
|
||||
|
||||
// Title
|
||||
if (title != null)
|
||||
Text(
|
||||
title!,
|
||||
style: AppTypography.headlineSmall.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Message
|
||||
if (message != null) ...[
|
||||
if (title != null) AppSpacing.verticalSpaceMD,
|
||||
Text(
|
||||
message!,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
// Actions
|
||||
if (actionText != null || secondaryActionText != null) ...[
|
||||
AppSpacing.verticalSpaceXXL,
|
||||
_buildActions(context),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Apply maximum width constraint
|
||||
content = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: content,
|
||||
);
|
||||
|
||||
// Apply padding
|
||||
content = Padding(
|
||||
padding: padding ?? const EdgeInsets.all(AppSpacing.screenPadding),
|
||||
child: content,
|
||||
);
|
||||
|
||||
// Center the content
|
||||
content = Center(child: content);
|
||||
|
||||
// Add semantic label for accessibility
|
||||
if (semanticLabel != null) {
|
||||
content = Semantics(
|
||||
label: semanticLabel,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/// Build action buttons
|
||||
Widget _buildActions(BuildContext context) {
|
||||
final List<Widget> actions = [];
|
||||
|
||||
// Primary action
|
||||
if (actionText != null && onActionPressed != null) {
|
||||
actions.add(
|
||||
AppButton(
|
||||
text: actionText!,
|
||||
onPressed: onActionPressed,
|
||||
variant: AppButtonVariant.filled,
|
||||
isFullWidth: secondaryActionText == null, // Full width if only one button
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Secondary action
|
||||
if (secondaryActionText != null && onSecondaryActionPressed != null) {
|
||||
if (actions.isNotEmpty) {
|
||||
actions.add(AppSpacing.verticalSpaceMD);
|
||||
}
|
||||
actions.add(
|
||||
AppButton(
|
||||
text: secondaryActionText!,
|
||||
onPressed: onSecondaryActionPressed,
|
||||
variant: AppButtonVariant.outlined,
|
||||
isFullWidth: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// If both actions exist and we want them side by side
|
||||
if (actions.length >= 3) {
|
||||
return Column(children: actions);
|
||||
}
|
||||
|
||||
// Single action or side-by-side layout
|
||||
if (actions.length == 1) {
|
||||
return actions.first;
|
||||
}
|
||||
|
||||
return Column(children: actions);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact empty state for smaller spaces (like lists)
|
||||
class AppCompactEmptyState extends StatelessWidget {
|
||||
/// Creates a compact empty state widget
|
||||
const AppCompactEmptyState({
|
||||
super.key,
|
||||
this.icon = Icons.inbox_outlined,
|
||||
required this.message,
|
||||
this.actionText,
|
||||
this.onActionPressed,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// Icon to display
|
||||
final IconData icon;
|
||||
|
||||
/// Message text
|
||||
final String message;
|
||||
|
||||
/// Action button text
|
||||
final String? actionText;
|
||||
|
||||
/// Callback for action
|
||||
final VoidCallback? onActionPressed;
|
||||
|
||||
/// Padding around the content
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
Widget content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: AppSpacing.iconLG,
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
AppSpacing.verticalSpaceMD,
|
||||
Text(
|
||||
message,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (actionText != null && onActionPressed != null) ...[
|
||||
AppSpacing.verticalSpaceMD,
|
||||
AppButton(
|
||||
text: actionText!,
|
||||
onPressed: onActionPressed,
|
||||
variant: AppButtonVariant.text,
|
||||
size: AppButtonSize.small,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Apply padding
|
||||
content = Padding(
|
||||
padding: padding ?? const EdgeInsets.all(AppSpacing.lg),
|
||||
child: content,
|
||||
);
|
||||
|
||||
// Center the content
|
||||
content = Center(child: content);
|
||||
|
||||
// Add semantic label for accessibility
|
||||
if (semanticLabel != null) {
|
||||
content = Semantics(
|
||||
label: semanticLabel,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty state for specific list/grid scenarios
|
||||
class AppListEmptyState extends StatelessWidget {
|
||||
/// Creates an empty state for lists
|
||||
const AppListEmptyState({
|
||||
super.key,
|
||||
this.type = AppListEmptyType.noItems,
|
||||
this.title,
|
||||
this.message,
|
||||
this.actionText,
|
||||
this.onActionPressed,
|
||||
this.searchQuery,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// Type of empty state
|
||||
final AppListEmptyType type;
|
||||
|
||||
/// Custom title (overrides default)
|
||||
final String? title;
|
||||
|
||||
/// Custom message (overrides default)
|
||||
final String? message;
|
||||
|
||||
/// Action button text
|
||||
final String? actionText;
|
||||
|
||||
/// Callback for action
|
||||
final VoidCallback? onActionPressed;
|
||||
|
||||
/// Search query for search results empty state
|
||||
final String? searchQuery;
|
||||
|
||||
/// Padding around the content
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = _getEmptyStateConfig();
|
||||
|
||||
return AppEmptyState(
|
||||
icon: config.icon,
|
||||
title: title ?? config.title,
|
||||
message: message ?? _getMessageWithQuery(config.message),
|
||||
actionText: actionText ?? config.actionText,
|
||||
onActionPressed: onActionPressed,
|
||||
padding: padding,
|
||||
semanticLabel: semanticLabel,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get configuration based on type
|
||||
_EmptyStateConfig _getEmptyStateConfig() {
|
||||
return switch (type) {
|
||||
AppListEmptyType.noItems => _EmptyStateConfig(
|
||||
icon: Icons.inbox_outlined,
|
||||
title: 'No items',
|
||||
message: 'There are no items to display.',
|
||||
actionText: 'Add item',
|
||||
),
|
||||
AppListEmptyType.noFavorites => _EmptyStateConfig(
|
||||
icon: Icons.favorite_border_outlined,
|
||||
title: 'No favorites',
|
||||
message: 'Items you mark as favorite will appear here.',
|
||||
actionText: 'Browse items',
|
||||
),
|
||||
AppListEmptyType.noSearchResults => _EmptyStateConfig(
|
||||
icon: Icons.search_off_outlined,
|
||||
title: 'No results found',
|
||||
message: 'No results found for your search.',
|
||||
actionText: 'Clear search',
|
||||
),
|
||||
AppListEmptyType.noNotifications => _EmptyStateConfig(
|
||||
icon: Icons.notifications_none_outlined,
|
||||
title: 'No notifications',
|
||||
message: 'You have no new notifications.',
|
||||
actionText: null,
|
||||
),
|
||||
AppListEmptyType.noMessages => _EmptyStateConfig(
|
||||
icon: Icons.message_outlined,
|
||||
title: 'No messages',
|
||||
message: 'You have no messages yet.',
|
||||
actionText: 'Start conversation',
|
||||
),
|
||||
AppListEmptyType.noHistory => _EmptyStateConfig(
|
||||
icon: Icons.history,
|
||||
title: 'No history',
|
||||
message: 'Your activity history will appear here.',
|
||||
actionText: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// Get message with search query if applicable
|
||||
String _getMessageWithQuery(String defaultMessage) {
|
||||
if (type == AppListEmptyType.noSearchResults && searchQuery != null) {
|
||||
return 'No results found for "$searchQuery". Try different keywords.';
|
||||
}
|
||||
return defaultMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for empty state
|
||||
class _EmptyStateConfig {
|
||||
const _EmptyStateConfig({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.actionText,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String message;
|
||||
final String? actionText;
|
||||
}
|
||||
|
||||
/// Types of list empty states
|
||||
enum AppListEmptyType {
|
||||
/// Generic no items state
|
||||
noItems,
|
||||
|
||||
/// No favorite items
|
||||
noFavorites,
|
||||
|
||||
/// No search results
|
||||
noSearchResults,
|
||||
|
||||
/// No notifications
|
||||
noNotifications,
|
||||
|
||||
/// No messages
|
||||
noMessages,
|
||||
|
||||
/// No history
|
||||
noHistory,
|
||||
}
|
||||
579
lib/core/widgets/app_error_widget.dart
Normal file
579
lib/core/widgets/app_error_widget.dart
Normal file
@@ -0,0 +1,579 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
import '../theme/app_typography.dart';
|
||||
import 'app_button.dart';
|
||||
|
||||
/// Error display widget with retry action and customizable styling
|
||||
///
|
||||
/// Provides consistent error handling UI with support for different error types,
|
||||
/// retry functionality, and accessibility features.
|
||||
class AppErrorWidget extends StatelessWidget {
|
||||
/// Creates an error widget with the specified configuration
|
||||
const AppErrorWidget({
|
||||
super.key,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
this.title,
|
||||
this.message,
|
||||
this.icon,
|
||||
this.onRetry,
|
||||
this.retryText = 'Retry',
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryAction,
|
||||
this.showDetails = false,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// Creates a network error widget
|
||||
const AppErrorWidget.network({
|
||||
super.key,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
this.title = 'Network Error',
|
||||
this.message = 'Please check your internet connection and try again.',
|
||||
this.onRetry,
|
||||
this.retryText = 'Retry',
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryAction,
|
||||
this.showDetails = false,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
}) : icon = Icons.wifi_off_outlined;
|
||||
|
||||
/// Creates a server error widget
|
||||
const AppErrorWidget.server({
|
||||
super.key,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
this.title = 'Server Error',
|
||||
this.message = 'Something went wrong on our end. Please try again later.',
|
||||
this.onRetry,
|
||||
this.retryText = 'Retry',
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryAction,
|
||||
this.showDetails = false,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
}) : icon = Icons.dns_outlined;
|
||||
|
||||
/// Creates a not found error widget
|
||||
const AppErrorWidget.notFound({
|
||||
super.key,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
this.title = 'Not Found',
|
||||
this.message = 'The requested content could not be found.',
|
||||
this.onRetry,
|
||||
this.retryText = 'Go Back',
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryAction,
|
||||
this.showDetails = false,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
}) : icon = Icons.search_off_outlined;
|
||||
|
||||
/// Creates a generic error widget
|
||||
const AppErrorWidget.generic({
|
||||
super.key,
|
||||
this.error,
|
||||
this.stackTrace,
|
||||
this.title = 'Something went wrong',
|
||||
this.message = 'An unexpected error occurred. Please try again.',
|
||||
this.onRetry,
|
||||
this.retryText = 'Retry',
|
||||
this.secondaryActionText,
|
||||
this.onSecondaryAction,
|
||||
this.showDetails = false,
|
||||
this.maxWidth = 320,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
}) : icon = Icons.error_outline;
|
||||
|
||||
/// The error object (for development/debugging)
|
||||
final Object? error;
|
||||
|
||||
/// Stack trace (for development/debugging)
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
/// Error title
|
||||
final String? title;
|
||||
|
||||
/// Error message
|
||||
final String? message;
|
||||
|
||||
/// Error icon
|
||||
final IconData? icon;
|
||||
|
||||
/// Retry callback
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
/// Retry button text
|
||||
final String retryText;
|
||||
|
||||
/// Secondary action text
|
||||
final String? secondaryActionText;
|
||||
|
||||
/// Secondary action callback
|
||||
final VoidCallback? onSecondaryAction;
|
||||
|
||||
/// Whether to show error details (for debugging)
|
||||
final bool showDetails;
|
||||
|
||||
/// Maximum width of the error content
|
||||
final double maxWidth;
|
||||
|
||||
/// Padding around the content
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isDebug = _isDebugMode();
|
||||
|
||||
Widget content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Error icon
|
||||
if (icon != null)
|
||||
Icon(
|
||||
icon,
|
||||
size: AppSpacing.iconXXL * 1.5, // 72dp
|
||||
color: colorScheme.error.withOpacity(0.8),
|
||||
),
|
||||
|
||||
AppSpacing.verticalSpaceXL,
|
||||
|
||||
// Error title
|
||||
if (title != null)
|
||||
Text(
|
||||
title!,
|
||||
style: AppTypography.headlineSmall.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (message != null) ...[
|
||||
if (title != null) AppSpacing.verticalSpaceMD,
|
||||
Text(
|
||||
message!,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
|
||||
// Error details (debug mode only)
|
||||
if (isDebug && showDetails && error != null) ...[
|
||||
AppSpacing.verticalSpaceLG,
|
||||
_buildErrorDetails(context),
|
||||
],
|
||||
|
||||
// Action buttons
|
||||
if (onRetry != null || onSecondaryAction != null) ...[
|
||||
AppSpacing.verticalSpaceXXL,
|
||||
_buildActions(context),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Apply maximum width constraint
|
||||
content = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: content,
|
||||
);
|
||||
|
||||
// Apply padding
|
||||
content = Padding(
|
||||
padding: padding ?? const EdgeInsets.all(AppSpacing.screenPadding),
|
||||
child: content,
|
||||
);
|
||||
|
||||
// Center the content
|
||||
content = Center(child: content);
|
||||
|
||||
// Add semantic label for accessibility
|
||||
if (semanticLabel != null) {
|
||||
content = Semantics(
|
||||
label: semanticLabel,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/// Build action buttons
|
||||
Widget _buildActions(BuildContext context) {
|
||||
final List<Widget> actions = [];
|
||||
|
||||
// Retry button
|
||||
if (onRetry != null) {
|
||||
actions.add(
|
||||
AppButton(
|
||||
text: retryText,
|
||||
onPressed: onRetry,
|
||||
variant: AppButtonVariant.filled,
|
||||
isFullWidth: secondaryActionText == null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Secondary action
|
||||
if (secondaryActionText != null && onSecondaryAction != null) {
|
||||
if (actions.isNotEmpty) {
|
||||
actions.add(AppSpacing.verticalSpaceMD);
|
||||
}
|
||||
actions.add(
|
||||
AppButton(
|
||||
text: secondaryActionText!,
|
||||
onPressed: onSecondaryAction,
|
||||
variant: AppButtonVariant.outlined,
|
||||
isFullWidth: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(children: actions);
|
||||
}
|
||||
|
||||
/// Build error details for debugging
|
||||
Widget _buildErrorDetails(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer.withOpacity(0.1),
|
||||
borderRadius: AppSpacing.radiusMD,
|
||||
border: Border.all(
|
||||
color: colorScheme.error.withOpacity(0.3),
|
||||
width: AppSpacing.borderWidth,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bug_report_outlined,
|
||||
size: AppSpacing.iconSM,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
AppSpacing.horizontalSpaceXS,
|
||||
Text(
|
||||
'Debug Information',
|
||||
style: AppTypography.labelMedium.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text(
|
||||
error.toString(),
|
||||
style: AppTypography.bodySmall.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
maxLines: 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (stackTrace != null) ...[
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text(
|
||||
'Stack trace available (check console)',
|
||||
style: AppTypography.bodySmall.copyWith(
|
||||
color: colorScheme.onErrorContainer.withOpacity(0.7),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if app is in debug mode
|
||||
bool _isDebugMode() {
|
||||
bool inDebugMode = false;
|
||||
assert(inDebugMode = true);
|
||||
return inDebugMode;
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact error widget for smaller spaces
|
||||
class AppCompactErrorWidget extends StatelessWidget {
|
||||
/// Creates a compact error widget
|
||||
const AppCompactErrorWidget({
|
||||
super.key,
|
||||
this.error,
|
||||
this.message = 'An error occurred',
|
||||
this.onRetry,
|
||||
this.retryText = 'Retry',
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// The error object
|
||||
final Object? error;
|
||||
|
||||
/// Error message
|
||||
final String message;
|
||||
|
||||
/// Retry callback
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
/// Retry button text
|
||||
final String retryText;
|
||||
|
||||
/// Padding around the content
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
Widget content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: AppSpacing.iconLG,
|
||||
color: colorScheme.error.withOpacity(0.8),
|
||||
),
|
||||
AppSpacing.verticalSpaceMD,
|
||||
Text(
|
||||
message,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
AppSpacing.verticalSpaceMD,
|
||||
AppButton(
|
||||
text: retryText,
|
||||
onPressed: onRetry,
|
||||
variant: AppButtonVariant.text,
|
||||
size: AppButtonSize.small,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Apply padding
|
||||
content = Padding(
|
||||
padding: padding ?? const EdgeInsets.all(AppSpacing.lg),
|
||||
child: content,
|
||||
);
|
||||
|
||||
// Center the content
|
||||
content = Center(child: content);
|
||||
|
||||
// Add semantic label for accessibility
|
||||
if (semanticLabel != null) {
|
||||
content = Semantics(
|
||||
label: semanticLabel,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline error widget for form fields and small spaces
|
||||
class AppInlineErrorWidget extends StatelessWidget {
|
||||
/// Creates an inline error widget
|
||||
const AppInlineErrorWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.icon = Icons.error_outline,
|
||||
this.onRetry,
|
||||
this.retryText = 'Retry',
|
||||
this.color,
|
||||
this.backgroundColor,
|
||||
this.padding,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// Error message
|
||||
final String message;
|
||||
|
||||
/// Error icon
|
||||
final IconData icon;
|
||||
|
||||
/// Retry callback
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
/// Retry button text
|
||||
final String retryText;
|
||||
|
||||
/// Error color override
|
||||
final Color? color;
|
||||
|
||||
/// Background color override
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Padding around the content
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final errorColor = color ?? colorScheme.error;
|
||||
|
||||
Widget content = Container(
|
||||
padding: padding ?? const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? errorColor.withOpacity(0.1),
|
||||
borderRadius: AppSpacing.radiusSM,
|
||||
border: Border.all(
|
||||
color: errorColor.withOpacity(0.3),
|
||||
width: AppSpacing.borderWidth,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: AppSpacing.iconSM,
|
||||
color: errorColor,
|
||||
),
|
||||
AppSpacing.horizontalSpaceXS,
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: AppTypography.bodySmall.copyWith(
|
||||
color: errorColor,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
AppButton(
|
||||
text: retryText,
|
||||
onPressed: onRetry,
|
||||
variant: AppButtonVariant.text,
|
||||
size: AppButtonSize.small,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Add semantic label for accessibility
|
||||
if (semanticLabel != null) {
|
||||
content = Semantics(
|
||||
label: semanticLabel,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
/// Error boundary widget that catches errors in child widgets
|
||||
class AppErrorBoundary extends StatefulWidget {
|
||||
/// Creates an error boundary that catches errors in child widgets
|
||||
const AppErrorBoundary({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onError,
|
||||
this.errorWidgetBuilder,
|
||||
this.fallbackWidget,
|
||||
});
|
||||
|
||||
/// The child widget to wrap with error handling
|
||||
final Widget child;
|
||||
|
||||
/// Callback when an error occurs
|
||||
final void Function(Object error, StackTrace stackTrace)? onError;
|
||||
|
||||
/// Custom error widget builder
|
||||
final Widget Function(Object error, StackTrace stackTrace)? errorWidgetBuilder;
|
||||
|
||||
/// Fallback widget to show when error occurs (used if errorWidgetBuilder is null)
|
||||
final Widget? fallbackWidget;
|
||||
|
||||
@override
|
||||
State<AppErrorBoundary> createState() => _AppErrorBoundaryState();
|
||||
}
|
||||
|
||||
class _AppErrorBoundaryState extends State<AppErrorBoundary> {
|
||||
Object? _error;
|
||||
StackTrace? _stackTrace;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ErrorWidget.builder = (FlutterErrorDetails details) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = details.exception;
|
||||
_stackTrace = details.stack;
|
||||
});
|
||||
}
|
||||
widget.onError?.call(details.exception, details.stack ?? StackTrace.current);
|
||||
|
||||
return widget.errorWidgetBuilder?.call(
|
||||
details.exception,
|
||||
details.stack ?? StackTrace.current,
|
||||
) ?? widget.fallbackWidget ?? const AppErrorWidget.generic();
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null) {
|
||||
return widget.errorWidgetBuilder?.call(_error!, _stackTrace!) ??
|
||||
widget.fallbackWidget ??
|
||||
AppErrorWidget.generic(
|
||||
error: _error,
|
||||
stackTrace: _stackTrace,
|
||||
onRetry: _handleRetry,
|
||||
);
|
||||
}
|
||||
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
void _handleRetry() {
|
||||
setState(() {
|
||||
_error = null;
|
||||
_stackTrace = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
529
lib/core/widgets/app_loading_indicator.dart
Normal file
529
lib/core/widgets/app_loading_indicator.dart
Normal file
@@ -0,0 +1,529 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
import '../theme/app_typography.dart';
|
||||
|
||||
/// Various loading states including circular, linear, and skeleton loaders
|
||||
///
|
||||
/// Provides consistent loading indicators with customizable styling
|
||||
/// and accessibility features.
|
||||
class AppLoadingIndicator extends StatelessWidget {
|
||||
/// Creates a loading indicator with the specified type and styling
|
||||
const AppLoadingIndicator({
|
||||
super.key,
|
||||
this.type = AppLoadingType.circular,
|
||||
this.size = AppLoadingSize.medium,
|
||||
this.color,
|
||||
this.backgroundColor,
|
||||
this.strokeWidth,
|
||||
this.value,
|
||||
this.message,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// The type of loading indicator to display
|
||||
final AppLoadingType type;
|
||||
|
||||
/// The size of the loading indicator
|
||||
final AppLoadingSize size;
|
||||
|
||||
/// Color override for the indicator
|
||||
final Color? color;
|
||||
|
||||
/// Background color override (for linear progress)
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Stroke width override (for circular progress)
|
||||
final double? strokeWidth;
|
||||
|
||||
/// Progress value (0.0 to 1.0) for determinate progress indicators
|
||||
final double? value;
|
||||
|
||||
/// Optional message to display below the indicator
|
||||
final String? message;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
Widget indicator = switch (type) {
|
||||
AppLoadingType.circular => _buildCircularIndicator(colorScheme),
|
||||
AppLoadingType.linear => _buildLinearIndicator(colorScheme),
|
||||
AppLoadingType.adaptive => _buildAdaptiveIndicator(context, colorScheme),
|
||||
AppLoadingType.refresh => _buildRefreshIndicator(colorScheme),
|
||||
};
|
||||
|
||||
// Add message if provided
|
||||
if (message != null) {
|
||||
indicator = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
indicator,
|
||||
AppSpacing.verticalSpaceMD,
|
||||
Text(
|
||||
message!,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Add semantic label for accessibility
|
||||
if (semanticLabel != null) {
|
||||
indicator = Semantics(
|
||||
label: semanticLabel,
|
||||
child: indicator,
|
||||
);
|
||||
}
|
||||
|
||||
return indicator;
|
||||
}
|
||||
|
||||
/// Build circular progress indicator
|
||||
Widget _buildCircularIndicator(ColorScheme colorScheme) {
|
||||
final indicatorSize = _getIndicatorSize();
|
||||
|
||||
return SizedBox(
|
||||
width: indicatorSize,
|
||||
height: indicatorSize,
|
||||
child: CircularProgressIndicator(
|
||||
value: value,
|
||||
color: color ?? colorScheme.primary,
|
||||
backgroundColor: backgroundColor,
|
||||
strokeWidth: strokeWidth ?? _getStrokeWidth(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build linear progress indicator
|
||||
Widget _buildLinearIndicator(ColorScheme colorScheme) {
|
||||
return LinearProgressIndicator(
|
||||
value: value,
|
||||
color: color ?? colorScheme.primary,
|
||||
backgroundColor: backgroundColor ?? colorScheme.surfaceContainerHighest,
|
||||
minHeight: _getLinearHeight(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build adaptive progress indicator (Material on Android, Cupertino on iOS)
|
||||
Widget _buildAdaptiveIndicator(BuildContext context, ColorScheme colorScheme) {
|
||||
final indicatorSize = _getIndicatorSize();
|
||||
|
||||
return SizedBox(
|
||||
width: indicatorSize,
|
||||
height: indicatorSize,
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
value: value,
|
||||
backgroundColor: backgroundColor,
|
||||
strokeWidth: strokeWidth ?? _getStrokeWidth(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Build refresh indicator style circular indicator
|
||||
Widget _buildRefreshIndicator(ColorScheme colorScheme) {
|
||||
final indicatorSize = _getIndicatorSize();
|
||||
|
||||
return SizedBox(
|
||||
width: indicatorSize,
|
||||
height: indicatorSize,
|
||||
child: RefreshProgressIndicator(
|
||||
value: value,
|
||||
color: color ?? colorScheme.primary,
|
||||
backgroundColor: backgroundColor ?? colorScheme.surface,
|
||||
strokeWidth: strokeWidth ?? _getStrokeWidth(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get indicator size based on size enum
|
||||
double _getIndicatorSize() {
|
||||
return switch (size) {
|
||||
AppLoadingSize.small => AppSpacing.iconMD,
|
||||
AppLoadingSize.medium => AppSpacing.iconLG,
|
||||
AppLoadingSize.large => AppSpacing.iconXL,
|
||||
AppLoadingSize.extraLarge => AppSpacing.iconXXL,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get stroke width based on size
|
||||
double _getStrokeWidth() {
|
||||
return switch (size) {
|
||||
AppLoadingSize.small => 2.0,
|
||||
AppLoadingSize.medium => 3.0,
|
||||
AppLoadingSize.large => 4.0,
|
||||
AppLoadingSize.extraLarge => 5.0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get linear indicator height based on size
|
||||
double _getLinearHeight() {
|
||||
return switch (size) {
|
||||
AppLoadingSize.small => 2.0,
|
||||
AppLoadingSize.medium => 4.0,
|
||||
AppLoadingSize.large => 6.0,
|
||||
AppLoadingSize.extraLarge => 8.0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Available loading indicator types
|
||||
enum AppLoadingType {
|
||||
/// Circular progress indicator
|
||||
circular,
|
||||
|
||||
/// Linear progress indicator
|
||||
linear,
|
||||
|
||||
/// Adaptive indicator (platform-specific)
|
||||
adaptive,
|
||||
|
||||
/// Refresh indicator style
|
||||
refresh,
|
||||
}
|
||||
|
||||
/// Available loading indicator sizes
|
||||
enum AppLoadingSize {
|
||||
/// Small indicator (24dp)
|
||||
small,
|
||||
|
||||
/// Medium indicator (32dp) - default
|
||||
medium,
|
||||
|
||||
/// Large indicator (40dp)
|
||||
large,
|
||||
|
||||
/// Extra large indicator (48dp)
|
||||
extraLarge,
|
||||
}
|
||||
|
||||
/// Skeleton loader for content placeholders
|
||||
class AppSkeletonLoader extends StatefulWidget {
|
||||
/// Creates a skeleton loader with the specified dimensions and styling
|
||||
const AppSkeletonLoader({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height = 16.0,
|
||||
this.borderRadius,
|
||||
this.baseColor,
|
||||
this.highlightColor,
|
||||
this.animationDuration = const Duration(milliseconds: 1500),
|
||||
});
|
||||
|
||||
/// Creates a rectangular skeleton loader
|
||||
const AppSkeletonLoader.rectangle({
|
||||
super.key,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.borderRadius,
|
||||
this.baseColor,
|
||||
this.highlightColor,
|
||||
this.animationDuration = const Duration(milliseconds: 1500),
|
||||
});
|
||||
|
||||
/// Creates a circular skeleton loader (avatar placeholder)
|
||||
AppSkeletonLoader.circle({
|
||||
super.key,
|
||||
required double diameter,
|
||||
this.baseColor,
|
||||
this.highlightColor,
|
||||
this.animationDuration = const Duration(milliseconds: 1500),
|
||||
}) : width = diameter,
|
||||
height = diameter,
|
||||
borderRadius = BorderRadius.circular(diameter / 2.0);
|
||||
|
||||
/// Creates a text line skeleton loader
|
||||
const AppSkeletonLoader.text({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height = 16.0,
|
||||
this.baseColor,
|
||||
this.highlightColor,
|
||||
this.animationDuration = const Duration(milliseconds: 1500),
|
||||
}) : borderRadius = const BorderRadius.all(Radius.circular(8.0));
|
||||
|
||||
/// Width of the skeleton loader
|
||||
final double? width;
|
||||
|
||||
/// Height of the skeleton loader
|
||||
final double height;
|
||||
|
||||
/// Border radius for rounded corners
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
/// Base color for the skeleton
|
||||
final Color? baseColor;
|
||||
|
||||
/// Highlight color for the animation
|
||||
final Color? highlightColor;
|
||||
|
||||
/// Duration of the shimmer animation
|
||||
final Duration animationDuration;
|
||||
|
||||
@override
|
||||
State<AppSkeletonLoader> createState() => _AppSkeletonLoaderState();
|
||||
}
|
||||
|
||||
class _AppSkeletonLoaderState extends State<AppSkeletonLoader>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: widget.animationDuration,
|
||||
vsync: this,
|
||||
);
|
||||
_animation = Tween<double>(begin: -1.0, end: 2.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.ease),
|
||||
);
|
||||
_animationController.repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final baseColor = widget.baseColor ??
|
||||
(theme.brightness == Brightness.light
|
||||
? colorScheme.surfaceContainerHighest
|
||||
: colorScheme.surfaceContainerLowest);
|
||||
|
||||
final highlightColor = widget.highlightColor ??
|
||||
(theme.brightness == Brightness.light
|
||||
? colorScheme.surface
|
||||
: colorScheme.surfaceContainerHigh);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: widget.borderRadius ?? AppSpacing.radiusSM,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
baseColor,
|
||||
highlightColor,
|
||||
baseColor,
|
||||
],
|
||||
stops: [
|
||||
0.0,
|
||||
0.5,
|
||||
1.0,
|
||||
],
|
||||
transform: GradientRotation(_animation.value),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton loader for list items
|
||||
class AppListItemSkeleton extends StatelessWidget {
|
||||
/// Creates a skeleton loader for list items with avatar and text
|
||||
const AppListItemSkeleton({
|
||||
super.key,
|
||||
this.hasAvatar = true,
|
||||
this.hasSubtitle = true,
|
||||
this.hasTrailing = false,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
/// Whether to show avatar placeholder
|
||||
final bool hasAvatar;
|
||||
|
||||
/// Whether to show subtitle placeholder
|
||||
final bool hasSubtitle;
|
||||
|
||||
/// Whether to show trailing element placeholder
|
||||
final bool hasTrailing;
|
||||
|
||||
/// Padding around the skeleton
|
||||
final EdgeInsets? padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: padding ?? const EdgeInsets.all(AppSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
if (hasAvatar) ...[
|
||||
AppSkeletonLoader.circle(diameter: 40.0),
|
||||
AppSpacing.horizontalSpaceMD,
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppSkeletonLoader.text(width: double.infinity),
|
||||
if (hasSubtitle) ...[
|
||||
AppSpacing.verticalSpaceXS,
|
||||
const AppSkeletonLoader.text(width: 200),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasTrailing) ...[
|
||||
AppSpacing.horizontalSpaceMD,
|
||||
const AppSkeletonLoader.rectangle(width: 60, height: 32),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton loader for card content
|
||||
class AppCardSkeleton extends StatelessWidget {
|
||||
/// Creates a skeleton loader for card content
|
||||
const AppCardSkeleton({
|
||||
super.key,
|
||||
this.hasImage = false,
|
||||
this.hasTitle = true,
|
||||
this.hasSubtitle = true,
|
||||
this.hasContent = true,
|
||||
this.hasActions = false,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
/// Whether to show image placeholder
|
||||
final bool hasImage;
|
||||
|
||||
/// Whether to show title placeholder
|
||||
final bool hasTitle;
|
||||
|
||||
/// Whether to show subtitle placeholder
|
||||
final bool hasSubtitle;
|
||||
|
||||
/// Whether to show content placeholder
|
||||
final bool hasContent;
|
||||
|
||||
/// Whether to show action buttons placeholder
|
||||
final bool hasActions;
|
||||
|
||||
/// Padding around the skeleton
|
||||
final EdgeInsets? padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: padding ?? const EdgeInsets.all(AppSpacing.cardPadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasImage) ...[
|
||||
const AppSkeletonLoader.rectangle(
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
),
|
||||
AppSpacing.verticalSpaceMD,
|
||||
],
|
||||
if (hasTitle) ...[
|
||||
const AppSkeletonLoader.text(width: 250),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
],
|
||||
if (hasSubtitle) ...[
|
||||
const AppSkeletonLoader.text(width: 180),
|
||||
AppSpacing.verticalSpaceMD,
|
||||
],
|
||||
if (hasContent) ...[
|
||||
const AppSkeletonLoader.text(width: double.infinity),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
const AppSkeletonLoader.text(width: double.infinity),
|
||||
AppSpacing.verticalSpaceXS,
|
||||
const AppSkeletonLoader.text(width: 300),
|
||||
],
|
||||
if (hasActions) ...[
|
||||
AppSpacing.verticalSpaceMD,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const AppSkeletonLoader.rectangle(width: 80, height: 36),
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
const AppSkeletonLoader.rectangle(width: 80, height: 36),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Loading overlay that can be displayed over content
|
||||
class AppLoadingOverlay extends StatelessWidget {
|
||||
/// Creates a loading overlay with the specified content and styling
|
||||
const AppLoadingOverlay({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.child,
|
||||
this.loadingWidget,
|
||||
this.backgroundColor,
|
||||
this.message,
|
||||
this.dismissible = false,
|
||||
});
|
||||
|
||||
/// Whether the loading overlay should be displayed
|
||||
final bool isLoading;
|
||||
|
||||
/// The content to display behind the overlay
|
||||
final Widget child;
|
||||
|
||||
/// Custom loading widget (defaults to circular indicator)
|
||||
final Widget? loadingWidget;
|
||||
|
||||
/// Background color of the overlay
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Optional message to display with the loading indicator
|
||||
final String? message;
|
||||
|
||||
/// Whether the overlay can be dismissed by tapping
|
||||
final bool dismissible;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
if (isLoading)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: backgroundColor ??
|
||||
theme.colorScheme.surface.withOpacity(0.8),
|
||||
child: Center(
|
||||
child: loadingWidget ??
|
||||
AppLoadingIndicator(
|
||||
message: message,
|
||||
semanticLabel: 'Loading content',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
621
lib/core/widgets/app_snackbar.dart
Normal file
621
lib/core/widgets/app_snackbar.dart
Normal file
@@ -0,0 +1,621 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
import '../theme/app_typography.dart';
|
||||
|
||||
/// Snackbar utilities for consistent notifications and user feedback
|
||||
///
|
||||
/// Provides easy-to-use static methods for showing different types of
|
||||
/// snackbars with Material 3 styling and customization options.
|
||||
class AppSnackbar {
|
||||
// Prevent instantiation
|
||||
AppSnackbar._();
|
||||
|
||||
/// Show a basic snackbar
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> show({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
SnackBarAction? action,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
double? elevation,
|
||||
EdgeInsets? margin,
|
||||
ShapeBorder? shape,
|
||||
SnackBarBehavior behavior = SnackBarBehavior.floating,
|
||||
bool showCloseIcon = false,
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final snackBarTheme = theme.snackBarTheme;
|
||||
|
||||
final snackBar = SnackBar(
|
||||
content: Text(
|
||||
message,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: textColor ?? snackBarTheme.contentTextStyle?.color,
|
||||
),
|
||||
),
|
||||
duration: duration,
|
||||
action: action,
|
||||
backgroundColor: backgroundColor ?? snackBarTheme.backgroundColor,
|
||||
elevation: elevation ?? snackBarTheme.elevation,
|
||||
margin: margin ?? _getDefaultMargin(context),
|
||||
shape: shape ?? snackBarTheme.shape,
|
||||
behavior: behavior,
|
||||
showCloseIcon: showCloseIcon,
|
||||
onVisible: onVisible,
|
||||
);
|
||||
|
||||
return ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
/// Show a success snackbar
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showSuccess({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
SnackBarAction? action,
|
||||
bool showCloseIcon = false,
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final successColor = _getSuccessColor(theme);
|
||||
|
||||
return _showWithIcon(
|
||||
context: context,
|
||||
message: message,
|
||||
icon: Icons.check_circle_outline,
|
||||
backgroundColor: successColor,
|
||||
textColor: _getOnColor(successColor),
|
||||
iconColor: _getOnColor(successColor),
|
||||
duration: duration,
|
||||
action: action,
|
||||
showCloseIcon: showCloseIcon,
|
||||
onVisible: onVisible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show an error snackbar
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showError({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
Duration duration = const Duration(seconds: 5),
|
||||
SnackBarAction? action,
|
||||
bool showCloseIcon = true,
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return _showWithIcon(
|
||||
context: context,
|
||||
message: message,
|
||||
icon: Icons.error_outline,
|
||||
backgroundColor: colorScheme.error,
|
||||
textColor: colorScheme.onError,
|
||||
iconColor: colorScheme.onError,
|
||||
duration: duration,
|
||||
action: action,
|
||||
showCloseIcon: showCloseIcon,
|
||||
onVisible: onVisible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a warning snackbar
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showWarning({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
SnackBarAction? action,
|
||||
bool showCloseIcon = false,
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final warningColor = _getWarningColor(theme);
|
||||
|
||||
return _showWithIcon(
|
||||
context: context,
|
||||
message: message,
|
||||
icon: Icons.warning_outlined,
|
||||
backgroundColor: warningColor,
|
||||
textColor: _getOnColor(warningColor),
|
||||
iconColor: _getOnColor(warningColor),
|
||||
duration: duration,
|
||||
action: action,
|
||||
showCloseIcon: showCloseIcon,
|
||||
onVisible: onVisible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show an info snackbar
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showInfo({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
SnackBarAction? action,
|
||||
bool showCloseIcon = false,
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final infoColor = _getInfoColor(theme);
|
||||
|
||||
return _showWithIcon(
|
||||
context: context,
|
||||
message: message,
|
||||
icon: Icons.info_outline,
|
||||
backgroundColor: infoColor,
|
||||
textColor: _getOnColor(infoColor),
|
||||
iconColor: _getOnColor(infoColor),
|
||||
duration: duration,
|
||||
action: action,
|
||||
showCloseIcon: showCloseIcon,
|
||||
onVisible: onVisible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a loading snackbar
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showLoading({
|
||||
required BuildContext context,
|
||||
String message = 'Loading...',
|
||||
Duration duration = const Duration(seconds: 30),
|
||||
bool showCloseIcon = false,
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final snackBar = SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: AppSpacing.iconSM,
|
||||
height: AppSpacing.iconSM,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
AppSpacing.horizontalSpaceMD,
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: duration,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
margin: _getDefaultMargin(context),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppSpacing.radiusSM,
|
||||
),
|
||||
showCloseIcon: showCloseIcon,
|
||||
onVisible: onVisible,
|
||||
);
|
||||
|
||||
return ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
/// Show an action snackbar with custom button
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showAction({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
required String actionLabel,
|
||||
required VoidCallback onActionPressed,
|
||||
Duration duration = const Duration(seconds: 6),
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
Color? actionColor,
|
||||
bool showCloseIcon = false,
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return show(
|
||||
context: context,
|
||||
message: message,
|
||||
duration: duration,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
showCloseIcon: showCloseIcon,
|
||||
onVisible: onVisible,
|
||||
action: SnackBarAction(
|
||||
label: actionLabel,
|
||||
onPressed: onActionPressed,
|
||||
textColor: actionColor ?? colorScheme.inversePrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show an undo snackbar
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showUndo({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
required VoidCallback onUndo,
|
||||
String undoLabel = 'Undo',
|
||||
Duration duration = const Duration(seconds: 5),
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
return showAction(
|
||||
context: context,
|
||||
message: message,
|
||||
actionLabel: undoLabel,
|
||||
onActionPressed: onUndo,
|
||||
duration: duration,
|
||||
onVisible: onVisible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a retry snackbar
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showRetry({
|
||||
required BuildContext context,
|
||||
String message = 'Something went wrong',
|
||||
required VoidCallback onRetry,
|
||||
String retryLabel = 'Retry',
|
||||
Duration duration = const Duration(seconds: 6),
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return _showWithIcon(
|
||||
context: context,
|
||||
message: message,
|
||||
icon: Icons.refresh,
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
textColor: colorScheme.onErrorContainer,
|
||||
iconColor: colorScheme.onErrorContainer,
|
||||
duration: duration,
|
||||
action: SnackBarAction(
|
||||
label: retryLabel,
|
||||
onPressed: onRetry,
|
||||
textColor: colorScheme.error,
|
||||
),
|
||||
onVisible: onVisible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a custom snackbar with icon
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showCustom({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
IconData? icon,
|
||||
Color? iconColor,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
SnackBarAction? action,
|
||||
bool showCloseIcon = false,
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
if (icon != null) {
|
||||
return _showWithIcon(
|
||||
context: context,
|
||||
message: message,
|
||||
icon: icon,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
iconColor: iconColor,
|
||||
duration: duration,
|
||||
action: action,
|
||||
showCloseIcon: showCloseIcon,
|
||||
onVisible: onVisible,
|
||||
);
|
||||
}
|
||||
|
||||
return show(
|
||||
context: context,
|
||||
message: message,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
duration: duration,
|
||||
action: action,
|
||||
showCloseIcon: showCloseIcon,
|
||||
onVisible: onVisible,
|
||||
);
|
||||
}
|
||||
|
||||
/// Hide current snackbar
|
||||
static void hide(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).hideCurrentSnackBar();
|
||||
}
|
||||
|
||||
/// Remove all snackbars
|
||||
static void removeAll(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
/// Show snackbar with icon
|
||||
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> _showWithIcon({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
required IconData icon,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
Color? iconColor,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
SnackBarAction? action,
|
||||
bool showCloseIcon = false,
|
||||
VoidCallback? onVisible,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final snackBarTheme = theme.snackBarTheme;
|
||||
|
||||
final snackBar = SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: AppSpacing.iconSM,
|
||||
color: iconColor ?? textColor,
|
||||
),
|
||||
AppSpacing.horizontalSpaceMD,
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: textColor ?? snackBarTheme.contentTextStyle?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: duration,
|
||||
action: action,
|
||||
backgroundColor: backgroundColor ?? snackBarTheme.backgroundColor,
|
||||
elevation: snackBarTheme.elevation,
|
||||
margin: _getDefaultMargin(context),
|
||||
shape: snackBarTheme.shape,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
showCloseIcon: showCloseIcon,
|
||||
onVisible: onVisible,
|
||||
);
|
||||
|
||||
return ScaffoldMessenger.of(context).showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
/// Get default margin for floating snackbars
|
||||
static EdgeInsets _getDefaultMargin(BuildContext context) {
|
||||
return const EdgeInsets.all(AppSpacing.md);
|
||||
}
|
||||
|
||||
/// Get success color from theme extension or fallback
|
||||
static Color _getSuccessColor(ThemeData theme) {
|
||||
return const Color(0xFF4CAF50); // Material Green 500
|
||||
}
|
||||
|
||||
/// Get warning color from theme extension or fallback
|
||||
static Color _getWarningColor(ThemeData theme) {
|
||||
return const Color(0xFFFF9800); // Material Orange 500
|
||||
}
|
||||
|
||||
/// Get info color from theme extension or fallback
|
||||
static Color _getInfoColor(ThemeData theme) {
|
||||
return theme.colorScheme.primary;
|
||||
}
|
||||
|
||||
/// Get appropriate "on" color for given background color
|
||||
static Color _getOnColor(Color backgroundColor) {
|
||||
// Simple calculation based on luminance
|
||||
return backgroundColor.computeLuminance() > 0.5
|
||||
? Colors.black87
|
||||
: Colors.white;
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom snackbar widget for more complex layouts
|
||||
class AppCustomSnackbar extends StatelessWidget {
|
||||
/// Creates a custom snackbar widget
|
||||
const AppCustomSnackbar({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.backgroundColor,
|
||||
this.elevation,
|
||||
this.margin,
|
||||
this.shape,
|
||||
this.behavior = SnackBarBehavior.floating,
|
||||
this.width,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
/// The content to display in the snackbar
|
||||
final Widget child;
|
||||
|
||||
/// Background color override
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Elevation override
|
||||
final double? elevation;
|
||||
|
||||
/// Margin around the snackbar
|
||||
final EdgeInsets? margin;
|
||||
|
||||
/// Shape override
|
||||
final ShapeBorder? shape;
|
||||
|
||||
/// Snackbar behavior
|
||||
final SnackBarBehavior behavior;
|
||||
|
||||
/// Fixed width override
|
||||
final double? width;
|
||||
|
||||
/// Content padding
|
||||
final EdgeInsets? padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final snackBarTheme = theme.snackBarTheme;
|
||||
|
||||
Widget content = Container(
|
||||
width: width,
|
||||
padding: padding ?? const EdgeInsets.all(AppSpacing.md),
|
||||
child: child,
|
||||
);
|
||||
|
||||
return SnackBar(
|
||||
content: content,
|
||||
backgroundColor: backgroundColor ?? snackBarTheme.backgroundColor,
|
||||
elevation: elevation ?? snackBarTheme.elevation,
|
||||
margin: margin ?? const EdgeInsets.all(AppSpacing.md),
|
||||
shape: shape ?? snackBarTheme.shape,
|
||||
behavior: behavior,
|
||||
padding: EdgeInsets.zero, // Remove default padding since we handle it
|
||||
);
|
||||
}
|
||||
|
||||
/// Show this custom snackbar
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> show({
|
||||
required BuildContext context,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
}) {
|
||||
return ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: child,
|
||||
backgroundColor: backgroundColor,
|
||||
elevation: elevation,
|
||||
margin: margin ?? const EdgeInsets.all(AppSpacing.md),
|
||||
shape: shape,
|
||||
behavior: behavior,
|
||||
duration: duration,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snackbar with rich content (title, subtitle, actions)
|
||||
class AppRichSnackbar extends StatelessWidget {
|
||||
/// Creates a rich snackbar with title, subtitle, and actions
|
||||
const AppRichSnackbar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.icon,
|
||||
this.actions,
|
||||
this.backgroundColor,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// Main title text
|
||||
final String title;
|
||||
|
||||
/// Optional subtitle text
|
||||
final String? subtitle;
|
||||
|
||||
/// Optional leading icon
|
||||
final IconData? icon;
|
||||
|
||||
/// Optional action widgets
|
||||
final List<Widget>? actions;
|
||||
|
||||
/// Background color override
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Tap callback for the entire snackbar
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
Widget content = Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: AppSpacing.iconMD,
|
||||
color: backgroundColor != null
|
||||
? _getOnColor(backgroundColor!)
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
AppSpacing.horizontalSpaceMD,
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.titleSmall.copyWith(
|
||||
color: backgroundColor != null
|
||||
? _getOnColor(backgroundColor!)
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
AppSpacing.verticalSpaceXS,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: AppTypography.bodySmall.copyWith(
|
||||
color: backgroundColor != null
|
||||
? _getOnColor(backgroundColor!).withOpacity(0.8)
|
||||
: colorScheme.onSurfaceVariant.withOpacity(0.8),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (actions != null && actions!.isNotEmpty) ...[
|
||||
AppSpacing.horizontalSpaceSM,
|
||||
Row(children: actions!),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
content = InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppSpacing.radiusSM,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
return AppCustomSnackbar(
|
||||
backgroundColor: backgroundColor,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show this rich snackbar
|
||||
ScaffoldFeatureController<SnackBar, SnackBarClosedReason> show({
|
||||
required BuildContext context,
|
||||
Duration duration = const Duration(seconds: 5),
|
||||
}) {
|
||||
return ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: this,
|
||||
duration: duration,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(AppSpacing.md),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get appropriate "on" color for given background color
|
||||
static Color _getOnColor(Color backgroundColor) {
|
||||
return backgroundColor.computeLuminance() > 0.5
|
||||
? Colors.black87
|
||||
: Colors.white;
|
||||
}
|
||||
}
|
||||
463
lib/core/widgets/app_text_field.dart
Normal file
463
lib/core/widgets/app_text_field.dart
Normal file
@@ -0,0 +1,463 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../theme/app_spacing.dart';
|
||||
import '../theme/app_typography.dart';
|
||||
|
||||
/// Text input field with validation, error handling, and Material 3 styling
|
||||
///
|
||||
/// Provides comprehensive form input functionality with built-in validation,
|
||||
/// accessibility support, and consistent theming.
|
||||
class AppTextField extends StatefulWidget {
|
||||
/// Creates a text input field with the specified configuration
|
||||
const AppTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.initialValue,
|
||||
this.labelText,
|
||||
this.hintText,
|
||||
this.helperText,
|
||||
this.errorText,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.onTap,
|
||||
this.validator,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.obscureText = false,
|
||||
this.autocorrect = true,
|
||||
this.enableSuggestions = true,
|
||||
this.maxLines = 1,
|
||||
this.minLines,
|
||||
this.maxLength,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.inputFormatters,
|
||||
this.focusNode,
|
||||
this.textCapitalization = TextCapitalization.none,
|
||||
this.textAlign = TextAlign.start,
|
||||
this.style,
|
||||
this.filled,
|
||||
this.fillColor,
|
||||
this.borderRadius,
|
||||
this.contentPadding,
|
||||
this.semanticLabel,
|
||||
});
|
||||
|
||||
/// Controls the text being edited
|
||||
final TextEditingController? controller;
|
||||
|
||||
/// Initial value for the field (if no controller provided)
|
||||
final String? initialValue;
|
||||
|
||||
/// Label text displayed above the field
|
||||
final String? labelText;
|
||||
|
||||
/// Hint text displayed when field is empty
|
||||
final String? hintText;
|
||||
|
||||
/// Helper text displayed below the field
|
||||
final String? helperText;
|
||||
|
||||
/// Error text displayed below the field (overrides helper text)
|
||||
final String? errorText;
|
||||
|
||||
/// Icon displayed at the start of the field
|
||||
final Widget? prefixIcon;
|
||||
|
||||
/// Icon displayed at the end of the field
|
||||
final Widget? suffixIcon;
|
||||
|
||||
/// Called when the field value changes
|
||||
final ValueChanged<String>? onChanged;
|
||||
|
||||
/// Called when the field is submitted
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
|
||||
/// Called when the field is tapped
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Validator function for form validation
|
||||
final FormFieldValidator<String>? validator;
|
||||
|
||||
/// Whether the field is enabled
|
||||
final bool enabled;
|
||||
|
||||
/// Whether the field is read-only
|
||||
final bool readOnly;
|
||||
|
||||
/// Whether to obscure the text (for passwords)
|
||||
final bool obscureText;
|
||||
|
||||
/// Whether to enable autocorrect
|
||||
final bool autocorrect;
|
||||
|
||||
/// Whether to enable input suggestions
|
||||
final bool enableSuggestions;
|
||||
|
||||
/// Maximum number of lines
|
||||
final int? maxLines;
|
||||
|
||||
/// Minimum number of lines
|
||||
final int? minLines;
|
||||
|
||||
/// Maximum character length
|
||||
final int? maxLength;
|
||||
|
||||
/// Keyboard type for input
|
||||
final TextInputType? keyboardType;
|
||||
|
||||
/// Text input action for the keyboard
|
||||
final TextInputAction? textInputAction;
|
||||
|
||||
/// Input formatters to apply
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
|
||||
/// Focus node for the field
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// Text capitalization behavior
|
||||
final TextCapitalization textCapitalization;
|
||||
|
||||
/// Text alignment within the field
|
||||
final TextAlign textAlign;
|
||||
|
||||
/// Text style override
|
||||
final TextStyle? style;
|
||||
|
||||
/// Whether the field should be filled
|
||||
final bool? filled;
|
||||
|
||||
/// Fill color override
|
||||
final Color? fillColor;
|
||||
|
||||
/// Border radius override
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
/// Content padding override
|
||||
final EdgeInsets? contentPadding;
|
||||
|
||||
/// Semantic label for accessibility
|
||||
final String? semanticLabel;
|
||||
|
||||
@override
|
||||
State<AppTextField> createState() => _AppTextFieldState();
|
||||
}
|
||||
|
||||
class _AppTextFieldState extends State<AppTextField> {
|
||||
late TextEditingController _controller;
|
||||
late FocusNode _focusNode;
|
||||
bool _obscureText = false;
|
||||
bool _isFocused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = widget.controller ?? TextEditingController(text: widget.initialValue);
|
||||
_focusNode = widget.focusNode ?? FocusNode();
|
||||
_obscureText = widget.obscureText;
|
||||
|
||||
_focusNode.addListener(_onFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.controller == null) {
|
||||
_controller.dispose();
|
||||
}
|
||||
if (widget.focusNode == null) {
|
||||
_focusNode.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
setState(() {
|
||||
_isFocused = _focusNode.hasFocus;
|
||||
});
|
||||
}
|
||||
|
||||
void _toggleObscureText() {
|
||||
setState(() {
|
||||
_obscureText = !_obscureText;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
// Build suffix icon with password visibility toggle if needed
|
||||
Widget? suffixIcon = widget.suffixIcon;
|
||||
if (widget.obscureText) {
|
||||
suffixIcon = IconButton(
|
||||
icon: Icon(
|
||||
_obscureText ? Icons.visibility : Icons.visibility_off,
|
||||
size: AppSpacing.iconMD,
|
||||
),
|
||||
onPressed: _toggleObscureText,
|
||||
tooltip: _obscureText ? 'Show password' : 'Hide password',
|
||||
);
|
||||
}
|
||||
|
||||
// Create input decoration
|
||||
final inputDecoration = InputDecoration(
|
||||
labelText: widget.labelText,
|
||||
hintText: widget.hintText,
|
||||
helperText: widget.helperText,
|
||||
errorText: widget.errorText,
|
||||
prefixIcon: widget.prefixIcon,
|
||||
suffixIcon: suffixIcon,
|
||||
filled: widget.filled ?? theme.inputDecorationTheme.filled,
|
||||
fillColor: widget.fillColor ??
|
||||
(widget.enabled
|
||||
? theme.inputDecorationTheme.fillColor
|
||||
: colorScheme.surface.withOpacity(0.1)),
|
||||
contentPadding: widget.contentPadding ??
|
||||
theme.inputDecorationTheme.contentPadding,
|
||||
border: _createBorder(theme, null),
|
||||
enabledBorder: _createBorder(theme, colorScheme.outline),
|
||||
focusedBorder: _createBorder(theme, colorScheme.primary),
|
||||
errorBorder: _createBorder(theme, colorScheme.error),
|
||||
focusedErrorBorder: _createBorder(theme, colorScheme.error),
|
||||
disabledBorder: _createBorder(theme, colorScheme.outline.withOpacity(0.38)),
|
||||
labelStyle: theme.inputDecorationTheme.labelStyle?.copyWith(
|
||||
color: _getLabelColor(theme, colorScheme),
|
||||
),
|
||||
hintStyle: widget.style ?? theme.inputDecorationTheme.hintStyle,
|
||||
errorStyle: theme.inputDecorationTheme.errorStyle,
|
||||
helperStyle: theme.inputDecorationTheme.helperStyle,
|
||||
counterStyle: AppTypography.bodySmall.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
|
||||
// Create the text field
|
||||
Widget textField = TextFormField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
decoration: inputDecoration,
|
||||
enabled: widget.enabled,
|
||||
readOnly: widget.readOnly,
|
||||
obscureText: _obscureText,
|
||||
autocorrect: widget.autocorrect,
|
||||
enableSuggestions: widget.enableSuggestions,
|
||||
maxLines: widget.maxLines,
|
||||
minLines: widget.minLines,
|
||||
maxLength: widget.maxLength,
|
||||
keyboardType: widget.keyboardType,
|
||||
textInputAction: widget.textInputAction,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
textCapitalization: widget.textCapitalization,
|
||||
textAlign: widget.textAlign,
|
||||
style: widget.style ?? theme.inputDecorationTheme.labelStyle,
|
||||
validator: widget.validator,
|
||||
onChanged: widget.onChanged,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
onTap: widget.onTap,
|
||||
);
|
||||
|
||||
// Add semantic label if provided
|
||||
if (widget.semanticLabel != null) {
|
||||
textField = Semantics(
|
||||
label: widget.semanticLabel,
|
||||
child: textField,
|
||||
);
|
||||
}
|
||||
|
||||
return textField;
|
||||
}
|
||||
|
||||
/// Create input border with custom styling
|
||||
InputBorder _createBorder(ThemeData theme, Color? borderColor) {
|
||||
return OutlineInputBorder(
|
||||
borderRadius: widget.borderRadius ?? AppSpacing.fieldRadius,
|
||||
borderSide: BorderSide(
|
||||
color: borderColor ?? Colors.transparent,
|
||||
width: _isFocused ? AppSpacing.borderWidthThick : AppSpacing.borderWidth,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get appropriate label color based on state
|
||||
Color _getLabelColor(ThemeData theme, ColorScheme colorScheme) {
|
||||
if (!widget.enabled) {
|
||||
return colorScheme.onSurface.withOpacity(0.38);
|
||||
}
|
||||
if (widget.errorText != null) {
|
||||
return colorScheme.error;
|
||||
}
|
||||
if (_isFocused) {
|
||||
return colorScheme.primary;
|
||||
}
|
||||
return colorScheme.onSurfaceVariant;
|
||||
}
|
||||
}
|
||||
|
||||
/// Email validation text field
|
||||
class AppEmailField extends StatelessWidget {
|
||||
const AppEmailField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.labelText = 'Email',
|
||||
this.hintText = 'Enter your email address',
|
||||
this.onChanged,
|
||||
this.validator,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
});
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String labelText;
|
||||
final String hintText;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppTextField(
|
||||
controller: controller,
|
||||
labelText: labelText,
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.email_outlined),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
onChanged: onChanged,
|
||||
validator: validator ?? _defaultEmailValidator,
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
semanticLabel: 'Email address input field',
|
||||
);
|
||||
}
|
||||
|
||||
String? _defaultEmailValidator(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Email is required';
|
||||
}
|
||||
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
|
||||
if (!emailRegex.hasMatch(value)) {
|
||||
return 'Enter a valid email address';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Password validation text field
|
||||
class AppPasswordField extends StatelessWidget {
|
||||
const AppPasswordField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.labelText = 'Password',
|
||||
this.hintText = 'Enter your password',
|
||||
this.onChanged,
|
||||
this.validator,
|
||||
this.enabled = true,
|
||||
this.readOnly = false,
|
||||
this.requireStrong = false,
|
||||
});
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String labelText;
|
||||
final String hintText;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final bool enabled;
|
||||
final bool readOnly;
|
||||
final bool requireStrong;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppTextField(
|
||||
controller: controller,
|
||||
labelText: labelText,
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
onChanged: onChanged,
|
||||
validator: validator ?? (requireStrong ? _strongPasswordValidator : _defaultPasswordValidator),
|
||||
enabled: enabled,
|
||||
readOnly: readOnly,
|
||||
semanticLabel: 'Password input field',
|
||||
);
|
||||
}
|
||||
|
||||
String? _defaultPasswordValidator(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return 'Password must be at least 6 characters';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _strongPasswordValidator(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
if (!RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)').hasMatch(value)) {
|
||||
return 'Password must contain uppercase, lowercase, and number';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Search text field with built-in search functionality
|
||||
class AppSearchField extends StatelessWidget {
|
||||
const AppSearchField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.hintText = 'Search...',
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.onClear,
|
||||
this.enabled = true,
|
||||
this.autofocus = false,
|
||||
});
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String hintText;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final VoidCallback? onClear;
|
||||
final bool enabled;
|
||||
final bool autofocus;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppTextField(
|
||||
controller: controller,
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: controller?.text.isNotEmpty == true
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
controller?.clear();
|
||||
onClear?.call();
|
||||
},
|
||||
tooltip: 'Clear search',
|
||||
)
|
||||
: null,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.search,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
enabled: enabled,
|
||||
semanticLabel: 'Search input field',
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/core/widgets/error_widget.dart
Normal file
127
lib/core/widgets/error_widget.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A customizable error display widget
|
||||
class AppErrorWidget extends StatelessWidget {
|
||||
final String message;
|
||||
final String? title;
|
||||
final VoidCallback? onRetry;
|
||||
final IconData? icon;
|
||||
|
||||
const AppErrorWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.title,
|
||||
this.onRetry,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(120, 40),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact error widget for inline use
|
||||
class InlineErrorWidget extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const InlineErrorWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.error.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: const Size(60, 32),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
child: Text(
|
||||
'Retry',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/core/widgets/loading_widget.dart
Normal file
69
lib/core/widgets/loading_widget.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A customizable loading widget
|
||||
class LoadingWidget extends StatelessWidget {
|
||||
final String? message;
|
||||
final double size;
|
||||
final Color? color;
|
||||
|
||||
const LoadingWidget({
|
||||
super.key,
|
||||
this.message,
|
||||
this.size = 24.0,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
color: color ?? Theme.of(context).colorScheme.primary,
|
||||
strokeWidth: 3.0,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small loading indicator for buttons
|
||||
class SmallLoadingIndicator extends StatelessWidget {
|
||||
final Color? color;
|
||||
final double size;
|
||||
|
||||
const SmallLoadingIndicator({
|
||||
super.key,
|
||||
this.color,
|
||||
this.size = 16.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
color: color ?? Theme.of(context).colorScheme.onPrimary,
|
||||
strokeWidth: 2.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
3
lib/core/widgets/widgets.dart
Normal file
3
lib/core/widgets/widgets.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
// Barrel export file for core widgets
|
||||
export 'error_widget.dart';
|
||||
export 'loading_widget.dart';
|
||||
269
lib/features/auth/presentation/screens/login_screen.dart
Normal file
269
lib/features/auth/presentation/screens/login_screen.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isPasswordVisible = false;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your email';
|
||||
}
|
||||
|
||||
final emailRegExp = RegExp(r'^[^@]+@[^@]+\.[^@]+');
|
||||
if (!emailRegExp.hasMatch(value)) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validatePassword(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your password';
|
||||
}
|
||||
|
||||
if (value.length < 6) {
|
||||
return 'Password must be at least 6 characters long';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// Simulate API call
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Simple demo login - accept any valid email/password
|
||||
if (_emailController.text.isNotEmpty && _passwordController.text.length >= 6) {
|
||||
// Navigate to home screen (will be implemented when routing is set up)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Login successful!'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Login failed. Please check your credentials.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// App Logo/Title
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
size: 80,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Welcome Text
|
||||
Text(
|
||||
'Welcome Back',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Text(
|
||||
'Sign in to your account',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Email Field
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: _validateEmail,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Email',
|
||||
hintText: 'Enter your email address',
|
||||
prefixIcon: const Icon(Icons.email_outlined),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Password Field
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: !_isPasswordVisible,
|
||||
textInputAction: TextInputAction.done,
|
||||
validator: _validatePassword,
|
||||
onFieldSubmitted: (_) => _handleLogin(),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText: 'Enter your password',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isPasswordVisible
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isPasswordVisible = !_isPasswordVisible;
|
||||
});
|
||||
},
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceVariant.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Forgot Password Link
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
// Handle forgot password
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Forgot password functionality not implemented yet'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'Forgot Password?',
|
||||
style: TextStyle(color: colorScheme.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Login Button
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handleLogin,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(double.infinity, 56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Sign In',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Sign up link
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Don't have an account? ",
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// Handle sign up
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Sign up functionality not implemented yet'),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'Sign Up',
|
||||
style: TextStyle(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
511
lib/features/home/presentation/pages/home_page.dart
Normal file
511
lib/features/home/presentation/pages/home_page.dart
Normal file
@@ -0,0 +1,511 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/routing/route_paths.dart';
|
||||
import '../../../../core/routing/route_guards.dart';
|
||||
import '../../../../shared/presentation/providers/app_providers.dart';
|
||||
|
||||
/// Home page with navigation to different features
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authState = ref.watch(authStateProvider);
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Base Flutter App'),
|
||||
actions: [
|
||||
// Theme toggle button
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
ref.read(themeModeProvider.notifier).toggleTheme();
|
||||
},
|
||||
icon: Icon(
|
||||
themeMode == ThemeMode.dark
|
||||
? Icons.light_mode
|
||||
: themeMode == ThemeMode.light
|
||||
? Icons.dark_mode
|
||||
: Icons.brightness_auto,
|
||||
),
|
||||
tooltip: 'Toggle theme',
|
||||
),
|
||||
// Settings button
|
||||
IconButton(
|
||||
onPressed: () => context.push(RoutePaths.settings),
|
||||
icon: const Icon(Icons.settings),
|
||||
tooltip: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Welcome section
|
||||
_WelcomeCard(authState: authState),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Quick actions section
|
||||
Text(
|
||||
'Quick Actions',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_QuickActionsGrid(),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Features section
|
||||
Text(
|
||||
'Features',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_FeaturesGrid(),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Recent activity section
|
||||
_RecentActivityCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => context.push(RoutePaths.addTodo),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Todo'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WelcomeCard extends StatelessWidget {
|
||||
final AuthState authState;
|
||||
|
||||
const _WelcomeCard({
|
||||
required this.authState,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
authState == AuthState.authenticated
|
||||
? 'Welcome back!'
|
||||
: 'Welcome!',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
authState == AuthState.authenticated
|
||||
? 'Ready to be productive today?'
|
||||
: 'Get started with the base Flutter app.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (authState == AuthState.unauthenticated) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => context.push(RoutePaths.login),
|
||||
child: const Text('Login'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => context.push(RoutePaths.register),
|
||||
child: const Text('Register'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickActionsGrid extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final quickActions = [
|
||||
_QuickAction(
|
||||
icon: Icons.add_task,
|
||||
title: 'Add Todo',
|
||||
subtitle: 'Create a new task',
|
||||
onTap: () => context.push(RoutePaths.addTodo),
|
||||
),
|
||||
_QuickAction(
|
||||
icon: Icons.list,
|
||||
title: 'View Todos',
|
||||
subtitle: 'See all tasks',
|
||||
onTap: () => context.push(RoutePaths.todos),
|
||||
),
|
||||
_QuickAction(
|
||||
icon: Icons.settings,
|
||||
title: 'Settings',
|
||||
subtitle: 'App preferences',
|
||||
onTap: () => context.push(RoutePaths.settings),
|
||||
),
|
||||
_QuickAction(
|
||||
icon: Icons.info,
|
||||
title: 'About',
|
||||
subtitle: 'App information',
|
||||
onTap: () => context.push(RoutePaths.about),
|
||||
),
|
||||
];
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemCount: quickActions.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _QuickActionCard(action: quickActions[index]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeaturesGrid extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final features = [
|
||||
_Feature(
|
||||
icon: Icons.check_circle_outline,
|
||||
title: 'Todo Management',
|
||||
description: 'Create, edit, and track your tasks efficiently.',
|
||||
color: Colors.blue,
|
||||
onTap: () => context.push(RoutePaths.todos),
|
||||
),
|
||||
_Feature(
|
||||
icon: Icons.palette,
|
||||
title: 'Theming',
|
||||
description: 'Switch between light, dark, and system themes.',
|
||||
color: Colors.purple,
|
||||
onTap: () => context.push(RoutePaths.settingsTheme),
|
||||
),
|
||||
_Feature(
|
||||
icon: Icons.storage,
|
||||
title: 'Local Storage',
|
||||
description: 'Hive database integration for offline support.',
|
||||
color: Colors.orange,
|
||||
onTap: () => context.push(RoutePaths.settings),
|
||||
),
|
||||
_Feature(
|
||||
icon: Icons.security,
|
||||
title: 'Secure Storage',
|
||||
description: 'Protected storage for sensitive data.',
|
||||
color: Colors.green,
|
||||
onTap: () => context.push(RoutePaths.settingsPrivacy),
|
||||
),
|
||||
];
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 0.8,
|
||||
),
|
||||
itemCount: features.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _FeatureCard(feature: features[index]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecentActivityCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Recent Activity',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.push(RoutePaths.todos),
|
||||
child: const Text('View All'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ActivityItem(
|
||||
icon: Icons.add_task,
|
||||
title: 'Welcome to Base Flutter App',
|
||||
subtitle: 'Your journey starts here',
|
||||
time: 'Just now',
|
||||
),
|
||||
_ActivityItem(
|
||||
icon: Icons.info,
|
||||
title: 'App initialized',
|
||||
subtitle: 'Database and services ready',
|
||||
time: 'A few seconds ago',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickActionCard extends StatelessWidget {
|
||||
final _QuickAction action;
|
||||
|
||||
const _QuickActionCard({
|
||||
required this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: action.onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
action.icon,
|
||||
size: 32,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
action.title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
action.subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeatureCard extends StatelessWidget {
|
||||
final _Feature feature;
|
||||
|
||||
const _FeatureCard({
|
||||
required this.feature,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: feature.onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: feature.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
feature.icon,
|
||||
color: feature.color,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
feature.title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
feature.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String time;
|
||||
|
||||
const _ActivityItem({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.time,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
time,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickAction {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QuickAction({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
class _Feature {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String description;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _Feature({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
426
lib/features/settings/presentation/pages/settings_page.dart
Normal file
426
lib/features/settings/presentation/pages/settings_page.dart
Normal file
@@ -0,0 +1,426 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/routing/route_paths.dart';
|
||||
import '../../../../core/routing/route_guards.dart';
|
||||
import '../../../../shared/presentation/providers/app_providers.dart';
|
||||
|
||||
/// Main settings page with theme switcher and navigation to other settings
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final authState = ref.watch(authStateProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Theme Section
|
||||
_SectionHeader(title: 'Appearance'),
|
||||
_ThemeSection(themeMode: themeMode, ref: ref),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Account Section
|
||||
_SectionHeader(title: 'Account'),
|
||||
_AccountSection(authState: authState, ref: ref),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// App Settings Section
|
||||
_SectionHeader(title: 'App Settings'),
|
||||
_AppSettingsSection(),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Privacy & Security Section
|
||||
_SectionHeader(title: 'Privacy & Security'),
|
||||
_PrivacySection(),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// About Section
|
||||
_SectionHeader(title: 'About'),
|
||||
_AboutSection(),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _SectionHeader({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThemeSection extends StatelessWidget {
|
||||
final ThemeMode themeMode;
|
||||
final WidgetRef ref;
|
||||
|
||||
const _ThemeSection({
|
||||
required this.themeMode,
|
||||
required this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
Icons.palette_outlined,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: const Text('Theme'),
|
||||
subtitle: Text(_getThemeModeText(themeMode)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push(RoutePaths.settingsTheme),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
themeMode == ThemeMode.dark
|
||||
? Icons.dark_mode
|
||||
: themeMode == ThemeMode.light
|
||||
? Icons.light_mode
|
||||
: Icons.brightness_auto,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
title: const Text('Quick Theme Toggle'),
|
||||
subtitle: const Text('Switch between light and dark mode'),
|
||||
trailing: Switch(
|
||||
value: themeMode == ThemeMode.dark,
|
||||
onChanged: (value) {
|
||||
ref.read(themeModeProvider.notifier).setThemeMode(
|
||||
value ? ThemeMode.dark : ThemeMode.light,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getThemeModeText(ThemeMode mode) {
|
||||
switch (mode) {
|
||||
case ThemeMode.light:
|
||||
return 'Light mode';
|
||||
case ThemeMode.dark:
|
||||
return 'Dark mode';
|
||||
case ThemeMode.system:
|
||||
return 'Follow system';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountSection extends StatelessWidget {
|
||||
final AuthState authState;
|
||||
final WidgetRef ref;
|
||||
|
||||
const _AccountSection({
|
||||
required this.authState,
|
||||
required this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (authState == AuthState.authenticated) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outline),
|
||||
title: const Text('Profile'),
|
||||
subtitle: const Text('Manage your profile information'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push(RoutePaths.profile),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout),
|
||||
title: const Text('Sign Out'),
|
||||
subtitle: const Text('Sign out of your account'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showSignOutDialog(context, ref),
|
||||
),
|
||||
] else ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.login),
|
||||
title: const Text('Sign In'),
|
||||
subtitle: const Text('Sign in to your account'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push(RoutePaths.login),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add_outlined),
|
||||
title: const Text('Create Account'),
|
||||
subtitle: const Text('Sign up for a new account'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push(RoutePaths.register),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _showSignOutDialog(BuildContext context, WidgetRef ref) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Sign Out'),
|
||||
content: const Text('Are you sure you want to sign out?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
ref.read(authStateProvider.notifier).logout();
|
||||
},
|
||||
child: const Text('Sign Out'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppSettingsSection extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.notifications_outlined),
|
||||
title: const Text('Notifications'),
|
||||
subtitle: const Text('Manage notification preferences'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push(RoutePaths.settingsNotifications),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language),
|
||||
title: const Text('Language'),
|
||||
subtitle: const Text('English (United States)'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Language settings coming soon!')),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.storage_outlined),
|
||||
title: const Text('Storage'),
|
||||
subtitle: const Text('Manage local data and cache'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Storage settings coming soon!')),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrivacySection extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.privacy_tip_outlined),
|
||||
title: const Text('Privacy'),
|
||||
subtitle: const Text('Privacy settings and data protection'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push(RoutePaths.settingsPrivacy),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.security),
|
||||
title: const Text('Security'),
|
||||
subtitle: const Text('App security and permissions'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Security settings coming soon!')),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AboutSection extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outlined),
|
||||
title: const Text('About'),
|
||||
subtitle: const Text('App version and information'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push(RoutePaths.about),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.help_outline),
|
||||
title: const Text('Help & Support'),
|
||||
subtitle: const Text('Get help and contact support'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Help & Support coming soon!')),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.article_outlined),
|
||||
title: const Text('Terms of Service'),
|
||||
subtitle: const Text('View terms and conditions'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Terms of Service coming soon!')),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.policy_outlined),
|
||||
title: const Text('Privacy Policy'),
|
||||
subtitle: const Text('View privacy policy'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Privacy Policy coming soon!')),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme settings page
|
||||
class ThemeSettingsPage extends ConsumerWidget {
|
||||
const ThemeSettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Theme Settings'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('Light'),
|
||||
subtitle: const Text('Use light theme'),
|
||||
value: ThemeMode.light,
|
||||
groupValue: themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
ref.read(themeModeProvider.notifier).setThemeMode(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('Dark'),
|
||||
subtitle: const Text('Use dark theme'),
|
||||
value: ThemeMode.dark,
|
||||
groupValue: themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
ref.read(themeModeProvider.notifier).setThemeMode(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile<ThemeMode>(
|
||||
title: const Text('System'),
|
||||
subtitle: const Text('Follow system theme'),
|
||||
value: ThemeMode.system,
|
||||
groupValue: themeMode,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
ref.read(themeModeProvider.notifier).setThemeMode(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Preview',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Filled Button'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Outlined Button'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'This is how your theme looks with sample content. The theme will be applied across the entire app.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
352
lib/features/todos/presentation/screens/home_screen.dart
Normal file
352
lib/features/todos/presentation/screens/home_screen.dart
Normal file
@@ -0,0 +1,352 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
bool _isLoading = false;
|
||||
List<Map<String, dynamic>> _todos = [];
|
||||
String _searchQuery = '';
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTodos();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Mock todos data - will be replaced with API call
|
||||
Future<void> _loadTodos() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// Simulate API call delay
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
// Mock data simulating JSONPlaceholder response
|
||||
final mockTodos = [
|
||||
{
|
||||
'id': 1,
|
||||
'title': 'Complete project documentation',
|
||||
'completed': false,
|
||||
'userId': 1,
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'title': 'Review code changes',
|
||||
'completed': true,
|
||||
'userId': 1,
|
||||
},
|
||||
{
|
||||
'id': 3,
|
||||
'title': 'Update Flutter dependencies',
|
||||
'completed': false,
|
||||
'userId': 1,
|
||||
},
|
||||
{
|
||||
'id': 4,
|
||||
'title': 'Write unit tests',
|
||||
'completed': false,
|
||||
'userId': 2,
|
||||
},
|
||||
{
|
||||
'id': 5,
|
||||
'title': 'Fix navigation bug',
|
||||
'completed': true,
|
||||
'userId': 2,
|
||||
},
|
||||
];
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_todos = mockTodos;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> get _filteredTodos {
|
||||
if (_searchQuery.isEmpty) {
|
||||
return _todos;
|
||||
}
|
||||
return _todos.where((todo) {
|
||||
return todo['title']
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.contains(_searchQuery.toLowerCase());
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _toggleTodoStatus(int id) {
|
||||
setState(() {
|
||||
final todoIndex = _todos.indexWhere((todo) => todo['id'] == id);
|
||||
if (todoIndex != -1) {
|
||||
_todos[todoIndex]['completed'] = !_todos[todoIndex]['completed'];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refreshTodos() async {
|
||||
await _loadTodos();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('My Todos'),
|
||||
elevation: 0,
|
||||
backgroundColor: colorScheme.surfaceVariant,
|
||||
foregroundColor: colorScheme.onSurfaceVariant,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () {
|
||||
// Handle logout - will be connected to auth logic later
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Logout functionality will be implemented'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search Bar
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
color: colorScheme.surfaceVariant,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search todos...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Todos List
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: _todos.isEmpty
|
||||
? _buildEmptyState()
|
||||
: RefreshIndicator(
|
||||
onRefresh: _refreshTodos,
|
||||
child: _filteredTodos.isEmpty
|
||||
? _buildNoResultsState()
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
itemCount: _filteredTodos.length,
|
||||
itemBuilder: (context, index) {
|
||||
final todo = _filteredTodos[index];
|
||||
return _buildTodoCard(todo);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
// Handle add new todo
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Add todo functionality will be implemented'),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Todo'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTodoCard(Map<String, dynamic> todo) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isCompleted = todo['completed'] as bool;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8.0),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
leading: Checkbox(
|
||||
value: isCompleted,
|
||||
onChanged: (_) => _toggleTodoStatus(todo['id']),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
todo['title'],
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
decoration: isCompleted ? TextDecoration.lineThrough : null,
|
||||
color: isCompleted
|
||||
? colorScheme.onSurfaceVariant
|
||||
: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'ID: ${todo['id']} • User: ${todo['userId']}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'edit':
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Edit functionality will be implemented'),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 'delete':
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Delete functionality will be implemented'),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit),
|
||||
SizedBox(width: 8),
|
||||
Text('Edit'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete),
|
||||
SizedBox(width: 8),
|
||||
Text('Delete'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.task_outlined,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No todos yet',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add your first todo to get started!',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoResultsState() {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No todos found',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Try adjusting your search terms',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
164
lib/main.dart
164
lib/main.dart
@@ -1,122 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
import 'core/constants/app_constants.dart';
|
||||
import 'core/database/hive_service.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/routing/routing.dart';
|
||||
import 'shared/presentation/providers/app_providers.dart';
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
try {
|
||||
// Initialize Hive database service
|
||||
await HiveService.initialize();
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: MyApp(),
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
// Handle initialization error
|
||||
debugPrint('❌ Failed to initialize app: $e');
|
||||
debugPrint('Stack trace: $stackTrace');
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Text('You have pushed the button this many times:'),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
// You might want to show an error screen here
|
||||
runApp(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error, color: Colors.red, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Failed to initialize app'),
|
||||
const SizedBox(height: 8),
|
||||
Text(e.toString()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
), // This trailing comma makes auto-formatting nicer for build methods.
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class MyApp extends ConsumerWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: AppConstants.appName,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: themeMode,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
22
lib/shared/domain/usecases/usecase.dart
Normal file
22
lib/shared/domain/usecases/usecase.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import '../../../core/utils/typedef.dart';
|
||||
|
||||
/// Base usecase class for implementing clean architecture use cases
|
||||
abstract class UseCase<T, Params> {
|
||||
const UseCase();
|
||||
|
||||
/// Execute the use case with given parameters
|
||||
AsyncResult<T> call(Params params);
|
||||
}
|
||||
|
||||
/// Use case that doesn't require any parameters
|
||||
abstract class UseCaseWithoutParams<T> {
|
||||
const UseCaseWithoutParams();
|
||||
|
||||
/// Execute the use case without parameters
|
||||
AsyncResult<T> call();
|
||||
}
|
||||
|
||||
/// No parameters class for use cases that don't need parameters
|
||||
class NoParams {
|
||||
const NoParams();
|
||||
}
|
||||
95
lib/shared/presentation/providers/app_providers.dart
Normal file
95
lib/shared/presentation/providers/app_providers.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../../../core/constants/storage_constants.dart';
|
||||
import '../../../core/network/dio_client.dart';
|
||||
import '../../../core/providers/network_providers.dart';
|
||||
|
||||
/// Secure storage provider
|
||||
final secureStorageProvider = Provider<FlutterSecureStorage>(
|
||||
(ref) => const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
),
|
||||
iOptions: IOSOptions(),
|
||||
),
|
||||
);
|
||||
|
||||
/// HTTP client provider
|
||||
final httpClientProvider = Provider<DioClient>(
|
||||
(ref) {
|
||||
final networkInfo = ref.watch(networkInfoProvider);
|
||||
final secureStorage = ref.watch(secureStorageProvider);
|
||||
|
||||
return DioClient(
|
||||
networkInfo: networkInfo,
|
||||
secureStorage: secureStorage,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// App settings Hive box provider
|
||||
final appSettingsBoxProvider = Provider<Box>(
|
||||
(ref) => Hive.box(StorageConstants.appSettingsBox),
|
||||
);
|
||||
|
||||
/// Cache Hive box provider
|
||||
final cacheBoxProvider = Provider<Box>(
|
||||
(ref) => Hive.box(StorageConstants.cacheBox),
|
||||
);
|
||||
|
||||
/// User data Hive box provider
|
||||
final userDataBoxProvider = Provider<Box>(
|
||||
(ref) => Hive.box(StorageConstants.userDataBox),
|
||||
);
|
||||
|
||||
/// Theme mode provider
|
||||
final themeModeProvider = StateNotifierProvider<ThemeModeNotifier, ThemeMode>(
|
||||
(ref) => ThemeModeNotifier(ref.watch(appSettingsBoxProvider)),
|
||||
);
|
||||
|
||||
/// Theme mode notifier
|
||||
class ThemeModeNotifier extends StateNotifier<ThemeMode> {
|
||||
final Box _box;
|
||||
|
||||
ThemeModeNotifier(this._box) : super(ThemeMode.system) {
|
||||
_loadThemeMode();
|
||||
}
|
||||
|
||||
void _loadThemeMode() {
|
||||
final isDarkMode = _box.get(StorageConstants.isDarkModeKey, defaultValue: null);
|
||||
if (isDarkMode == null) {
|
||||
state = ThemeMode.system;
|
||||
} else {
|
||||
state = isDarkMode ? ThemeMode.dark : ThemeMode.light;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(ThemeMode mode) async {
|
||||
state = mode;
|
||||
switch (mode) {
|
||||
case ThemeMode.system:
|
||||
await _box.delete(StorageConstants.isDarkModeKey);
|
||||
break;
|
||||
case ThemeMode.light:
|
||||
await _box.put(StorageConstants.isDarkModeKey, false);
|
||||
break;
|
||||
case ThemeMode.dark:
|
||||
await _box.put(StorageConstants.isDarkModeKey, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleTheme() async {
|
||||
switch (state) {
|
||||
case ThemeMode.system:
|
||||
case ThemeMode.light:
|
||||
await setThemeMode(ThemeMode.dark);
|
||||
break;
|
||||
case ThemeMode.dark:
|
||||
await setThemeMode(ThemeMode.light);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
345
lib/shared/presentation/providers/connectivity_providers.dart
Normal file
345
lib/shared/presentation/providers/connectivity_providers.dart
Normal file
@@ -0,0 +1,345 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
part 'connectivity_providers.g.dart';
|
||||
|
||||
/// Network connection type
|
||||
enum NetworkConnectionType {
|
||||
wifi,
|
||||
mobile,
|
||||
ethernet,
|
||||
bluetooth,
|
||||
vpn,
|
||||
other,
|
||||
none,
|
||||
}
|
||||
|
||||
/// Network status data class
|
||||
class NetworkStatus {
|
||||
final bool isConnected;
|
||||
final NetworkConnectionType connectionType;
|
||||
final DateTime lastUpdated;
|
||||
final String? errorMessage;
|
||||
|
||||
const NetworkStatus({
|
||||
required this.isConnected,
|
||||
required this.connectionType,
|
||||
required this.lastUpdated,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
NetworkStatus copyWith({
|
||||
bool? isConnected,
|
||||
NetworkConnectionType? connectionType,
|
||||
DateTime? lastUpdated,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return NetworkStatus(
|
||||
isConnected: isConnected ?? this.isConnected,
|
||||
connectionType: connectionType ?? this.connectionType,
|
||||
lastUpdated: lastUpdated ?? this.lastUpdated,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NetworkStatus{isConnected: $isConnected, connectionType: $connectionType, lastUpdated: $lastUpdated}';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is NetworkStatus &&
|
||||
other.isConnected == isConnected &&
|
||||
other.connectionType == connectionType;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return isConnected.hashCode ^ connectionType.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert ConnectivityResult to NetworkConnectionType
|
||||
NetworkConnectionType _getConnectionType(List<ConnectivityResult> results) {
|
||||
if (results.isEmpty || results.contains(ConnectivityResult.none)) {
|
||||
return NetworkConnectionType.none;
|
||||
}
|
||||
|
||||
if (results.contains(ConnectivityResult.wifi)) {
|
||||
return NetworkConnectionType.wifi;
|
||||
}
|
||||
|
||||
if (results.contains(ConnectivityResult.mobile)) {
|
||||
return NetworkConnectionType.mobile;
|
||||
}
|
||||
|
||||
if (results.contains(ConnectivityResult.ethernet)) {
|
||||
return NetworkConnectionType.ethernet;
|
||||
}
|
||||
|
||||
if (results.contains(ConnectivityResult.bluetooth)) {
|
||||
return NetworkConnectionType.bluetooth;
|
||||
}
|
||||
|
||||
if (results.contains(ConnectivityResult.vpn)) {
|
||||
return NetworkConnectionType.vpn;
|
||||
}
|
||||
|
||||
if (results.contains(ConnectivityResult.other)) {
|
||||
return NetworkConnectionType.other;
|
||||
}
|
||||
|
||||
return NetworkConnectionType.none;
|
||||
}
|
||||
|
||||
/// Connectivity instance provider
|
||||
@riverpod
|
||||
Connectivity connectivity(ConnectivityRef ref) {
|
||||
return Connectivity();
|
||||
}
|
||||
|
||||
/// Network connectivity stream provider
|
||||
@riverpod
|
||||
Stream<NetworkStatus> networkConnectivityStream(NetworkConnectivityStreamRef ref) {
|
||||
final connectivity = ref.watch(connectivityProvider);
|
||||
|
||||
return connectivity.onConnectivityChanged.map((results) {
|
||||
final connectionType = _getConnectionType(results);
|
||||
final isConnected = connectionType != NetworkConnectionType.none;
|
||||
|
||||
return NetworkStatus(
|
||||
isConnected: isConnected,
|
||||
connectionType: connectionType,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}).handleError((error) {
|
||||
debugPrint('❌ Connectivity stream error: $error');
|
||||
return NetworkStatus(
|
||||
isConnected: false,
|
||||
connectionType: NetworkConnectionType.none,
|
||||
lastUpdated: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Current network status provider
|
||||
@riverpod
|
||||
class NetworkStatusNotifier extends _$NetworkStatusNotifier {
|
||||
@override
|
||||
NetworkStatus build() {
|
||||
// Start listening to connectivity changes
|
||||
final streamValue = ref.watch(networkConnectivityStreamProvider);
|
||||
streamValue.whenData((status) {
|
||||
state = status;
|
||||
_logConnectionChange(status);
|
||||
});
|
||||
|
||||
// Get initial connectivity state
|
||||
_checkInitialConnectivity();
|
||||
|
||||
// Return initial state
|
||||
return NetworkStatus(
|
||||
isConnected: false,
|
||||
connectionType: NetworkConnectionType.none,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _checkInitialConnectivity() async {
|
||||
try {
|
||||
final connectivity = ref.read(connectivityProvider);
|
||||
final result = await connectivity.checkConnectivity();
|
||||
final connectionType = _getConnectionType(result);
|
||||
final isConnected = connectionType != NetworkConnectionType.none;
|
||||
|
||||
state = NetworkStatus(
|
||||
isConnected: isConnected,
|
||||
connectionType: connectionType,
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
|
||||
debugPrint('📡 Initial connectivity: ${isConnected ? '✅ Connected' : '❌ Disconnected'} ($connectionType)');
|
||||
} catch (error) {
|
||||
debugPrint('❌ Error checking initial connectivity: $error');
|
||||
state = NetworkStatus(
|
||||
isConnected: false,
|
||||
connectionType: NetworkConnectionType.none,
|
||||
lastUpdated: DateTime.now(),
|
||||
errorMessage: error.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _logConnectionChange(NetworkStatus status) {
|
||||
final icon = status.isConnected ? '📶' : '📵';
|
||||
final statusText = status.isConnected ? 'Connected' : 'Disconnected';
|
||||
debugPrint('$icon Network status changed: $statusText (${status.connectionType})');
|
||||
}
|
||||
|
||||
/// Force refresh network status
|
||||
Future<void> refresh() async {
|
||||
await _checkInitialConnectivity();
|
||||
}
|
||||
|
||||
/// Get connection strength (mock implementation)
|
||||
double getConnectionStrength() {
|
||||
switch (state.connectionType) {
|
||||
case NetworkConnectionType.wifi:
|
||||
return 1.0; // Assume strong Wi-Fi
|
||||
case NetworkConnectionType.ethernet:
|
||||
return 1.0; // Assume strong ethernet
|
||||
case NetworkConnectionType.mobile:
|
||||
return 0.7; // Assume moderate mobile
|
||||
case NetworkConnectionType.bluetooth:
|
||||
return 0.5; // Assume weak bluetooth
|
||||
case NetworkConnectionType.vpn:
|
||||
return 0.8; // Assume good VPN
|
||||
case NetworkConnectionType.other:
|
||||
return 0.6; // Assume moderate other
|
||||
case NetworkConnectionType.none:
|
||||
return 0.0; // No connection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple connectivity status provider
|
||||
@riverpod
|
||||
bool isConnected(IsConnectedRef ref) {
|
||||
final networkStatus = ref.watch(networkStatusNotifierProvider);
|
||||
return networkStatus.isConnected;
|
||||
}
|
||||
|
||||
/// Connection type provider
|
||||
@riverpod
|
||||
NetworkConnectionType connectionType(ConnectionTypeRef ref) {
|
||||
final networkStatus = ref.watch(networkStatusNotifierProvider);
|
||||
return networkStatus.connectionType;
|
||||
}
|
||||
|
||||
/// Is Wi-Fi connected provider
|
||||
@riverpod
|
||||
bool isWifiConnected(IsWifiConnectedRef ref) {
|
||||
final networkStatus = ref.watch(networkStatusNotifierProvider);
|
||||
return networkStatus.isConnected && networkStatus.connectionType == NetworkConnectionType.wifi;
|
||||
}
|
||||
|
||||
/// Is mobile data connected provider
|
||||
@riverpod
|
||||
bool isMobileConnected(IsMobileConnectedRef ref) {
|
||||
final networkStatus = ref.watch(networkStatusNotifierProvider);
|
||||
return networkStatus.isConnected && networkStatus.connectionType == NetworkConnectionType.mobile;
|
||||
}
|
||||
|
||||
/// Network quality indicator provider
|
||||
@riverpod
|
||||
String networkQuality(NetworkQualityRef ref) {
|
||||
final networkStatus = ref.watch(networkStatusNotifierProvider);
|
||||
|
||||
if (!networkStatus.isConnected) {
|
||||
return 'No Connection';
|
||||
}
|
||||
|
||||
switch (networkStatus.connectionType) {
|
||||
case NetworkConnectionType.wifi:
|
||||
return 'Excellent';
|
||||
case NetworkConnectionType.ethernet:
|
||||
return 'Excellent';
|
||||
case NetworkConnectionType.mobile:
|
||||
return 'Good';
|
||||
case NetworkConnectionType.vpn:
|
||||
return 'Good';
|
||||
case NetworkConnectionType.other:
|
||||
return 'Fair';
|
||||
case NetworkConnectionType.bluetooth:
|
||||
return 'Poor';
|
||||
case NetworkConnectionType.none:
|
||||
return 'No Connection';
|
||||
}
|
||||
}
|
||||
|
||||
/// Network history provider for tracking connection changes
|
||||
@riverpod
|
||||
class NetworkHistoryNotifier extends _$NetworkHistoryNotifier {
|
||||
static const int _maxHistorySize = 50;
|
||||
|
||||
@override
|
||||
List<NetworkStatus> build() {
|
||||
// Listen to network status changes and add to history
|
||||
ref.listen(networkStatusNotifierProvider, (previous, next) {
|
||||
if (previous != next) {
|
||||
_addToHistory(next);
|
||||
}
|
||||
});
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
void _addToHistory(NetworkStatus status) {
|
||||
final newHistory = [...state, status];
|
||||
|
||||
// Keep only the last _maxHistorySize entries
|
||||
if (newHistory.length > _maxHistorySize) {
|
||||
newHistory.removeRange(0, newHistory.length - _maxHistorySize);
|
||||
}
|
||||
|
||||
state = newHistory;
|
||||
}
|
||||
|
||||
/// Get recent connection changes
|
||||
List<NetworkStatus> getRecentChanges({int count = 10}) {
|
||||
return state.reversed.take(count).toList();
|
||||
}
|
||||
|
||||
/// Get connection uptime percentage
|
||||
double getUptimePercentage({Duration? period}) {
|
||||
if (state.isEmpty) return 0.0;
|
||||
|
||||
final now = DateTime.now();
|
||||
final startTime = period != null ? now.subtract(period) : state.first.lastUpdated;
|
||||
|
||||
final relevantEntries = state.where((status) => status.lastUpdated.isAfter(startTime)).toList();
|
||||
|
||||
if (relevantEntries.isEmpty) return 0.0;
|
||||
|
||||
final connectedCount = relevantEntries.where((status) => status.isConnected).length;
|
||||
return connectedCount / relevantEntries.length;
|
||||
}
|
||||
|
||||
/// Clear history
|
||||
void clearHistory() {
|
||||
state = [];
|
||||
}
|
||||
|
||||
/// Get connection statistics
|
||||
Map<String, dynamic> getConnectionStats() {
|
||||
if (state.isEmpty) {
|
||||
return {
|
||||
'totalChanges': 0,
|
||||
'uptimePercentage': 0.0,
|
||||
'mostCommonConnection': 'Unknown',
|
||||
'connectionTypes': <String, int>{},
|
||||
};
|
||||
}
|
||||
|
||||
final connectionTypeCounts = <NetworkConnectionType, int>{};
|
||||
for (final status in state) {
|
||||
connectionTypeCounts[status.connectionType] = (connectionTypeCounts[status.connectionType] ?? 0) + 1;
|
||||
}
|
||||
|
||||
final mostCommonType = connectionTypeCounts.entries.reduce((a, b) => a.value > b.value ? a : b).key;
|
||||
|
||||
return {
|
||||
'totalChanges': state.length,
|
||||
'uptimePercentage': getUptimePercentage(),
|
||||
'mostCommonConnection': mostCommonType.toString().split('.').last,
|
||||
'connectionTypes': connectionTypeCounts.map((key, value) => MapEntry(key.toString().split('.').last, value)),
|
||||
};
|
||||
}
|
||||
}
|
||||
169
lib/shared/presentation/providers/connectivity_providers.g.dart
Normal file
169
lib/shared/presentation/providers/connectivity_providers.g.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'connectivity_providers.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$connectivityHash() => r'da8080dfc40288eff97ff9cb96e9d9577714a9a0';
|
||||
|
||||
/// Connectivity instance provider
|
||||
///
|
||||
/// Copied from [connectivity].
|
||||
@ProviderFor(connectivity)
|
||||
final connectivityProvider = AutoDisposeProvider<Connectivity>.internal(
|
||||
connectivity,
|
||||
name: r'connectivityProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$connectivityHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef ConnectivityRef = AutoDisposeProviderRef<Connectivity>;
|
||||
String _$networkConnectivityStreamHash() =>
|
||||
r'0850402a3f1ed68481cfa9b8a3a371c804c358f3';
|
||||
|
||||
/// Network connectivity stream provider
|
||||
///
|
||||
/// Copied from [networkConnectivityStream].
|
||||
@ProviderFor(networkConnectivityStream)
|
||||
final networkConnectivityStreamProvider =
|
||||
AutoDisposeStreamProvider<NetworkStatus>.internal(
|
||||
networkConnectivityStream,
|
||||
name: r'networkConnectivityStreamProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$networkConnectivityStreamHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef NetworkConnectivityStreamRef
|
||||
= AutoDisposeStreamProviderRef<NetworkStatus>;
|
||||
String _$isConnectedHash() => r'89efbfc9ecb21e2ff1a7f6eea736457e35bed181';
|
||||
|
||||
/// Simple connectivity status provider
|
||||
///
|
||||
/// Copied from [isConnected].
|
||||
@ProviderFor(isConnected)
|
||||
final isConnectedProvider = AutoDisposeProvider<bool>.internal(
|
||||
isConnected,
|
||||
name: r'isConnectedProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product') ? null : _$isConnectedHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef IsConnectedRef = AutoDisposeProviderRef<bool>;
|
||||
String _$connectionTypeHash() => r'fd1d65f0ae9afe2b04b358755ed4347e27a0515f';
|
||||
|
||||
/// Connection type provider
|
||||
///
|
||||
/// Copied from [connectionType].
|
||||
@ProviderFor(connectionType)
|
||||
final connectionTypeProvider =
|
||||
AutoDisposeProvider<NetworkConnectionType>.internal(
|
||||
connectionType,
|
||||
name: r'connectionTypeProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$connectionTypeHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef ConnectionTypeRef = AutoDisposeProviderRef<NetworkConnectionType>;
|
||||
String _$isWifiConnectedHash() => r'6ab4a8f83d5073544d77620bea093f4b34d61e05';
|
||||
|
||||
/// Is Wi-Fi connected provider
|
||||
///
|
||||
/// Copied from [isWifiConnected].
|
||||
@ProviderFor(isWifiConnected)
|
||||
final isWifiConnectedProvider = AutoDisposeProvider<bool>.internal(
|
||||
isWifiConnected,
|
||||
name: r'isWifiConnectedProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$isWifiConnectedHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef IsWifiConnectedRef = AutoDisposeProviderRef<bool>;
|
||||
String _$isMobileConnectedHash() => r'1e03a490b5a59ac598fe75b45c42b353cec26129';
|
||||
|
||||
/// Is mobile data connected provider
|
||||
///
|
||||
/// Copied from [isMobileConnected].
|
||||
@ProviderFor(isMobileConnected)
|
||||
final isMobileConnectedProvider = AutoDisposeProvider<bool>.internal(
|
||||
isMobileConnected,
|
||||
name: r'isMobileConnectedProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$isMobileConnectedHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef IsMobileConnectedRef = AutoDisposeProviderRef<bool>;
|
||||
String _$networkQualityHash() => r'b72cb19e0b8537514827d11fbe2f46bba4e94ac2';
|
||||
|
||||
/// Network quality indicator provider
|
||||
///
|
||||
/// Copied from [networkQuality].
|
||||
@ProviderFor(networkQuality)
|
||||
final networkQualityProvider = AutoDisposeProvider<String>.internal(
|
||||
networkQuality,
|
||||
name: r'networkQualityProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$networkQualityHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef NetworkQualityRef = AutoDisposeProviderRef<String>;
|
||||
String _$networkStatusNotifierHash() =>
|
||||
r'adebb286dce36d8cb54504f04a67dd4c00dceade';
|
||||
|
||||
/// Current network status provider
|
||||
///
|
||||
/// Copied from [NetworkStatusNotifier].
|
||||
@ProviderFor(NetworkStatusNotifier)
|
||||
final networkStatusNotifierProvider =
|
||||
AutoDisposeNotifierProvider<NetworkStatusNotifier, NetworkStatus>.internal(
|
||||
NetworkStatusNotifier.new,
|
||||
name: r'networkStatusNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$networkStatusNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$NetworkStatusNotifier = AutoDisposeNotifier<NetworkStatus>;
|
||||
String _$networkHistoryNotifierHash() =>
|
||||
r'6498139c6e6e8472c81cb3f1789bcabfc4779943';
|
||||
|
||||
/// Network history provider for tracking connection changes
|
||||
///
|
||||
/// Copied from [NetworkHistoryNotifier].
|
||||
@ProviderFor(NetworkHistoryNotifier)
|
||||
final networkHistoryNotifierProvider = AutoDisposeNotifierProvider<
|
||||
NetworkHistoryNotifier, List<NetworkStatus>>.internal(
|
||||
NetworkHistoryNotifier.new,
|
||||
name: r'networkHistoryNotifierProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$networkHistoryNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$NetworkHistoryNotifier = AutoDisposeNotifier<List<NetworkStatus>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
||||
44
lib/shared/widgets/custom_app_bar.dart
Normal file
44
lib/shared/widgets/custom_app_bar.dart
Normal file
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const CustomAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.backgroundColor,
|
||||
this.foregroundColor,
|
||||
this.elevation = 0,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final Color? backgroundColor;
|
||||
final Color? foregroundColor;
|
||||
final double elevation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return AppBar(
|
||||
title: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
leading: leading,
|
||||
actions: actions,
|
||||
elevation: elevation,
|
||||
backgroundColor: backgroundColor ?? colorScheme.surfaceVariant,
|
||||
foregroundColor: foregroundColor ?? colorScheme.onSurfaceVariant,
|
||||
centerTitle: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
61
lib/shared/widgets/empty_state_widget.dart
Normal file
61
lib/shared/widgets/empty_state_widget.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class EmptyStateWidget extends StatelessWidget {
|
||||
const EmptyStateWidget({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.actionButton,
|
||||
this.iconSize = 64.0,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget? actionButton;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (actionButton != null) ...[
|
||||
const SizedBox(height: 32),
|
||||
actionButton!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
lib/shared/widgets/loading_widget.dart
Normal file
71
lib/shared/widgets/loading_widget.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class LoadingWidget extends StatelessWidget {
|
||||
const LoadingWidget({
|
||||
super.key,
|
||||
this.message,
|
||||
this.size = 24.0,
|
||||
});
|
||||
|
||||
final String? message;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoadingOverlay extends StatelessWidget {
|
||||
const LoadingOverlay({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.child,
|
||||
this.message,
|
||||
});
|
||||
|
||||
final bool isLoading;
|
||||
final Widget child;
|
||||
final String? message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
if (isLoading)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
child: LoadingWidget(message: message),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user