From 30ed6b39b531cca4b6b456488a52cee9900074a4 Mon Sep 17 00:00:00 2001 From: renolation Date: Fri, 26 Sep 2025 18:48:14 +0700 Subject: [PATCH] init cc --- ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Podfile | 43 + lib/core/constants/app_constants.dart | 35 + lib/core/constants/constants.dart | 3 + lib/core/constants/storage_constants.dart | 24 + lib/core/database/README.md | 369 ++++++++ .../examples/database_usage_example.dart | 412 +++++++++ lib/core/database/hive_service.dart | 180 ++++ lib/core/database/models/app_settings.dart | 212 +++++ lib/core/database/models/app_settings.g.dart | 68 ++ lib/core/database/models/cache_item.dart | 274 ++++++ lib/core/database/models/cache_item.g.dart | 59 ++ .../database/models/user_preferences.dart | 379 ++++++++ .../database/models/user_preferences.g.dart | 68 ++ .../providers/database_providers.dart | 438 ++++++++++ .../repositories/cache_repository.dart | 480 ++++++++++ .../repositories/settings_repository.dart | 249 ++++++ .../user_preferences_repository.dart | 329 +++++++ lib/core/errors/errors.dart | 3 + lib/core/errors/exceptions.dart | 79 ++ lib/core/errors/failures.dart | 59 ++ lib/core/network/README.md | 501 +++++++++++ lib/core/network/api_constants.dart | 78 ++ lib/core/network/dio_client.dart | 362 ++++++++ .../interceptors/auth_interceptor.dart | 279 ++++++ .../interceptors/error_interceptor.dart | 348 ++++++++ .../interceptors/logging_interceptor.dart | 281 ++++++ lib/core/network/models/api_response.dart | 333 +++++++ lib/core/network/network_info.dart | 233 +++++ lib/core/providers/README.md | 360 ++++++++ lib/core/providers/api_providers.dart | 34 + lib/core/providers/app_providers.dart | 348 ++++++++ lib/core/providers/app_providers.g.dart | 200 +++++ lib/core/providers/network_providers.dart | 51 ++ .../providers/provider_usage_example.dart | 381 ++++++++ lib/core/providers/providers.dart | 267 ++++++ lib/core/providers/storage_providers.dart | 373 ++++++++ lib/core/providers/storage_providers.g.dart | 151 ++++ lib/core/providers/theme_providers.dart | 231 +++++ lib/core/providers/theme_providers.g.dart | 153 ++++ lib/core/routing/app_router.dart | 413 +++++++++ lib/core/routing/error_page.dart | 271 ++++++ lib/core/routing/navigation_shell.dart | 312 +++++++ lib/core/routing/route_guards.dart | 168 ++++ lib/core/routing/route_names.dart | 37 + lib/core/routing/route_paths.dart | 70 ++ lib/core/routing/routing.dart | 12 + lib/core/services/api_service.dart | 237 +++++ lib/core/theme/README.md | 288 ++++++ lib/core/theme/app_colors.dart | 148 ++++ lib/core/theme/app_spacing.dart | 229 +++++ lib/core/theme/app_theme.dart | 509 +++++++++++ lib/core/theme/app_typography.dart | 381 ++++++++ lib/core/theme/theme.dart | 19 + lib/core/theme/theme_showcase.dart | 398 +++++++++ lib/core/theme/widgets/theme_mode_switch.dart | 593 +++++++++++++ lib/core/utils/extensions.dart | 137 +++ lib/core/utils/typedef.dart | 16 + lib/core/utils/utils.dart | 3 + lib/core/widgets/app_button.dart | 351 ++++++++ lib/core/widgets/app_card.dart | 538 ++++++++++++ lib/core/widgets/app_dialog.dart | 696 +++++++++++++++ lib/core/widgets/app_empty_state.dart | 501 +++++++++++ lib/core/widgets/app_error_widget.dart | 579 ++++++++++++ lib/core/widgets/app_loading_indicator.dart | 529 +++++++++++ lib/core/widgets/app_snackbar.dart | 621 +++++++++++++ lib/core/widgets/app_text_field.dart | 463 ++++++++++ lib/core/widgets/error_widget.dart | 127 +++ lib/core/widgets/loading_widget.dart | 69 ++ lib/core/widgets/widgets.dart | 3 + .../presentation/screens/login_screen.dart | 269 ++++++ .../home/presentation/pages/home_page.dart | 511 +++++++++++ .../presentation/pages/settings_page.dart | 426 +++++++++ .../presentation/screens/home_screen.dart | 352 ++++++++ lib/main.dart | 164 ++-- lib/shared/domain/usecases/usecase.dart | 22 + .../presentation/providers/app_providers.dart | 95 ++ .../providers/connectivity_providers.dart | 345 ++++++++ .../providers/connectivity_providers.g.dart | 169 ++++ lib/shared/widgets/custom_app_bar.dart | 44 + lib/shared/widgets/empty_state_widget.dart | 61 ++ lib/shared/widgets/loading_widget.dart | 71 ++ pubspec.lock | 823 +++++++++++++++++- pubspec.yaml | 35 + 85 files changed, 20722 insertions(+), 112 deletions(-) create mode 100644 ios/Podfile create mode 100644 lib/core/constants/app_constants.dart create mode 100644 lib/core/constants/constants.dart create mode 100644 lib/core/constants/storage_constants.dart create mode 100644 lib/core/database/README.md create mode 100644 lib/core/database/examples/database_usage_example.dart create mode 100644 lib/core/database/hive_service.dart create mode 100644 lib/core/database/models/app_settings.dart create mode 100644 lib/core/database/models/app_settings.g.dart create mode 100644 lib/core/database/models/cache_item.dart create mode 100644 lib/core/database/models/cache_item.g.dart create mode 100644 lib/core/database/models/user_preferences.dart create mode 100644 lib/core/database/models/user_preferences.g.dart create mode 100644 lib/core/database/providers/database_providers.dart create mode 100644 lib/core/database/repositories/cache_repository.dart create mode 100644 lib/core/database/repositories/settings_repository.dart create mode 100644 lib/core/database/repositories/user_preferences_repository.dart create mode 100644 lib/core/errors/errors.dart create mode 100644 lib/core/errors/exceptions.dart create mode 100644 lib/core/errors/failures.dart create mode 100644 lib/core/network/README.md create mode 100644 lib/core/network/api_constants.dart create mode 100644 lib/core/network/dio_client.dart create mode 100644 lib/core/network/interceptors/auth_interceptor.dart create mode 100644 lib/core/network/interceptors/error_interceptor.dart create mode 100644 lib/core/network/interceptors/logging_interceptor.dart create mode 100644 lib/core/network/models/api_response.dart create mode 100644 lib/core/network/network_info.dart create mode 100644 lib/core/providers/README.md create mode 100644 lib/core/providers/api_providers.dart create mode 100644 lib/core/providers/app_providers.dart create mode 100644 lib/core/providers/app_providers.g.dart create mode 100644 lib/core/providers/network_providers.dart create mode 100644 lib/core/providers/provider_usage_example.dart create mode 100644 lib/core/providers/providers.dart create mode 100644 lib/core/providers/storage_providers.dart create mode 100644 lib/core/providers/storage_providers.g.dart create mode 100644 lib/core/providers/theme_providers.dart create mode 100644 lib/core/providers/theme_providers.g.dart create mode 100644 lib/core/routing/app_router.dart create mode 100644 lib/core/routing/error_page.dart create mode 100644 lib/core/routing/navigation_shell.dart create mode 100644 lib/core/routing/route_guards.dart create mode 100644 lib/core/routing/route_names.dart create mode 100644 lib/core/routing/route_paths.dart create mode 100644 lib/core/routing/routing.dart create mode 100644 lib/core/services/api_service.dart create mode 100644 lib/core/theme/README.md create mode 100644 lib/core/theme/app_colors.dart create mode 100644 lib/core/theme/app_spacing.dart create mode 100644 lib/core/theme/app_theme.dart create mode 100644 lib/core/theme/app_typography.dart create mode 100644 lib/core/theme/theme.dart create mode 100644 lib/core/theme/theme_showcase.dart create mode 100644 lib/core/theme/widgets/theme_mode_switch.dart create mode 100644 lib/core/utils/extensions.dart create mode 100644 lib/core/utils/typedef.dart create mode 100644 lib/core/utils/utils.dart create mode 100644 lib/core/widgets/app_button.dart create mode 100644 lib/core/widgets/app_card.dart create mode 100644 lib/core/widgets/app_dialog.dart create mode 100644 lib/core/widgets/app_empty_state.dart create mode 100644 lib/core/widgets/app_error_widget.dart create mode 100644 lib/core/widgets/app_loading_indicator.dart create mode 100644 lib/core/widgets/app_snackbar.dart create mode 100644 lib/core/widgets/app_text_field.dart create mode 100644 lib/core/widgets/error_widget.dart create mode 100644 lib/core/widgets/loading_widget.dart create mode 100644 lib/core/widgets/widgets.dart create mode 100644 lib/features/auth/presentation/screens/login_screen.dart create mode 100644 lib/features/home/presentation/pages/home_page.dart create mode 100644 lib/features/settings/presentation/pages/settings_page.dart create mode 100644 lib/features/todos/presentation/screens/home_screen.dart create mode 100644 lib/shared/domain/usecases/usecase.dart create mode 100644 lib/shared/presentation/providers/app_providers.dart create mode 100644 lib/shared/presentation/providers/connectivity_providers.dart create mode 100644 lib/shared/presentation/providers/connectivity_providers.g.dart create mode 100644 lib/shared/widgets/custom_app_bar.dart create mode 100644 lib/shared/widgets/empty_state_widget.dart create mode 100644 lib/shared/widgets/loading_widget.dart diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..21ff445 --- /dev/null +++ b/lib/core/constants/app_constants.dart @@ -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'; +} \ No newline at end of file diff --git a/lib/core/constants/constants.dart b/lib/core/constants/constants.dart new file mode 100644 index 0000000..dde8da9 --- /dev/null +++ b/lib/core/constants/constants.dart @@ -0,0 +1,3 @@ +// Barrel export file for constants +export 'app_constants.dart'; +export 'storage_constants.dart'; \ No newline at end of file diff --git a/lib/core/constants/storage_constants.dart b/lib/core/constants/storage_constants.dart new file mode 100644 index 0000000..ddf894e --- /dev/null +++ b/lib/core/constants/storage_constants.dart @@ -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'; +} \ No newline at end of file diff --git a/lib/core/database/README.md b/lib/core/database/README.md new file mode 100644 index 0000000..2db65b1 --- /dev/null +++ b/lib/core/database/README.md @@ -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 getUserData(String userId) async { + // Try cache first + final cachedData = _cache.get('user_$userId'); + if (cachedData != null) { + return cachedData; + } + + // Fetch from API + final userData = await api.fetchUserData(userId); + + // Cache with 1 hour expiration + await _cache.put( + 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('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 updateUserData(UserData data) async { + // Update API + await api.updateUser(data); + + // Update cache + await cacheRepository.put( + key: 'user_${data.id}', + data: data, + expirationDuration: const Duration(hours: 1), + ); +} +``` + +### Cache-Aside Pattern +```dart +Future getUserData(String userId) async { + // Check cache first + var userData = cacheRepository.get('user_$userId'); + + if (userData == null) { + // Cache miss - fetch from API + userData = await api.fetchUser(userId); + + // Store in cache + await cacheRepository.put( + 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 cacheMultipleItems(Map items) async { + final futures = items.entries.map((entry) => + cacheRepository.put( + 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. \ No newline at end of file diff --git a/lib/core/database/examples/database_usage_example.dart b/lib/core/database/examples/database_usage_example.dart new file mode 100644 index 0000000..f104cf6 --- /dev/null +++ b/lib/core/database/examples/database_usage_example.dart @@ -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( + 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( + 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( + 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('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('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).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).entries.map( + (entry) => Text(' ${entry.key}: ${entry.value}'), + )), + ], + ], + ), + ), + ); + } +} + +/// Helper functions for demonstrating cache operations +class CacheExamples { + static Future demonstrateCaching(WidgetRef ref) async { + final cacheRepository = ref.read(cacheRepositoryProvider); + + // Store different types of data + await cacheRepository.put( + key: 'user_token', + data: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + expirationDuration: const Duration(hours: 1), + metadata: {'type': 'auth_token'}, + ); + + await cacheRepository.put>( + 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>( + key: 'recent_searches', + data: ['flutter', 'dart', 'hive'], + expirationDuration: const Duration(days: 7), + metadata: {'type': 'user_activity'}, + ); + + // Retrieve data + final token = cacheRepository.get('user_token'); + final profile = cacheRepository.get>('user_profile'); + final searches = cacheRepository.get>('recent_searches'); + + print('Token: $token'); + print('Profile: $profile'); + print('Searches: $searches'); + } + + static Future 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('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'); + } +} \ No newline at end of file diff --git a/lib/core/database/hive_service.dart b/lib/core/database/hive_service.dart new file mode 100644 index 0000000..a4b01ea --- /dev/null +++ b/lib/core/database/hive_service.dart @@ -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? _appSettingsBox; + static Box? _cacheBox; + static Box? _userDataBox; + + /// Initialize Hive database + static Future 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 _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 _openBoxes() async { + try { + _appSettingsBox = await Hive.openBox(_appSettingsBoxName); + _cacheBox = await Hive.openBox(_cacheBoxName); + _userDataBox = await Hive.openBox(_userDataBoxName); + } catch (e) { + debugPrint('Error opening Hive boxes: $e'); + rethrow; + } + } + + /// Get app settings box + static Box 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 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 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 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 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 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 getStats() { + return { + 'isInitialized': isInitialized, + 'appSettingsCount': _appSettingsBox?.length ?? 0, + 'cacheItemsCount': _cacheBox?.length ?? 0, + 'userDataCount': _userDataBox?.length ?? 0, + }; + } + + /// Perform database migration if needed + static Future 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 _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'); + } +} \ No newline at end of file diff --git a/lib/core/database/models/app_settings.dart b/lib/core/database/models/app_settings.dart new file mode 100644 index 0000000..73633ba --- /dev/null +++ b/lib/core/database/models/app_settings.dart @@ -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? 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? 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 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 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(), + ); + } + + /// 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(String key) { + return customSettings?[key] as T?; + } + + /// Set custom setting value + AppSettings setCustomSetting(String key, dynamic value) { + final updatedCustomSettings = Map.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.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? a, Map? 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, + ); + } +} \ No newline at end of file diff --git a/lib/core/database/models/app_settings.g.dart b/lib/core/database/models/app_settings.g.dart new file mode 100644 index 0000000..0f7bfeb --- /dev/null +++ b/lib/core/database/models/app_settings.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_settings.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class AppSettingsAdapter extends TypeAdapter { + @override + final int typeId = 0; + + @override + AppSettings read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + 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(), + ); + } + + @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; +} diff --git a/lib/core/database/models/cache_item.dart b/lib/core/database/models/cache_item.dart new file mode 100644 index 0000000..2480c92 --- /dev/null +++ b/lib/core/database/models/cache_item.dart @@ -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? 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? 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? 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 newMetadata) { + return CacheItem( + key: key, + data: data, + createdAt: createdAt, + expiresAt: expiresAt, + dataType: dataType, + metadata: {...(metadata ?? {}), ...newMetadata}, + version: version, + ); + } + + /// Get metadata value + V? getMetadata(String key) { + return metadata?[key] as V?; + } + + /// Convert to Map for JSON serialization + Map 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 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(), + 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? a, Map? 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 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 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(', ')}}'; + } +} \ No newline at end of file diff --git a/lib/core/database/models/cache_item.g.dart b/lib/core/database/models/cache_item.g.dart new file mode 100644 index 0000000..0f7d2f9 --- /dev/null +++ b/lib/core/database/models/cache_item.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'cache_item.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class CacheItemAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + CacheItem read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + 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(), + 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; +} diff --git a/lib/core/database/models/user_preferences.dart b/lib/core/database/models/user_preferences.dart new file mode 100644 index 0000000..42ec397 --- /dev/null +++ b/lib/core/database/models/user_preferences.dart @@ -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 preferences; + + @HiveField(5) + final List favoriteItems; + + @HiveField(6) + final Map 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? preferences, + List? 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 _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? preferences, + List? favoriteItems, + Map? 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(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.from(preferences); + updatedPreferences[key] = value; + + return copyWith( + preferences: updatedPreferences, + lastUpdated: DateTime.now(), + ); + } + + /// Remove a preference + UserPreferences removePreference(String key) { + final updatedPreferences = Map.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.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.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.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 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.from(lastAccessed); + + updatedLastAccessed.removeWhere((key, value) => value.isBefore(cutoffDate)); + + return copyWith( + lastAccessed: updatedLastAccessed, + lastUpdated: DateTime.now(), + ); + } + + /// Get user statistics + Map 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 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 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.from(map['preferences'] ?? {}), + favoriteItems: List.from(map['favoriteItems'] ?? []), + lastAccessed: (map['lastAccessed'] as Map?) + ?.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 a, Map 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 a, List 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 a, Map 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, + ); + } +} \ No newline at end of file diff --git a/lib/core/database/models/user_preferences.g.dart b/lib/core/database/models/user_preferences.g.dart new file mode 100644 index 0000000..b6cdcb6 --- /dev/null +++ b/lib/core/database/models/user_preferences.g.dart @@ -0,0 +1,68 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_preferences.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class UserPreferencesAdapter extends TypeAdapter { + @override + final int typeId = 2; + + @override + UserPreferences read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + 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(), + favoriteItems: (fields[5] as List).cast(), + lastAccessed: (fields[6] as Map).cast(), + 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; +} diff --git a/lib/core/database/providers/database_providers.dart b/lib/core/database/providers/database_providers.dart new file mode 100644 index 0000000..1936ef0 --- /dev/null +++ b/lib/core/database/providers/database_providers.dart @@ -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((ref) { + return SettingsRepository(); +}); + +/// Cache repository provider +final cacheRepositoryProvider = Provider((ref) { + return CacheRepository(); +}); + +/// User preferences repository provider +final userPreferencesRepositoryProvider = Provider((ref) { + return UserPreferencesRepository(); +}); + +/// Current app settings provider +final appSettingsProvider = StateNotifierProvider((ref) { + final repository = ref.watch(settingsRepositoryProvider); + return AppSettingsNotifier(repository); +}); + +/// App settings notifier +class AppSettingsNotifier extends StateNotifier { + 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 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 updateLocale(String locale) async { + try { + await _repository.updateLocale(locale); + state = state.copyWith(locale: locale); + } catch (e) { + rethrow; + } + } + + /// Update notifications enabled + Future updateNotificationsEnabled(bool enabled) async { + try { + await _repository.updateNotificationsEnabled(enabled); + state = state.copyWith(notificationsEnabled: enabled); + } catch (e) { + rethrow; + } + } + + /// Update analytics enabled + Future updateAnalyticsEnabled(bool enabled) async { + try { + await _repository.updateAnalyticsEnabled(enabled); + state = state.copyWith(analyticsEnabled: enabled); + } catch (e) { + rethrow; + } + } + + /// Update cache strategy + Future updateCacheStrategy(String strategy) async { + try { + await _repository.updateCacheStrategy(strategy); + state = state.copyWith(cacheStrategy: strategy); + } catch (e) { + rethrow; + } + } + + /// Update cache expiration hours + Future updateCacheExpirationHours(int hours) async { + try { + await _repository.updateCacheExpirationHours(hours); + state = state.copyWith(cacheExpirationHours: hours); + } catch (e) { + rethrow; + } + } + + /// Update auto update enabled + Future updateAutoUpdateEnabled(bool enabled) async { + try { + await _repository.updateAutoUpdateEnabled(enabled); + state = state.copyWith(autoUpdateEnabled: enabled); + } catch (e) { + rethrow; + } + } + + /// Set custom setting + Future setCustomSetting(String key, dynamic value) async { + try { + await _repository.setCustomSetting(key, value); + state = state.setCustomSetting(key, value); + } catch (e) { + rethrow; + } + } + + /// Remove custom setting + Future removeCustomSetting(String key) async { + try { + await _repository.removeCustomSetting(key); + state = state.removeCustomSetting(key); + } catch (e) { + rethrow; + } + } + + /// Reset to default settings + Future resetToDefault() async { + try { + await _repository.resetToDefault(); + state = AppSettings.defaultSettings(); + } catch (e) { + rethrow; + } + } +} + +/// Theme mode provider (derived from app settings) +final themeModeProvider = Provider((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((ref) { + final settings = ref.watch(appSettingsProvider); + return settings.locale; +}); + +/// Notifications enabled provider +final notificationsEnabledProvider = Provider((ref) { + final settings = ref.watch(appSettingsProvider); + return settings.notificationsEnabled; +}); + +/// Analytics enabled provider +final analyticsEnabledProvider = Provider((ref) { + final settings = ref.watch(appSettingsProvider); + return settings.analyticsEnabled; +}); + +/// Cache strategy provider +final cacheStrategyProvider = Provider((ref) { + final settings = ref.watch(appSettingsProvider); + return settings.cacheStrategy; +}); + +/// Cache expiration hours provider +final cacheExpirationHoursProvider = Provider((ref) { + final settings = ref.watch(appSettingsProvider); + return settings.cacheExpirationHours; +}); + +/// Auto update enabled provider +final autoUpdateEnabledProvider = Provider((ref) { + final settings = ref.watch(appSettingsProvider); + return settings.autoUpdateEnabled; +}); + +/// Cache statistics provider +final cacheStatsProvider = FutureProvider((ref) async { + final repository = ref.watch(cacheRepositoryProvider); + return repository.getStats(); +}); + +/// Cache maintenance provider +final cacheMaintenanceProvider = FutureProvider.family, 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((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((ref, key) { + final repository = ref.watch(cacheRepositoryProvider); + return repository.get(key); +}); + +/// Database statistics provider +final databaseStatsProvider = Provider>((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((ref) async { + final repository = ref.watch(cacheRepositoryProvider); + return repository.clearExpired(); +}); + +/// Provider to get cache keys by pattern +final cacheKeysByPatternProvider = Provider.family, String>((ref, pattern) { + final repository = ref.watch(cacheRepositoryProvider); + return repository.getKeysByPattern(pattern); +}); + +/// Provider to get cache keys by type +final cacheKeysByTypeProvider = Provider.family, String>((ref, type) { + final repository = ref.watch(cacheRepositoryProvider); + return repository.getKeysByType(type); +}); + +/// Current user preferences provider +final userPreferencesProvider = StateNotifierProvider.family((ref, userId) { + final repository = ref.watch(userPreferencesRepositoryProvider); + return UserPreferencesNotifier(repository, userId); +}); + +/// User preferences notifier +class UserPreferencesNotifier extends StateNotifier { + 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 createUserPreferences({ + required String userId, + required String displayName, + String? email, + String? avatarUrl, + Map? preferences, + List? 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 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 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 removePreference(String key) async { + if (state == null) return; + + try { + await _repository.removePreference(key, _userId); + state = state!.removePreference(key); + } catch (e) { + rethrow; + } + } + + /// Add favorite + Future addFavorite(String itemId) async { + if (state == null) return; + + try { + await _repository.addFavorite(itemId, _userId); + state = state!.addFavorite(itemId); + } catch (e) { + rethrow; + } + } + + /// Remove favorite + Future 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 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 clearPreferences() async { + try { + await _repository.clearUserPreferences(_userId); + state = null; + } catch (e) { + rethrow; + } + } +} + +/// User preference provider for specific key +final userPreferenceProvider = Provider.family.autoDispose((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, String?>((ref, userId) { + final repository = ref.watch(userPreferencesRepositoryProvider); + return repository.getFavorites(userId); +}); + +/// Recently accessed provider +final recentlyAccessedProvider = Provider.family, (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((ref, params) { + final (itemId, userId) = params; + final repository = ref.watch(userPreferencesRepositoryProvider); + return repository.isFavorite(itemId, userId); +}); + +/// User stats provider +final userStatsProvider = Provider.family, String?>((ref, userId) { + final repository = ref.watch(userPreferencesRepositoryProvider); + return repository.getUserStats(userId); +}); \ No newline at end of file diff --git a/lib/core/database/repositories/cache_repository.dart b/lib/core/database/repositories/cache_repository.dart new file mode 100644 index 0000000..bac509b --- /dev/null +++ b/lib/core/database/repositories/cache_repository.dart @@ -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 put({ + required String key, + required T data, + required Duration expirationDuration, + Map? 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 putPermanent({ + required String key, + required T data, + Map? 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(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 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 deleteMultiple(List 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 cleanExpiredItems() async { + return await clearExpired(); + } + + /// Clear all expired items + Future clearExpired() async { + try { + final box = HiveService.cacheBox; + final expiredKeys = []; + 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 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 clearByPattern(Pattern pattern) async { + try { + final box = HiveService.cacheBox; + final keysToDelete = []; + + 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 clearByType(String dataType) async { + try { + final box = HiveService.cacheBox; + final keysToDelete = []; + + 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 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 update(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 getAllKeys() { + try { + final box = HiveService.cacheBox; + return box.keys.cast().toList(); + } catch (e) { + debugPrint('❌ Error getting all keys: $e'); + return []; + } + } + + /// Get keys by pattern + List getKeysByPattern(Pattern pattern) { + try { + final box = HiveService.cacheBox; + return box.keys + .cast() + .where((key) => key.contains(pattern)) + .toList(); + } catch (e) { + debugPrint('❌ Error getting keys by pattern: $e'); + return []; + } + } + + /// Get keys by data type + List getKeysByType(String dataType) { + try { + final box = HiveService.cacheBox; + final keys = []; + + 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 = {}; + + 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 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 exportCache({bool includeExpired = false}) { + try { + final box = HiveService.cacheBox; + final now = DateTime.now(); + final exportData = {}; + + 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 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> 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()}; + } + } +} \ No newline at end of file diff --git a/lib/core/database/repositories/settings_repository.dart b/lib/core/database/repositories/settings_repository.dart new file mode 100644 index 0000000..892adb4 --- /dev/null +++ b/lib/core/database/repositories/settings_repository.dart @@ -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 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 updateThemeMode(String themeMode) async { + final currentSettings = getSettings(); + final updatedSettings = currentSettings.copyWith(themeMode: themeMode); + await saveSettings(updatedSettings); + } + + /// Update locale + Future updateLocale(String locale) async { + final currentSettings = getSettings(); + final updatedSettings = currentSettings.copyWith(locale: locale); + await saveSettings(updatedSettings); + } + + /// Update notifications enabled + Future updateNotificationsEnabled(bool enabled) async { + final currentSettings = getSettings(); + final updatedSettings = currentSettings.copyWith(notificationsEnabled: enabled); + await saveSettings(updatedSettings); + } + + /// Update analytics enabled + Future updateAnalyticsEnabled(bool enabled) async { + final currentSettings = getSettings(); + final updatedSettings = currentSettings.copyWith(analyticsEnabled: enabled); + await saveSettings(updatedSettings); + } + + /// Update cache strategy + Future updateCacheStrategy(String strategy) async { + final currentSettings = getSettings(); + final updatedSettings = currentSettings.copyWith(cacheStrategy: strategy); + await saveSettings(updatedSettings); + } + + /// Update cache expiration hours + Future updateCacheExpirationHours(int hours) async { + final currentSettings = getSettings(); + final updatedSettings = currentSettings.copyWith(cacheExpirationHours: hours); + await saveSettings(updatedSettings); + } + + /// Update auto update enabled + Future updateAutoUpdateEnabled(bool enabled) async { + final currentSettings = getSettings(); + final updatedSettings = currentSettings.copyWith(autoUpdateEnabled: enabled); + await saveSettings(updatedSettings); + } + + /// Set custom setting + Future setCustomSetting(String key, dynamic value) async { + final currentSettings = getSettings(); + final updatedSettings = currentSettings.setCustomSetting(key, value); + await saveSettings(updatedSettings); + } + + /// Get custom setting + T? getCustomSetting(String key) { + final settings = getSettings(); + return settings.getCustomSetting(key); + } + + /// Remove custom setting + Future removeCustomSetting(String key) async { + final currentSettings = getSettings(); + final updatedSettings = currentSettings.removeCustomSetting(key); + await saveSettings(updatedSettings); + } + + /// Reset to default settings + Future resetToDefault() async { + final defaultSettings = AppSettings.defaultSettings(); + await saveSettings(defaultSettings); + debugPrint('✅ Settings reset to default'); + } + + /// Export settings to Map (for backup) + Map exportSettings() { + try { + final settings = getSettings(); + return settings.toMap(); + } catch (e) { + debugPrint('❌ Error exporting settings: $e'); + return {}; + } + } + + /// Import settings from Map (for restore) + Future importSettings(Map 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 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 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 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 compact() async { + try { + final box = HiveService.appSettingsBox; + await box.compact(); + debugPrint('✅ Settings storage compacted'); + } catch (e) { + debugPrint('❌ Error compacting settings: $e'); + } + } +} \ No newline at end of file diff --git a/lib/core/database/repositories/user_preferences_repository.dart b/lib/core/database/repositories/user_preferences_repository.dart new file mode 100644 index 0000000..6d6406d --- /dev/null +++ b/lib/core/database/repositories/user_preferences_repository.dart @@ -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 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 createUserPreferences({ + required String userId, + required String displayName, + String? email, + String? avatarUrl, + Map? preferences, + List? 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 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 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(String key, T defaultValue, [String? userId]) { + final preferences = getUserPreferences(userId); + if (preferences == null) return defaultValue; + + return preferences.getPreference(key, defaultValue); + } + + /// Remove a user preference + Future 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 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 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 getFavorites([String? userId]) { + final preferences = getUserPreferences(userId); + return preferences?.favoriteItems ?? []; + } + + /// Update last accessed time for an item + Future 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 getRecentlyAccessed({int limit = 10, String? userId}) { + final preferences = getUserPreferences(userId); + return preferences?.getRecentlyAccessed(limit: limit) ?? []; + } + + /// Clean old access records + Future 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 getUserStats([String? userId]) { + final preferences = getUserPreferences(userId); + return preferences?.getStats() ?? {}; + } + + /// Export user preferences to Map (for backup) + Map 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 importUserPreferences(Map 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 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 getAllUserIds() { + try { + final box = HiveService.userDataBox; + return box.keys.cast().where((key) => key != _defaultKey).toList(); + } catch (e) { + debugPrint('❌ Error getting all user IDs: $e'); + return []; + } + } + + /// Delete preferences for a specific user + Future 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 getMultipleUserPreferences(List userIds) { + final result = {}; + + 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 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 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 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 {}; + } + } +} \ No newline at end of file diff --git a/lib/core/errors/errors.dart b/lib/core/errors/errors.dart new file mode 100644 index 0000000..7bcef92 --- /dev/null +++ b/lib/core/errors/errors.dart @@ -0,0 +1,3 @@ +// Barrel export file for errors +export 'exceptions.dart'; +export 'failures.dart'; \ No newline at end of file diff --git a/lib/core/errors/exceptions.dart b/lib/core/errors/exceptions.dart new file mode 100644 index 0000000..123ad9e --- /dev/null +++ b/lib/core/errors/exceptions.dart @@ -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, + }); +} \ No newline at end of file diff --git a/lib/core/errors/failures.dart b/lib/core/errors/failures.dart new file mode 100644 index 0000000..596853d --- /dev/null +++ b/lib/core/errors/failures.dart @@ -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 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 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}); +} \ No newline at end of file diff --git a/lib/core/network/README.md b/lib/core/network/README.md new file mode 100644 index 0000000..21c7775 --- /dev/null +++ b/lib/core/network/README.md @@ -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 { + final bool success; + final String message; + final T? data; + final List? errors; + final Map? meta; + + // Factory constructors + factory ApiResponse.success({required T data}); + factory ApiResponse.error({required String message}); +} +``` + +### Usage with Services + +```dart +class UserService { + Future getUser(String id) async { + final response = await dioClient.get('/users/$id'); + + return handleApiResponse( + response, + (data) => User.fromJson(data), + ); + } + + T handleApiResponse(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 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> get(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 get isConnected => Future.value(_isConnected); +} +``` + +## Security Considerations + +### 1. Certificate Pinning + +```dart +// Enable in production +class ApiConstants { + static const bool enableCertificatePinning = true; + static const List 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. \ No newline at end of file diff --git a/lib/core/network/api_constants.dart b/lib/core/network/api_constants.dart new file mode 100644 index 0000000..ea6732b --- /dev/null +++ b/lib/core/network/api_constants.dart @@ -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 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); +} \ No newline at end of file diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart new file mode 100644 index 0000000..0807c84 --- /dev/null +++ b/lib/core/network/dio_client.dart @@ -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> get( + String path, { + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + }) async { + return await _dio.get( + path, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + ); + } + + /// POST request + Future> post( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + }) async { + return await _dio.post( + path, + data: data, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + ); + } + + /// PUT request + Future> put( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + }) async { + return await _dio.put( + path, + data: data, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + ); + } + + /// PATCH request + Future> patch( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + }) async { + return await _dio.patch( + path, + data: data, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + ); + } + + /// DELETE request + Future> delete( + String path, { + dynamic data, + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + }) async { + return await _dio.delete( + path, + data: data, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + ); + } + + /// Upload file + Future> uploadFile( + String path, + File file, { + String? field, + String? filename, + Map? data, + Map? 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( + path, + data: formData, + queryParameters: queryParameters, + options: options, + cancelToken: cancelToken, + onSendProgress: onSendProgress, + ); + } + + /// Download file + Future downloadFile( + String urlPath, + String savePath, { + Map? queryParameters, + CancelToken? cancelToken, + ProgressCallback? onReceiveProgress, + }) async { + return await _dio.download( + urlPath, + savePath, + queryParameters: queryParameters, + cancelToken: cancelToken, + onReceiveProgress: onReceiveProgress, + ); + } + + // Utility Methods + + /// Check network connectivity + Future get isConnected => _networkInfo.isConnected; + + /// Get network connection stream + Stream 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); + } +} \ No newline at end of file diff --git a/lib/core/network/interceptors/auth_interceptor.dart b/lib/core/network/interceptors/auth_interceptor.dart new file mode 100644 index 0000000..a623eaf --- /dev/null +++ b/lib/core/network/interceptors/auth_interceptor.dart @@ -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> _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 _getAccessToken() async { + try { + return await _secureStorage.read(key: _accessTokenKey); + } catch (e) { + return null; + } + } + + /// Get the stored refresh token + Future _getRefreshToken() async { + try { + return await _secureStorage.read(key: _refreshTokenKey); + } catch (e) { + return null; + } + } + + /// Check if the token is expired + Future _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 _refreshToken() async { + // If already refreshing, wait for it to complete + if (_isRefreshing) { + final completer = Completer(); + _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; + + // 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 _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 storeTokens({ + required String accessToken, + String? refreshToken, + int? expiresIn, + }) async { + await _storeTokens( + accessToken: accessToken, + refreshToken: refreshToken, + expiresIn: expiresIn, + ); + } + + Future _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 _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 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 logout() async { + await _clearTokens(); + } + + /// Get current access token (for debugging or manual API calls) + Future getCurrentAccessToken() async { + return await _getAccessToken(); + } + + /// Get current refresh token (for debugging) + Future getCurrentRefreshToken() async { + return await _getRefreshToken(); + } +} \ No newline at end of file diff --git a/lib/core/network/interceptors/error_interceptor.dart b/lib/core/network/interceptors/error_interceptor.dart new file mode 100644 index 0000000..c079cd9 --- /dev/null +++ b/lib/core/network/interceptors/error_interceptor.dart @@ -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) { + // 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; + 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 && 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 _extractValidationErrors(dynamic responseData) { + final errors = []; + + if (responseData == null) return errors; + + try { + if (responseData is Map) { + // Handle Laravel-style validation errors + if (responseData.containsKey('errors') && responseData['errors'] is Map) { + final errorsMap = responseData['errors'] as Map; + + 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) { + errors.add(ApiError( + code: error['code']?.toString() ?? 'validation_error', + message: error['message']?.toString() ?? 'Validation error', + field: error['field']?.toString(), + details: error['details'] as Map?, + )); + } 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, + ); +} \ No newline at end of file diff --git a/lib/core/network/interceptors/logging_interceptor.dart b/lib/core/network/interceptors/logging_interceptor.dart new file mode 100644 index 0000000..f1bc074 --- /dev/null +++ b/lib/core/network/interceptors/logging_interceptor.dart @@ -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 _sanitizeHeaders(Map headers) { + final sanitized = {}; + + 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; + } +} \ No newline at end of file diff --git a/lib/core/network/models/api_response.dart b/lib/core/network/models/api_response.dart new file mode 100644 index 0000000..eae232a --- /dev/null +++ b/lib/core/network/models/api_response.dart @@ -0,0 +1,333 @@ +/// Simple API response wrapper that standardizes all API responses +class ApiResponse { + final bool success; + final String message; + final T? data; + final List? errors; + final Map? 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? 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? errors, + int? statusCode, + Map? meta, + }) { + return ApiResponse( + success: false, + message: message, + errors: errors, + statusCode: statusCode, + meta: meta, + timestamp: DateTime.now().toIso8601String(), + ); + } + + /// Create from JSON + factory ApiResponse.fromJson( + Map 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?)?.cast(), + meta: json['meta'] as Map?, + statusCode: json['status_code'] as int?, + timestamp: json['timestamp'] as String?, + ); + } + + /// Convert to JSON + Map 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 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 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 { + final bool success; + final String message; + final List data; + final PaginationMeta pagination; + final List? 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 json, + T Function(dynamic) fromJsonT, + ) { + return PaginatedApiResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List?)?.map(fromJsonT).toList() ?? [], + pagination: PaginationMeta.fromJson(json['pagination'] ?? {}), + errors: (json['errors'] as List?)?.cast(), + 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? details; + + const ApiError({ + required this.code, + required this.message, + this.field, + this.details, + }); + + factory ApiError.fromJson(Map json) { + return ApiError( + code: json['code'] ?? '', + message: json['message'] ?? '', + field: json['field'] as String?, + details: json['details'] as Map?, + ); + } + + Map 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 { + const NetworkResponse(); +} + +class NetworkSuccess extends NetworkResponse { + final T data; + + const NetworkSuccess(this.data); + + @override + String toString() => 'NetworkSuccess(data: $data)'; +} + +class NetworkError extends NetworkResponse { + 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({ + required T Function(int statusCode, String message, List? 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 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? 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 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)'; +} \ No newline at end of file diff --git a/lib/core/network/network_info.dart b/lib/core/network/network_info.dart new file mode 100644 index 0000000..8d2fe27 --- /dev/null +++ b/lib/core/network/network_info.dart @@ -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 get isConnected; + Stream get connectionStream; + Future get connectionStatus; + Future hasInternetConnection(); +} + +/// Implementation of NetworkInfo using connectivity_plus package +class NetworkInfoImpl implements NetworkInfo { + final Connectivity _connectivity; + StreamSubscription>? _connectivitySubscription; + final StreamController _connectionController = StreamController.broadcast(); + + NetworkInfoImpl(this._connectivity) { + _initializeConnectivityStream(); + } + + void _initializeConnectivityStream() { + _connectivitySubscription = _connectivity.onConnectivityChanged.listen( + (List results) { + _updateConnectionStatus(results); + }, + ); + + // Check initial connectivity status + _checkInitialConnectivity(); + } + + Future _checkInitialConnectivity() async { + try { + final results = await _connectivity.checkConnectivity(); + _updateConnectionStatus(results); + } catch (e) { + _connectionController.add(false); + } + } + + void _updateConnectionStatus(List 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 results) { + return results.any((result) => + result != ConnectivityResult.none); + } + + @override + Future get isConnected async { + try { + final results = await _connectivity.checkConnectivity(); + if (!_hasConnectionFromResults(results)) { + return false; + } + return await hasInternetConnection(); + } catch (e) { + return false; + } + } + + @override + Stream get connectionStream => _connectionController.stream; + + @override + Future 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 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 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 get isConnectedToWiFi async { + final results = await _connectivity.checkConnectivity(); + return results.contains(ConnectivityResult.wifi); + } + + /// Check if connected to mobile data + Future get isConnectedToMobile async { + final results = await _connectivity.checkConnectivity(); + return results.contains(ConnectivityResult.mobile); + } + + /// Check if connected to ethernet (mainly for desktop/web) + Future 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 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 = []; + 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)'; + } +} \ No newline at end of file diff --git a/lib/core/providers/README.md b/lib/core/providers/README.md new file mode 100644 index 0000000..f9a249c --- /dev/null +++ b/lib/core/providers/README.md @@ -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('user_preference'); +``` + +## 🚨 Error Handling + +### Provider-Level Error Handling +```dart +@riverpod +class DataNotifier extends _$DataNotifier { + @override + Future 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. \ No newline at end of file diff --git a/lib/core/providers/api_providers.dart b/lib/core/providers/api_providers.dart new file mode 100644 index 0000000..62162ee --- /dev/null +++ b/lib/core/providers/api_providers.dart @@ -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((ref) { + final dioClient = ref.watch(dioClientProvider); + return ExampleApiService(dioClient); +}); + +/// Provider for AuthApiService +final authApiServiceProvider = Provider((ref) { + final dioClient = ref.watch(dioClientProvider); + return AuthApiService(dioClient); +}); + +/// Provider to check authentication status +final isAuthenticatedProvider = FutureProvider((ref) async { + final authService = ref.watch(authApiServiceProvider); + return await authService.isAuthenticated(); +}); + +/// Example provider for user profile data +final userProfileProvider = FutureProvider.family, 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>, ({int page, int limit})>((ref, params) async { + final apiService = ref.watch(exampleApiServiceProvider); + return await apiService.getPosts(page: params.page, limit: params.limit); +}); \ No newline at end of file diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart new file mode 100644 index 0000000..fd5c10e --- /dev/null +++ b/lib/core/providers/app_providers.dart @@ -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 build() async { + return _initializeApp(); + } + + Future _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 _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 retry() async { + state = const AsyncValue.loading(); + state = AsyncValue.data(await _initializeApp()); + } + + /// Force re-initialization + Future 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 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.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 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 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(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 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> build() { + return []; + } + + /// Log error + void logError(dynamic error, StackTrace? stackTrace, {Map? 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> getRecentErrors({int count = 10}) { + return state.reversed.take(count).toList(); + } +} \ No newline at end of file diff --git a/lib/core/providers/app_providers.g.dart b/lib/core/providers/app_providers.g.dart new file mode 100644 index 0000000..b45dd98 --- /dev/null +++ b/lib/core/providers/app_providers.g.dart @@ -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.internal( + cacheRepository, + name: r'cacheRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$cacheRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef CacheRepositoryRef = AutoDisposeProviderRef; +String _$userPreferencesRepositoryHash() => + r'0244be191fd7576cbfc90468fe491306ed06d537'; + +/// See also [userPreferencesRepository]. +@ProviderFor(userPreferencesRepository) +final userPreferencesRepositoryProvider = + AutoDisposeProvider.internal( + userPreferencesRepository, + name: r'userPreferencesRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userPreferencesRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef UserPreferencesRepositoryRef + = AutoDisposeProviderRef; +String _$appVersionHash() => r'2605d9c0fd6d6a24e56caceadbe25b8370fedc4f'; + +/// App version provider +/// +/// Copied from [appVersion]. +@ProviderFor(appVersion) +final appVersionProvider = AutoDisposeProvider.internal( + appVersion, + name: r'appVersionProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$appVersionHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef AppVersionRef = AutoDisposeProviderRef; +String _$appBuildModeHash() => r'fa100842dc5c894edb352036f8d887d97618f696'; + +/// App build mode provider +/// +/// Copied from [appBuildMode]. +@ProviderFor(appBuildMode) +final appBuildModeProvider = AutoDisposeProvider.internal( + appBuildMode, + name: r'appBuildModeProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$appBuildModeHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef AppBuildModeRef = AutoDisposeProviderRef; +String _$isAppReadyHash() => r'b23a0450aa7bb2c9e3ea07630429118f239e610a'; + +/// App ready state provider +/// +/// Copied from [isAppReady]. +@ProviderFor(isAppReady) +final isAppReadyProvider = AutoDisposeProvider.internal( + isAppReady, + name: r'isAppReadyProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$isAppReadyHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef IsAppReadyRef = AutoDisposeProviderRef; +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; +String _$globalAppStateHash() => r'fd0daa69a2a1dc4aaa3af95a1b148ba1e6de0e3f'; + +/// Global app state notifier +/// +/// Copied from [GlobalAppState]. +@ProviderFor(GlobalAppState) +final globalAppStateProvider = + AutoDisposeNotifierProvider>.internal( + GlobalAppState.new, + name: r'globalAppStateProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$globalAppStateHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$GlobalAppState = AutoDisposeNotifier>; +String _$featureFlagsHash() => r'747e9d64c73eed5b374f37a8f28eb4b7fc94e53d'; + +/// Feature flags provider +/// +/// Copied from [FeatureFlags]. +@ProviderFor(FeatureFlags) +final featureFlagsProvider = + AutoDisposeNotifierProvider>.internal( + FeatureFlags.new, + name: r'featureFlagsProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$featureFlagsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$FeatureFlags = AutoDisposeNotifier>; +String _$appConfigurationHash() => r'115fff1ac67a37ff620bbd15ea142a7211e9dc9c'; + +/// App configuration provider +/// +/// Copied from [AppConfiguration]. +@ProviderFor(AppConfiguration) +final appConfigurationProvider = AutoDisposeNotifierProvider>.internal( + AppConfiguration.new, + name: r'appConfigurationProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$appConfigurationHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AppConfiguration = AutoDisposeNotifier>; +String _$appLifecycleNotifierHash() => + r'344a33715910c38bccc596ac0b543e59cb5752a0'; + +/// App lifecycle state provider +/// +/// Copied from [AppLifecycleNotifier]. +@ProviderFor(AppLifecycleNotifier) +final appLifecycleNotifierProvider = + AutoDisposeNotifierProvider.internal( + AppLifecycleNotifier.new, + name: r'appLifecycleNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$appLifecycleNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AppLifecycleNotifier = AutoDisposeNotifier; +String _$errorTrackerHash() => r'c286897f0ac33b2b619be30d3fd8d18331635b88'; + +/// Error tracking provider +/// +/// Copied from [ErrorTracker]. +@ProviderFor(ErrorTracker) +final errorTrackerProvider = AutoDisposeNotifierProvider>>.internal( + ErrorTracker.new, + name: r'errorTrackerProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$errorTrackerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ErrorTracker = AutoDisposeNotifier>>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/core/providers/network_providers.dart b/lib/core/providers/network_providers.dart new file mode 100644 index 0000000..d578955 --- /dev/null +++ b/lib/core/providers/network_providers.dart @@ -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((ref) { + return Connectivity(); +}); + +/// Provider for FlutterSecureStorage instance +final secureStorageProvider = Provider((ref) { + return const FlutterSecureStorage(); +}); + +/// Provider for NetworkInfo implementation +final networkInfoProvider = Provider((ref) { + final connectivity = ref.watch(connectivityProvider); + return NetworkInfoImpl(connectivity); +}); + +/// Provider for DioClient - the main HTTP client +final dioClientProvider = Provider((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((ref) { + final networkInfo = ref.watch(networkInfoProvider); + return networkInfo.connectionStream; +}); + +/// Provider for current network status +final isConnectedProvider = FutureProvider((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(); +}); \ No newline at end of file diff --git a/lib/core/providers/provider_usage_example.dart b/lib/core/providers/provider_usage_example.dart new file mode 100644 index 0000000..86dd2f9 --- /dev/null +++ b/lib/core/providers/provider_usage_example.dart @@ -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? ?? {}; + final secureStorageInfo = storageManager['secureStorage'] as Map? ?? {}; + final healthInfo = storageManager['health'] as Map? ?? {}; + + 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 createState() => _ProviderLifecycleExampleState(); +} + +class _ProviderLifecycleExampleState extends ConsumerState + 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(); + } +} \ No newline at end of file diff --git a/lib/core/providers/providers.dart b/lib/core/providers/providers.dart new file mode 100644 index 0000000..e4ee0e1 --- /dev/null +++ b/lib/core/providers/providers.dart @@ -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 criticalProviders = [ + 'appInitializationProvider', + 'appSettingsNotifierProvider', + 'networkStatusNotifierProvider', + 'secureStorageNotifierProvider', + ]; + + /// List of providers that can be initialized lazily when first accessed + static const List lazyProviders = [ + 'featureFlagsProvider', + 'appConfigurationProvider', + 'networkHistoryNotifierProvider', + 'errorTrackerProvider', + ]; + + /// Provider categories for better organization and management + static const Map> 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 getProvidersByCategory(String category) { + return providerCategories[category] ?? []; + } + + /// Get all provider names + static List 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 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 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 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 build() async { + return await _loadData(); + } + + Future 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 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', + ]; +} \ No newline at end of file diff --git a/lib/core/providers/storage_providers.dart b/lib/core/providers/storage_providers.dart new file mode 100644 index 0000000..28184a5 --- /dev/null +++ b/lib/core/providers/storage_providers.dart @@ -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> build() async { + _storage = ref.read(secureStorageProvider); + return await _loadAllSecureData(); + } + + Future> _loadAllSecureData() async { + try { + final allData = await _storage.readAll(); + return allData; + } catch (e) { + debugPrint('❌ Error loading secure storage data: $e'); + return {}; + } + } + + /// Store secure value + Future 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 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 delete(String key) async { + try { + await _storage.delete(key: key); + final currentData = Map.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 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 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 refresh() async { + state = const AsyncValue.loading(); + state = AsyncValue.data(await _loadAllSecureData()); + } +} + +/// Hive storage providers +@riverpod +Box appSettingsBox(AppSettingsBoxRef ref) { + return HiveService.appSettingsBox; +} + +@riverpod +Box cacheBox(CacheBoxRef ref) { + return HiveService.cacheBox; +} + +@riverpod +Box userPreferencesBox(UserPreferencesBoxRef ref) { + return HiveService.userDataBox; +} + +/// Hive storage notifier for managing Hive data +@riverpod +class HiveStorageNotifier extends _$HiveStorageNotifier { + @override + Map 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 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 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 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 build() { + return { + 'isHealthy': true, + 'lastCheck': DateTime.now().toIso8601String(), + 'errors': [], + 'warnings': [], + }; + } + + /// Perform storage health check + Future performHealthCheck() async { + final errors = []; + final warnings = []; + + 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 get errors => List.from(state['errors'] ?? []); + + /// Get warnings + List get warnings => List.from(state['warnings'] ?? []); +} + +/// Unified storage manager +@riverpod +class StorageManager extends _$StorageManager { + @override + Map 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 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 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 getStorageOverview() { + return state; + } +} \ No newline at end of file diff --git a/lib/core/providers/storage_providers.g.dart b/lib/core/providers/storage_providers.g.dart new file mode 100644 index 0000000..9f7183a --- /dev/null +++ b/lib/core/providers/storage_providers.g.dart @@ -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.internal( + secureStorage, + name: r'secureStorageProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$secureStorageHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef SecureStorageRef = AutoDisposeProviderRef; +String _$appSettingsBoxHash() => r'9e348c0084f7f23850f09adb2e6496fdbf8f2bdf'; + +/// Hive storage providers +/// +/// Copied from [appSettingsBox]. +@ProviderFor(appSettingsBox) +final appSettingsBoxProvider = AutoDisposeProvider>.internal( + appSettingsBox, + name: r'appSettingsBoxProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$appSettingsBoxHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef AppSettingsBoxRef = AutoDisposeProviderRef>; +String _$cacheBoxHash() => r'949b55a2b7423b7fa7182b8e45adf02367ab8c7c'; + +/// See also [cacheBox]. +@ProviderFor(cacheBox) +final cacheBoxProvider = AutoDisposeProvider>.internal( + cacheBox, + name: r'cacheBoxProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$cacheBoxHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef CacheBoxRef = AutoDisposeProviderRef>; +String _$userPreferencesBoxHash() => + r'38e2eab12afb00cca5ad2f48bf1f9ec76cc962c8'; + +/// See also [userPreferencesBox]. +@ProviderFor(userPreferencesBox) +final userPreferencesBoxProvider = + AutoDisposeProvider>.internal( + userPreferencesBox, + name: r'userPreferencesBoxProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userPreferencesBoxHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef UserPreferencesBoxRef = AutoDisposeProviderRef>; +String _$secureStorageNotifierHash() => + r'08d6cb392865d7483027fde37192c07cb944c45f'; + +/// Secure storage notifier for managing secure data +/// +/// Copied from [SecureStorageNotifier]. +@ProviderFor(SecureStorageNotifier) +final secureStorageNotifierProvider = AutoDisposeAsyncNotifierProvider< + SecureStorageNotifier, Map>.internal( + SecureStorageNotifier.new, + name: r'secureStorageNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$secureStorageNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SecureStorageNotifier = AutoDisposeAsyncNotifier>; +String _$hiveStorageNotifierHash() => + r'5d91bf162282fcfbef13aa7296255bb87640af51'; + +/// Hive storage notifier for managing Hive data +/// +/// Copied from [HiveStorageNotifier]. +@ProviderFor(HiveStorageNotifier) +final hiveStorageNotifierProvider = AutoDisposeNotifierProvider< + HiveStorageNotifier, Map>.internal( + HiveStorageNotifier.new, + name: r'hiveStorageNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$hiveStorageNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$HiveStorageNotifier = AutoDisposeNotifier>; +String _$storageHealthMonitorHash() => + r'1d52e331a84bd59a36055f5e8963eaa996f9c235'; + +/// Storage health monitor +/// +/// Copied from [StorageHealthMonitor]. +@ProviderFor(StorageHealthMonitor) +final storageHealthMonitorProvider = AutoDisposeNotifierProvider< + StorageHealthMonitor, Map>.internal( + StorageHealthMonitor.new, + name: r'storageHealthMonitorProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$storageHealthMonitorHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$StorageHealthMonitor = AutoDisposeNotifier>; +String _$storageManagerHash() => r'8e017d34c8c574dd2777d6478af3cd921448b080'; + +/// Unified storage manager +/// +/// Copied from [StorageManager]. +@ProviderFor(StorageManager) +final storageManagerProvider = + AutoDisposeNotifierProvider>.internal( + StorageManager.new, + name: r'storageManagerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$storageManagerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$StorageManager = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/core/providers/theme_providers.dart b/lib/core/providers/theme_providers.dart new file mode 100644 index 0000000..223bcca --- /dev/null +++ b/lib/core/providers/theme_providers.dart @@ -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 build() async { + _repository = ref.read(settingsRepositoryProvider); + return _repository.getSettings(); + } + + /// Update theme mode + Future 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 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 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 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 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 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 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 build() { + final repository = ref.read(settingsRepositoryProvider); + return repository.watchSettings(); + } +} + +/// Theme preferences provider for quick access +@riverpod +class ThemePreferences extends _$ThemePreferences { + @override + Map 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, + }, + ); + } +} \ No newline at end of file diff --git a/lib/core/providers/theme_providers.g.dart b/lib/core/providers/theme_providers.g.dart new file mode 100644 index 0000000..afb260f --- /dev/null +++ b/lib/core/providers/theme_providers.g.dart @@ -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.internal( + settingsRepository, + name: r'settingsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$settingsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef SettingsRepositoryRef = AutoDisposeProviderRef; +String _$currentThemeModeHash() => r'6cd4101e1d0f6cbd7851f117872cd49253fe0564'; + +/// Current theme mode provider +/// +/// Copied from [currentThemeMode]. +@ProviderFor(currentThemeMode) +final currentThemeModeProvider = AutoDisposeProvider.internal( + currentThemeMode, + name: r'currentThemeModeProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$currentThemeModeHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef CurrentThemeModeRef = AutoDisposeProviderRef; +String _$effectiveThemeModeHash() => + r'd747fdd8489857c595ae766ee6a9497c4ad360c0'; + +/// Effective theme mode provider (resolves system theme) +/// +/// Copied from [effectiveThemeMode]. +@ProviderFor(effectiveThemeMode) +final effectiveThemeModeProvider = AutoDisposeProvider.internal( + effectiveThemeMode, + name: r'effectiveThemeModeProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$effectiveThemeModeHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef EffectiveThemeModeRef = AutoDisposeProviderRef; +String _$isDarkModeHash() => r'e76c5818694a33e63bd0a8ba0b7494d7ee12cff5'; + +/// Is dark mode active provider +/// +/// Copied from [isDarkMode]. +@ProviderFor(isDarkMode) +final isDarkModeProvider = AutoDisposeProvider.internal( + isDarkMode, + name: r'isDarkModeProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$isDarkModeHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef IsDarkModeRef = AutoDisposeProviderRef; +String _$currentLocaleHash() => r'c3cb4000a5eefa748ca41e50818b27323e61605a'; + +/// Current locale provider +/// +/// Copied from [currentLocale]. +@ProviderFor(currentLocale) +final currentLocaleProvider = AutoDisposeProvider.internal( + currentLocale, + name: r'currentLocaleProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$currentLocaleHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef CurrentLocaleRef = AutoDisposeProviderRef; +String _$appSettingsNotifierHash() => + r'3a66de82c9b8f75bf34ffc7755b145a6d1e9c21e'; + +/// Current app settings provider +/// +/// Copied from [AppSettingsNotifier]. +@ProviderFor(AppSettingsNotifier) +final appSettingsNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + AppSettingsNotifier.new, + name: r'appSettingsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$appSettingsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AppSettingsNotifier = AutoDisposeAsyncNotifier; +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; +String _$themePreferencesHash() => r'71778e4afc614e1566d4a15131e2ab5d2302e57b'; + +/// Theme preferences provider for quick access +/// +/// Copied from [ThemePreferences]. +@ProviderFor(ThemePreferences) +final themePreferencesProvider = AutoDisposeNotifierProvider>.internal( + ThemePreferences.new, + name: r'themePreferencesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$themePreferencesHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ThemePreferences = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart new file mode 100644 index 0000000..38f3e03 --- /dev/null +++ b/lib/core/routing/app_router.dart @@ -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((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( + key: state.pageKey, + child: ErrorPage( + error: state.error.toString(), + path: state.fullPath, + ), + ), + ); +}); + +/// Helper function to build pages with transitions +Page _buildPageWithTransition({ + required Widget child, + required GoRouterState state, + Duration transitionDuration = const Duration(milliseconds: 250), +}) { + return CustomTransitionPage( + 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 pathParameters = const {}, + Map 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 get pathParameters => GoRouterState.of(this).pathParameters; + Map 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'), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/core/routing/error_page.dart b/lib/core/routing/error_page.dart new file mode 100644 index 0000000..f9141a3 --- /dev/null +++ b/lib/core/routing/error_page.dart @@ -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'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/core/routing/navigation_shell.dart b/lib/core/routing/navigation_shell.dart new file mode 100644 index 0000000..6e7bf85 --- /dev/null +++ b/lib/core/routing/navigation_shell.dart @@ -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 createState() => _NavigationShellState(); +} + +class _NavigationShellState extends ConsumerState { + @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 _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 _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; + } + } +} \ No newline at end of file diff --git a/lib/core/routing/route_guards.dart b/lib/core/routing/route_guards.dart new file mode 100644 index 0000000..3551fa9 --- /dev/null +++ b/lib/core/routing/route_guards.dart @@ -0,0 +1,168 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'route_paths.dart'; + +/// Authentication state provider +final authStateProvider = StateNotifierProvider( + (ref) => AuthStateNotifier(), +); + +/// Authentication state +enum AuthState { + unknown, + authenticated, + unauthenticated, +} + +/// Authentication state notifier +class AuthStateNotifier extends StateNotifier { + AuthStateNotifier() : super(AuthState.unknown) { + _checkInitialAuth(); + } + + Future _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 login(String email, String password) async { + // TODO: Implement actual login logic + await Future.delayed(const Duration(seconds: 1)); + state = AuthState.authenticated; + } + + Future logout() async { + // TODO: Implement actual logout logic + await Future.delayed(const Duration(milliseconds: 300)); + state = AuthState.unauthenticated; + } + + Future 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( + (ref) => OnboardingStateNotifier(), +); + +/// Onboarding state notifier +class OnboardingStateNotifier extends StateNotifier { + OnboardingStateNotifier() : super(true) { + _checkOnboardingStatus(); + } + + Future _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>( + (ref) => PermissionStateNotifier(), +); + +/// Permission state notifier +class PermissionStateNotifier extends StateNotifier> { + PermissionStateNotifier() : super({}) { + _initializePermissions(); + } + + void _initializePermissions() { + // Initialize all permissions as not granted + state = { + for (final permission in Permission.values) permission: false, + }; + } + + Future 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 permissions) { + return permissions.every((permission) => hasPermission(permission)); + } +} \ No newline at end of file diff --git a/lib/core/routing/route_names.dart b/lib/core/routing/route_names.dart new file mode 100644 index 0000000..9e03c78 --- /dev/null +++ b/lib/core/routing/route_names.dart @@ -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'; +} \ No newline at end of file diff --git a/lib/core/routing/route_paths.dart b/lib/core/routing/route_paths.dart new file mode 100644 index 0000000..04f9146 --- /dev/null +++ b/lib/core/routing/route_paths.dart @@ -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); + } +} \ No newline at end of file diff --git a/lib/core/routing/routing.dart b/lib/core/routing/routing.dart new file mode 100644 index 0000000..6d91506 --- /dev/null +++ b/lib/core/routing/routing.dart @@ -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'; \ No newline at end of file diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart new file mode 100644 index 0000000..f12a34c --- /dev/null +++ b/lib/core/services/api_service.dart @@ -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( + 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) { + 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( + Response response, + T Function(dynamic) fromJson, + ) { + try { + if (response.statusCode != null && response.statusCode! >= 200 && response.statusCode! < 300) { + if (response.data is Map) { + final apiResponse = ApiResponse.fromJson( + response.data as Map, + 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 executeRequest( + Future Function() request, + T Function(dynamic) fromJson, { + bool useWrapper = false, + }) async { + try { + final response = await request(); + + if (useWrapper) { + return handleWrappedApiResponse(response, fromJson); + } else { + return handleApiResponse(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> getUserProfile(String userId) async { + return executeRequest( + () => dioClient.get('/users/$userId'), + (data) => data as Map, + ); + } + + /// Example: Create a new post + Future> createPost(Map postData) async { + return executeRequest( + () => dioClient.post('/posts', data: postData), + (data) => data as Map, + ); + } + + /// Example: Get posts with pagination + Future>> getPosts({ + int page = 1, + int limit = 10, + }) async { + return executeRequest( + () => dioClient.get( + '/posts', + queryParameters: { + 'page': page, + 'limit': limit, + }, + ), + (data) => (data as List).cast>(), + ); + } + + /// Example: Upload file + Future> 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, + ); + } catch (e) { + throw Exception('File upload failed: $e'); + } + } + + /// Example: Download file + Future 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 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> login(String email, String password) async { + return executeRequest( + () => dioClient.post('/auth/login', data: { + 'email': email, + 'password': password, + }), + (data) => data as Map, + ); + } + + /// Logout + Future 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> register(Map userData) async { + return executeRequest( + () => dioClient.post('/auth/register', data: userData), + (data) => data as Map, + ); + } + + /// Check if user is authenticated + Future isAuthenticated() async { + return await dioClient.authInterceptor.isAuthenticated(); + } + + /// Store authentication tokens + Future storeTokens({ + required String accessToken, + String? refreshToken, + int? expiresIn, + }) async { + await dioClient.authInterceptor.storeTokens( + accessToken: accessToken, + refreshToken: refreshToken, + expiresIn: expiresIn, + ); + } +} \ No newline at end of file diff --git a/lib/core/theme/README.md b/lib/core/theme/README.md new file mode 100644 index 0000000..a9f0e0e --- /dev/null +++ b/lib/core/theme/README.md @@ -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(); + +// 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. \ No newline at end of file diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..da1e1e8 --- /dev/null +++ b/lib/core/theme/app_colors.dart @@ -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 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 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 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; + } +} \ No newline at end of file diff --git a/lib/core/theme/app_spacing.dart b/lib/core/theme/app_spacing.dart new file mode 100644 index 0000000..cffd5a7 --- /dev/null +++ b/lib/core/theme/app_spacing.dart @@ -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; + } +} \ No newline at end of file diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..0d1c03a --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -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( + (Set states) => null, + ), + ); + + static SwitchThemeData get _darkSwitchTheme => SwitchThemeData( + thumbIcon: WidgetStateProperty.resolveWith( + (Set 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); + } +} \ No newline at end of file diff --git a/lib/core/theme/app_typography.dart b/lib/core/theme/app_typography.dart new file mode 100644 index 0000000..19c2bb5 --- /dev/null +++ b/lib/core/theme/app_typography.dart @@ -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()?.success, + ); + + static TextStyle warning(BuildContext context) => bodyMedium.copyWith( + color: Theme.of(context).extension()?.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()?.info, + ); +} + +/// Theme extension for custom semantic colors +@immutable +class AppColorsExtension extends ThemeExtension { + 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? 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), + ); +} \ No newline at end of file diff --git a/lib/core/theme/theme.dart b/lib/core/theme/theme.dart new file mode 100644 index 0000000..7946982 --- /dev/null +++ b/lib/core/theme/theme.dart @@ -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'; \ No newline at end of file diff --git a/lib/core/theme/theme_showcase.dart b/lib/core/theme/theme_showcase.dart new file mode 100644 index 0000000..d4f71a4 --- /dev/null +++ b/lib/core/theme/theme_showcase.dart @@ -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( + 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, + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/core/theme/widgets/theme_mode_switch.dart b/lib/core/theme/widgets/theme_mode_switch.dart new file mode 100644 index 0000000..58afd87 --- /dev/null +++ b/lib/core/theme/widgets/theme_mode_switch.dart @@ -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? 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( + (Set 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( + segments: [ + ButtonSegment( + value: ThemeMode.light, + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.light_mode, size: _getIconSize()), + if (showLabel) ...[ + AppSpacing.horizontalSpaceXS, + Text('Light', style: _getLabelStyle()), + ], + ], + ), + ), + ButtonSegment( + value: ThemeMode.system, + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.brightness_auto, size: _getIconSize()), + if (showLabel) ...[ + AppSpacing.horizontalSpaceXS, + Text('Auto', style: _getLabelStyle()), + ], + ], + ), + ), + ButtonSegment( + 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 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( + 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( + value: themeMode, + onChanged: (ThemeMode? value) { + if (value != null) { + ref.read(_themeModeProvider.notifier).state = value; + onChanged?.call(value); + } + }, + items: ThemeMode.values.map((mode) { + return DropdownMenuItem( + 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 createState() => _AnimatedThemeModeSwitchState(); +} + +class _AnimatedThemeModeSwitchState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _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 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((ref) => ThemeMode.system); + +/// Theme mode controller for managing theme state +class ThemeModeController extends StateNotifier { + 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((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'; + } + } +} \ No newline at end of file diff --git a/lib/core/utils/extensions.dart b/lib/core/utils/extensions.dart new file mode 100644 index 0000000..88eaf65 --- /dev/null +++ b/lib/core/utils/extensions.dart @@ -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 on List { + /// 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; + } +} \ No newline at end of file diff --git a/lib/core/utils/typedef.dart b/lib/core/utils/typedef.dart new file mode 100644 index 0000000..0dc7861 --- /dev/null +++ b/lib/core/utils/typedef.dart @@ -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 = Either; + +/// Async result type +typedef AsyncResult = Future>; + +/// Data map type for JSON serialization +typedef DataMap = Map; + +/// Data list type for JSON serialization +typedef DataList = List; \ No newline at end of file diff --git a/lib/core/utils/utils.dart b/lib/core/utils/utils.dart new file mode 100644 index 0000000..d6584d5 --- /dev/null +++ b/lib/core/utils/utils.dart @@ -0,0 +1,3 @@ +// Barrel export file for utilities +export 'extensions.dart'; +export 'typedef.dart'; \ No newline at end of file diff --git a/lib/core/widgets/app_button.dart b/lib/core/widgets/app_button.dart new file mode 100644 index 0000000..0c4a785 --- /dev/null +++ b/lib/core/widgets/app_button.dart @@ -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( + 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, +} \ No newline at end of file diff --git a/lib/core/widgets/app_card.dart b/lib/core/widgets/app_card.dart new file mode 100644 index 0000000..d4e4956 --- /dev/null +++ b/lib/core/widgets/app_card.dart @@ -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? 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 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 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, + ), + ], + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/core/widgets/app_dialog.dart b/lib/core/widgets/app_dialog.dart new file mode 100644 index 0000000..0f79bdd --- /dev/null +++ b/lib/core/widgets/app_dialog.dart @@ -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? 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? 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? _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 show({ + required BuildContext context, + required AppDialog dialog, + bool barrierDismissible = true, + }) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (context) => dialog, + ); + } +} + +/// Simple confirmation dialog helper +class AppConfirmDialog { + /// Show a confirmation dialog + static Future 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( + 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 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 show({ + required BuildContext context, + required String title, + required String message, + String okText = 'OK', + IconData icon = Icons.info_outline, + bool barrierDismissible = true, + }) { + return AppDialog.show( + context: context, + barrierDismissible: barrierDismissible, + dialog: AppDialog.info( + title: title, + content: message, + icon: icon, + okText: okText, + onOk: () {}, + ), + ); + } + + /// Show a success dialog + static Future 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 showError({ + required BuildContext context, + String title = 'Error', + required String message, + String okText = 'OK', + bool barrierDismissible = true, + }) { + return AppDialog.show( + 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 show({ + required BuildContext context, + required Future future, + String message = 'Loading...', + bool showProgress = false, + bool barrierDismissible = false, + }) async { + showDialog( + 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? 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 show({ + required BuildContext context, + required AppBottomSheetDialog dialog, + bool isDismissible = true, + bool enableDrag = true, + }) { + return showModalBottomSheet( + context: context, + builder: (context) => dialog, + isScrollControlled: dialog.isScrollControlled, + isDismissible: isDismissible, + enableDrag: enableDrag, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(28), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/core/widgets/app_empty_state.dart b/lib/core/widgets/app_empty_state.dart new file mode 100644 index 0000000..2a07af2 --- /dev/null +++ b/lib/core/widgets/app_empty_state.dart @@ -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 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, +} \ No newline at end of file diff --git a/lib/core/widgets/app_error_widget.dart b/lib/core/widgets/app_error_widget.dart new file mode 100644 index 0000000..5ad84fe --- /dev/null +++ b/lib/core/widgets/app_error_widget.dart @@ -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 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 createState() => _AppErrorBoundaryState(); +} + +class _AppErrorBoundaryState extends State { + 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; + }); + } +} \ No newline at end of file diff --git a/lib/core/widgets/app_loading_indicator.dart b/lib/core/widgets/app_loading_indicator.dart new file mode 100644 index 0000000..994bb56 --- /dev/null +++ b/lib/core/widgets/app_loading_indicator.dart @@ -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 createState() => _AppSkeletonLoaderState(); +} + +class _AppSkeletonLoaderState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + _animation = Tween(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', + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/core/widgets/app_snackbar.dart b/lib/core/widgets/app_snackbar.dart new file mode 100644 index 0000000..e3387ab --- /dev/null +++ b/lib/core/widgets/app_snackbar.dart @@ -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 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 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 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 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 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 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( + 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 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 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 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 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 _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 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? 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 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; + } +} \ No newline at end of file diff --git a/lib/core/widgets/app_text_field.dart b/lib/core/widgets/app_text_field.dart new file mode 100644 index 0000000..7571848 --- /dev/null +++ b/lib/core/widgets/app_text_field.dart @@ -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? onChanged; + + /// Called when the field is submitted + final ValueChanged? onSubmitted; + + /// Called when the field is tapped + final VoidCallback? onTap; + + /// Validator function for form validation + final FormFieldValidator? 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? 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 createState() => _AppTextFieldState(); +} + +class _AppTextFieldState extends State { + 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? onChanged; + final FormFieldValidator? 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? onChanged; + final FormFieldValidator? 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? onChanged; + final ValueChanged? 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', + ); + } +} \ No newline at end of file diff --git a/lib/core/widgets/error_widget.dart b/lib/core/widgets/error_widget.dart new file mode 100644 index 0000000..43f12c5 --- /dev/null +++ b/lib/core/widgets/error_widget.dart @@ -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, + ), + ), + ), + ], + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/core/widgets/loading_widget.dart b/lib/core/widgets/loading_widget.dart new file mode 100644 index 0000000..cf7a211 --- /dev/null +++ b/lib/core/widgets/loading_widget.dart @@ -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, + ), + ); + } +} \ No newline at end of file diff --git a/lib/core/widgets/widgets.dart b/lib/core/widgets/widgets.dart new file mode 100644 index 0000000..f4da231 --- /dev/null +++ b/lib/core/widgets/widgets.dart @@ -0,0 +1,3 @@ +// Barrel export file for core widgets +export 'error_widget.dart'; +export 'loading_widget.dart'; \ No newline at end of file diff --git a/lib/features/auth/presentation/screens/login_screen.dart b/lib/features/auth/presentation/screens/login_screen.dart new file mode 100644 index 0000000..58193de --- /dev/null +++ b/lib/features/auth/presentation/screens/login_screen.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + 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 _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, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart new file mode 100644 index 0000000..3e1b3d3 --- /dev/null +++ b/lib/features/home/presentation/pages/home_page.dart @@ -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, + }); +} \ No newline at end of file diff --git a/lib/features/settings/presentation/pages/settings_page.dart b/lib/features/settings/presentation/pages/settings_page.dart new file mode 100644 index 0000000..c4c8aa1 --- /dev/null +++ b/lib/features/settings/presentation/pages/settings_page.dart @@ -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( + 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( + 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( + 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( + 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, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/todos/presentation/screens/home_screen.dart b/lib/features/todos/presentation/screens/home_screen.dart new file mode 100644 index 0000000..a5cd576 --- /dev/null +++ b/lib/features/todos/presentation/screens/home_screen.dart @@ -0,0 +1,352 @@ +import 'package:flutter/material.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + bool _isLoading = false; + List> _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 _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> 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 _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 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( + 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, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 7b7f5b6..5562a2a 100644 --- a/lib/main.dart +++ b/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 createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - 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: [ - 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, + ); + } +} \ No newline at end of file diff --git a/lib/shared/domain/usecases/usecase.dart b/lib/shared/domain/usecases/usecase.dart new file mode 100644 index 0000000..2b6ca65 --- /dev/null +++ b/lib/shared/domain/usecases/usecase.dart @@ -0,0 +1,22 @@ +import '../../../core/utils/typedef.dart'; + +/// Base usecase class for implementing clean architecture use cases +abstract class UseCase { + const UseCase(); + + /// Execute the use case with given parameters + AsyncResult call(Params params); +} + +/// Use case that doesn't require any parameters +abstract class UseCaseWithoutParams { + const UseCaseWithoutParams(); + + /// Execute the use case without parameters + AsyncResult call(); +} + +/// No parameters class for use cases that don't need parameters +class NoParams { + const NoParams(); +} \ No newline at end of file diff --git a/lib/shared/presentation/providers/app_providers.dart b/lib/shared/presentation/providers/app_providers.dart new file mode 100644 index 0000000..033d547 --- /dev/null +++ b/lib/shared/presentation/providers/app_providers.dart @@ -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( + (ref) => const FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, + ), + iOptions: IOSOptions(), + ), +); + +/// HTTP client provider +final httpClientProvider = Provider( + (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( + (ref) => Hive.box(StorageConstants.appSettingsBox), +); + +/// Cache Hive box provider +final cacheBoxProvider = Provider( + (ref) => Hive.box(StorageConstants.cacheBox), +); + +/// User data Hive box provider +final userDataBoxProvider = Provider( + (ref) => Hive.box(StorageConstants.userDataBox), +); + +/// Theme mode provider +final themeModeProvider = StateNotifierProvider( + (ref) => ThemeModeNotifier(ref.watch(appSettingsBoxProvider)), +); + +/// Theme mode notifier +class ThemeModeNotifier extends StateNotifier { + 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 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 toggleTheme() async { + switch (state) { + case ThemeMode.system: + case ThemeMode.light: + await setThemeMode(ThemeMode.dark); + break; + case ThemeMode.dark: + await setThemeMode(ThemeMode.light); + break; + } + } +} \ No newline at end of file diff --git a/lib/shared/presentation/providers/connectivity_providers.dart b/lib/shared/presentation/providers/connectivity_providers.dart new file mode 100644 index 0000000..367fa3e --- /dev/null +++ b/lib/shared/presentation/providers/connectivity_providers.dart @@ -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 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 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 _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 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 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 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 getConnectionStats() { + if (state.isEmpty) { + return { + 'totalChanges': 0, + 'uptimePercentage': 0.0, + 'mostCommonConnection': 'Unknown', + 'connectionTypes': {}, + }; + } + + final connectionTypeCounts = {}; + 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)), + }; + } +} \ No newline at end of file diff --git a/lib/shared/presentation/providers/connectivity_providers.g.dart b/lib/shared/presentation/providers/connectivity_providers.g.dart new file mode 100644 index 0000000..59e2901 --- /dev/null +++ b/lib/shared/presentation/providers/connectivity_providers.g.dart @@ -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.internal( + connectivity, + name: r'connectivityProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$connectivityHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef ConnectivityRef = AutoDisposeProviderRef; +String _$networkConnectivityStreamHash() => + r'0850402a3f1ed68481cfa9b8a3a371c804c358f3'; + +/// Network connectivity stream provider +/// +/// Copied from [networkConnectivityStream]. +@ProviderFor(networkConnectivityStream) +final networkConnectivityStreamProvider = + AutoDisposeStreamProvider.internal( + networkConnectivityStream, + name: r'networkConnectivityStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$networkConnectivityStreamHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef NetworkConnectivityStreamRef + = AutoDisposeStreamProviderRef; +String _$isConnectedHash() => r'89efbfc9ecb21e2ff1a7f6eea736457e35bed181'; + +/// Simple connectivity status provider +/// +/// Copied from [isConnected]. +@ProviderFor(isConnected) +final isConnectedProvider = AutoDisposeProvider.internal( + isConnected, + name: r'isConnectedProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$isConnectedHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef IsConnectedRef = AutoDisposeProviderRef; +String _$connectionTypeHash() => r'fd1d65f0ae9afe2b04b358755ed4347e27a0515f'; + +/// Connection type provider +/// +/// Copied from [connectionType]. +@ProviderFor(connectionType) +final connectionTypeProvider = + AutoDisposeProvider.internal( + connectionType, + name: r'connectionTypeProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$connectionTypeHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef ConnectionTypeRef = AutoDisposeProviderRef; +String _$isWifiConnectedHash() => r'6ab4a8f83d5073544d77620bea093f4b34d61e05'; + +/// Is Wi-Fi connected provider +/// +/// Copied from [isWifiConnected]. +@ProviderFor(isWifiConnected) +final isWifiConnectedProvider = AutoDisposeProvider.internal( + isWifiConnected, + name: r'isWifiConnectedProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$isWifiConnectedHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef IsWifiConnectedRef = AutoDisposeProviderRef; +String _$isMobileConnectedHash() => r'1e03a490b5a59ac598fe75b45c42b353cec26129'; + +/// Is mobile data connected provider +/// +/// Copied from [isMobileConnected]. +@ProviderFor(isMobileConnected) +final isMobileConnectedProvider = AutoDisposeProvider.internal( + isMobileConnected, + name: r'isMobileConnectedProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$isMobileConnectedHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef IsMobileConnectedRef = AutoDisposeProviderRef; +String _$networkQualityHash() => r'b72cb19e0b8537514827d11fbe2f46bba4e94ac2'; + +/// Network quality indicator provider +/// +/// Copied from [networkQuality]. +@ProviderFor(networkQuality) +final networkQualityProvider = AutoDisposeProvider.internal( + networkQuality, + name: r'networkQualityProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$networkQualityHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef NetworkQualityRef = AutoDisposeProviderRef; +String _$networkStatusNotifierHash() => + r'adebb286dce36d8cb54504f04a67dd4c00dceade'; + +/// Current network status provider +/// +/// Copied from [NetworkStatusNotifier]. +@ProviderFor(NetworkStatusNotifier) +final networkStatusNotifierProvider = + AutoDisposeNotifierProvider.internal( + NetworkStatusNotifier.new, + name: r'networkStatusNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$networkStatusNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$NetworkStatusNotifier = AutoDisposeNotifier; +String _$networkHistoryNotifierHash() => + r'6498139c6e6e8472c81cb3f1789bcabfc4779943'; + +/// Network history provider for tracking connection changes +/// +/// Copied from [NetworkHistoryNotifier]. +@ProviderFor(NetworkHistoryNotifier) +final networkHistoryNotifierProvider = AutoDisposeNotifierProvider< + NetworkHistoryNotifier, List>.internal( + NetworkHistoryNotifier.new, + name: r'networkHistoryNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$networkHistoryNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$NetworkHistoryNotifier = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/lib/shared/widgets/custom_app_bar.dart b/lib/shared/widgets/custom_app_bar.dart new file mode 100644 index 0000000..53aa6cb --- /dev/null +++ b/lib/shared/widgets/custom_app_bar.dart @@ -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? 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); +} \ No newline at end of file diff --git a/lib/shared/widgets/empty_state_widget.dart b/lib/shared/widgets/empty_state_widget.dart new file mode 100644 index 0000000..40934fc --- /dev/null +++ b/lib/shared/widgets/empty_state_widget.dart @@ -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!, + ], + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/shared/widgets/loading_widget.dart b/lib/shared/widgets/loading_widget.dart new file mode 100644 index 0000000..ac3db47 --- /dev/null +++ b/lib/shared/widgets/loading_widget.dart @@ -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), + ), + ], + ); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index c1b51d3..b4ceb4a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,38 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + url: "https://pub.dev" + source: hosted + version: "0.11.3" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -17,6 +49,94 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + url: "https://pub.dev" + source: hosted + version: "2.4.13" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + url: "https://pub.dev" + source: hosted + version: "7.3.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + url: "https://pub.dev" + source: hosted + version: "8.12.0" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -25,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -33,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" collection: dependency: transitive description: @@ -41,6 +177,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -49,6 +217,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 + url: "https://pub.dev" + source: hosted + version: "0.6.3" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + url: "https://pub.dev" + source: hosted + version: "2.3.6" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + dio: + dependency: "direct main" + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -57,11 +273,43 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_lints: dependency: "direct dev" description: @@ -70,11 +318,208 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fpdart: + dependency: "direct main" + description: + name: fpdart + sha256: "1b84ce64453974159f08046f5d05592020d1fcb2099d7fe6ec58da0e7337af77" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1 + url: "https://pub.dev" + source: hosted + version: "2.5.2" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" + hive_generator: + dependency: "direct dev" + description: + name: hive_generator + sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + http: + dependency: transitive + description: + name: http + sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 + url: "https://pub.dev" + source: hosted + version: "1.5.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b + url: "https://pub.dev" + source: hosted + version: "6.8.0" leak_tracker: dependency: transitive description: @@ -107,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -131,6 +584,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -139,11 +624,187 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db" + url: "https://pub.dev" + source: hosted + version: "2.2.18" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pretty_dio_logger: + dependency: "direct main" + description: + name: pretty_dio_logger + sha256: "36f2101299786d567869493e2f5731de61ce130faa14679473b26905a92b6407" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: d451608bf17a372025fc36058863737636625dfdb7e3cbf6142e0dfeb366ab22 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" source_span: dependency: transitive description: @@ -152,6 +813,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -160,6 +869,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -168,6 +885,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -176,6 +901,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -192,6 +925,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: @@ -208,6 +965,70 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.9.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7dc91a8..5fb3408 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,34 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + # State Management + flutter_riverpod: ^2.5.1 + riverpod_annotation: ^2.3.5 + + # Networking + dio: ^5.7.0 + connectivity_plus: ^6.0.5 + pretty_dio_logger: ^1.4.0 + + # Navigation + go_router: ^14.6.2 + + # Local Storage + hive: ^2.2.3 + hive_flutter: ^1.1.0 + + # Data Models + freezed_annotation: ^2.4.1 + json_annotation: ^4.8.1 + equatable: ^2.0.5 + fpdart: ^1.1.0 + + # Secure Storage + flutter_secure_storage: ^9.2.2 + + # Image Caching + cached_network_image: ^3.3.1 + dev_dependencies: flutter_test: sdk: flutter @@ -46,6 +74,13 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^5.0.0 + # Code Generation + build_runner: ^2.4.7 + freezed: ^2.4.5 + json_serializable: ^6.7.1 + riverpod_generator: ^2.4.0 + hive_generator: ^2.0.1 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec