From e95ded01895ab61ab41a1528a47ead2184a4dbda Mon Sep 17 00:00:00 2001 From: "engine-labs-app[bot]" <140088366+engine-labs-app[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 14:26:47 +0000 Subject: [PATCH] feat(backend): integrate authentication, data, MinIO/S3, and AR services This change implements backend integration required for the AR feature, enabling robust authentication, data layer abstraction, and optimized file handling for large animation files. - Implements OAuth2 authentication (login, token refresh, secure local storage) - Adds repository/services for animation metadata, markers, user assets - Integrates MinIO/S3 with streaming, progress callbacks, presigned URLs - Centralizes AR business logic in AR service with token refresh and offline awareness - Comprehensive unit tests for API, storage, and repositories Migrate .env files for MinIO/S3 and endpoint configuration. BREAKING CHANGE: API and AR feature integration contracts updated. --- .env | 10 + .env.example | 10 + BACKEND_INTEGRATION.md | 555 ++++++++++++++++++ lib/core/config/app_config.dart | 26 + lib/core/di/injection_container.config.dart | 94 +++ lib/core/di/injection_container.dart | 12 + lib/core/error/exceptions.dart | 56 ++ lib/core/error/failures.dart | 37 ++ lib/core/network/network_info.dart | 27 + .../local/secure_storage_service.dart | 109 ++++ .../remote/animation_api_client.dart | 149 +++++ .../datasources/remote/auth_api_client.dart | 115 ++++ .../datasources/remote/marker_api_client.dart | 98 ++++ lib/data/datasources/remote/minio_client.dart | 174 ++++++ .../remote/user_asset_api_client.dart | 99 ++++ lib/data/models/animation_metadata_model.dart | 79 +++ lib/data/models/auth_token_model.dart | 50 ++ lib/data/models/marker_definition_model.dart | 60 ++ lib/data/models/user_asset_model.dart | 70 +++ .../repositories/animation_repository.dart | 203 +++++++ lib/data/repositories/auth_repository.dart | 168 ++++++ lib/data/repositories/marker_repository.dart | 101 ++++ .../repositories/user_asset_repository.dart | 112 ++++ lib/domain/entities/animation_metadata.dart | 51 ++ lib/domain/entities/auth_token.dart | 32 + lib/domain/entities/marker_definition.dart | 39 ++ lib/domain/entities/user_asset.dart | 43 ++ lib/domain/services/ar_service.dart | 197 +++++++ pubspec.yaml | 5 + test/unit/core/network/network_info_test.dart | 67 +++ .../core/network/network_info_test.mocks.dart | 26 + .../datasources/auth_api_client_test.dart | 183 ++++++ .../auth_api_client_test.mocks.dart | 62 ++ .../secure_storage_service_test.dart | 188 ++++++ .../secure_storage_service_test.mocks.dart | 97 +++ .../animation_repository_test.dart | 287 +++++++++ .../animation_repository_test.mocks.dart | 138 +++++ .../repositories/auth_repository_test.dart | 272 +++++++++ .../auth_repository_test.mocks.dart | 204 +++++++ .../unit/domain/services/ar_service_test.dart | 273 +++++++++ .../services/ar_service_test.mocks.dart | 148 +++++ 41 files changed, 4726 insertions(+) create mode 100644 BACKEND_INTEGRATION.md create mode 100644 lib/core/di/injection_container.config.dart create mode 100644 lib/core/error/exceptions.dart create mode 100644 lib/core/error/failures.dart create mode 100644 lib/core/network/network_info.dart create mode 100644 lib/data/datasources/local/secure_storage_service.dart create mode 100644 lib/data/datasources/remote/animation_api_client.dart create mode 100644 lib/data/datasources/remote/auth_api_client.dart create mode 100644 lib/data/datasources/remote/marker_api_client.dart create mode 100644 lib/data/datasources/remote/minio_client.dart create mode 100644 lib/data/datasources/remote/user_asset_api_client.dart create mode 100644 lib/data/models/animation_metadata_model.dart create mode 100644 lib/data/models/auth_token_model.dart create mode 100644 lib/data/models/marker_definition_model.dart create mode 100644 lib/data/models/user_asset_model.dart create mode 100644 lib/data/repositories/animation_repository.dart create mode 100644 lib/data/repositories/auth_repository.dart create mode 100644 lib/data/repositories/marker_repository.dart create mode 100644 lib/data/repositories/user_asset_repository.dart create mode 100644 lib/domain/entities/animation_metadata.dart create mode 100644 lib/domain/entities/auth_token.dart create mode 100644 lib/domain/entities/marker_definition.dart create mode 100644 lib/domain/entities/user_asset.dart create mode 100644 lib/domain/services/ar_service.dart create mode 100644 test/unit/core/network/network_info_test.dart create mode 100644 test/unit/core/network/network_info_test.mocks.dart create mode 100644 test/unit/data/datasources/auth_api_client_test.dart create mode 100644 test/unit/data/datasources/auth_api_client_test.mocks.dart create mode 100644 test/unit/data/datasources/secure_storage_service_test.dart create mode 100644 test/unit/data/datasources/secure_storage_service_test.mocks.dart create mode 100644 test/unit/data/repositories/animation_repository_test.dart create mode 100644 test/unit/data/repositories/animation_repository_test.mocks.dart create mode 100644 test/unit/data/repositories/auth_repository_test.dart create mode 100644 test/unit/data/repositories/auth_repository_test.mocks.dart create mode 100644 test/unit/domain/services/ar_service_test.dart create mode 100644 test/unit/domain/services/ar_service_test.mocks.dart diff --git a/.env b/.env index 35121e3..c817176 100644 --- a/.env +++ b/.env @@ -3,6 +3,16 @@ ENVIRONMENT=development # API Configuration API_BASE_URL=https://api.example.com +AUTH_TOKEN_ENDPOINT=/auth/token +AUTH_REFRESH_ENDPOINT=/auth/refresh + +# MinIO/S3 Configuration +MINIO_ENDPOINT=play.min.io +MINIO_PORT=9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_USE_SSL=true +MINIO_BUCKET=ar-animations # Feature Flags ENABLE_LOGGING=true diff --git a/.env.example b/.env.example index 6b4d65f..3970302 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,16 @@ ENVIRONMENT=development # API Configuration API_BASE_URL=https://api.example.com +AUTH_TOKEN_ENDPOINT=/auth/token +AUTH_REFRESH_ENDPOINT=/auth/refresh + +# MinIO/S3 Configuration +MINIO_ENDPOINT=play.min.io +MINIO_PORT=9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_USE_SSL=true +MINIO_BUCKET=ar-animations # Feature Flags ENABLE_LOGGING=true diff --git a/BACKEND_INTEGRATION.md b/BACKEND_INTEGRATION.md new file mode 100644 index 0000000..ad33ef3 --- /dev/null +++ b/BACKEND_INTEGRATION.md @@ -0,0 +1,555 @@ +# Backend Integration Documentation + +This document describes the backend integration implemented for the Flutter AR App, including authentication, data layer, MinIO/S3 integration, and AR services. + +## Table of Contents + +1. [Overview](#overview) +2. [Authentication Flow](#authentication-flow) +3. [Data Layer](#data-layer) +4. [MinIO/S3 Integration](#minios3-integration) +5. [AR Service](#ar-service) +6. [Error Handling](#error-handling) +7. [Testing](#testing) +8. [Configuration](#configuration) + +## Overview + +The backend integration follows Clean Architecture principles with three main layers: + +- **Domain Layer**: Business entities and services +- **Data Layer**: Repositories, API clients, and data sources +- **Presentation Layer**: UI components and state management + +All components use dependency injection via GetIt and Injectable for loose coupling and testability. + +## Authentication Flow + +### Components + +#### 1. AuthToken Entity +**Location**: `lib/domain/entities/auth_token.dart` + +Represents an authentication token with access token, refresh token, expiry date, and token type. + +```dart +final token = AuthToken( + accessToken: 'access_token', + refreshToken: 'refresh_token', + expiresAt: DateTime.now().add(Duration(hours: 1)), + tokenType: 'Bearer', +); + +// Check if token is expired or expiring soon +if (token.isExpired) { /* handle expired token */ } +if (token.isExpiringSoon) { /* refresh token */ } +``` + +#### 2. SecureStorageService +**Location**: `lib/data/datasources/local/secure_storage_service.dart` + +Provides secure storage for authentication tokens using Flutter Secure Storage with encrypted shared preferences on Android. + +```dart +// Save tokens +await secureStorage.saveAccessToken(token.accessToken); +await secureStorage.saveRefreshToken(token.refreshToken); +await secureStorage.saveTokenExpiry(token.expiresAt); + +// Retrieve tokens +final accessToken = await secureStorage.getAccessToken(); +final hasValid = await secureStorage.hasValidToken(); + +// Clear tokens +await secureStorage.clearTokens(); +``` + +#### 3. AuthApiClient +**Location**: `lib/data/datasources/remote/auth_api_client.dart` + +Handles authentication API calls with automatic error handling and retry logic. + +```dart +// Login +final tokenModel = await authApiClient.login( + username: 'user', + password: 'pass', +); + +// Refresh token +final newTokenModel = await authApiClient.refreshToken(refreshToken); + +// Logout +await authApiClient.logout(accessToken); +``` + +#### 4. AuthRepository +**Location**: `lib/data/repositories/auth_repository.dart` + +Implements the authentication repository with offline awareness and automatic retries. + +```dart +// Login +final result = await authRepository.login( + username: 'user', + password: 'pass', +); + +result.fold( + (failure) => print('Login failed: $failure'), + (token) => print('Login successful'), +); + +// Check authentication status +final isAuth = await authRepository.isAuthenticated(); +``` + +### Authentication Flow Diagram + +``` +User -> AuthRepository -> AuthApiClient -> API Server + -> SecureStorageService -> Secure Storage +``` + +## Data Layer + +### Animation Metadata + +Fetch and manage animation assets from the API. + +**Components**: +- Entity: `lib/domain/entities/animation_metadata.dart` +- Model: `lib/data/models/animation_metadata_model.dart` +- API Client: `lib/data/datasources/remote/animation_api_client.dart` +- Repository: `lib/data/repositories/animation_repository.dart` + +**Usage**: + +```dart +// Get all animations +final result = await animationRepository.getAnimations(page: 1, limit: 20); + +// Get specific animation +final result = await animationRepository.getAnimationById('animation-id'); + +// Search animations +final result = await animationRepository.searchAnimations( + query: 'dancing', + tags: ['3d', 'animated'], +); +``` + +### Marker Definitions + +Manage AR marker definitions for object recognition. + +**Components**: +- Entity: `lib/domain/entities/marker_definition.dart` +- Model: `lib/data/models/marker_definition_model.dart` +- API Client: `lib/data/datasources/remote/marker_api_client.dart` +- Repository: `lib/data/repositories/marker_repository.dart` + +**Usage**: + +```dart +// Get all markers +final result = await markerRepository.getMarkers(page: 1, limit: 20); + +// Get specific marker +final result = await markerRepository.getMarkerById('marker-id'); +``` + +### User Assets + +Manage user-specific assets and uploaded content. + +**Components**: +- Entity: `lib/domain/entities/user_asset.dart` +- Model: `lib/data/models/user_asset_model.dart` +- API Client: `lib/data/datasources/remote/user_asset_api_client.dart` +- Repository: `lib/data/repositories/user_asset_repository.dart` + +**Usage**: + +```dart +// Get user assets +final result = await userAssetRepository.getUserAssets( + page: 1, + limit: 20, + assetType: 'image', +); + +// Get specific asset +final result = await userAssetRepository.getUserAssetById('asset-id'); +``` + +## MinIO/S3 Integration + +### MinioClientService + +**Location**: `lib/data/datasources/remote/minio_client.dart` + +Provides S3-compatible object storage integration with MinIO. + +**Features**: +- Stream objects from MinIO +- Download objects with progress callbacks +- Generate presigned URLs for temporary access +- Check object existence and get metadata + +**Usage**: + +```dart +// Stream an object +final stream = await minioClient.streamObject( + objectName: 'animations/dance.mp4', +); + +// Download with progress tracking +await minioClient.downloadObject( + objectName: 'animations/dance.mp4', + destinationPath: '/path/to/save/dance.mp4', + onProgress: (received, total) { + final progress = (received / total) * 100; + print('Download progress: $progress%'); + }, +); + +// Get presigned URL for direct access +final url = await minioClient.getPresignedUrl( + objectName: 'animations/dance.mp4', + expiry: Duration(hours: 1), +); + +// Check if object exists +final exists = await minioClient.objectExists( + objectName: 'animations/dance.mp4', +); + +// Get object size +final size = await minioClient.getObjectSize( + objectName: 'animations/dance.mp4', +); +``` + +### AnimationRepository Integration + +The AnimationRepository integrates MinIO for downloading animation files: + +```dart +// Download animation file +final result = await animationRepository.downloadAnimation( + objectName: 'animations/dance.mp4', + destinationPath: '/local/path/dance.mp4', + onProgress: (received, total) { + // Update UI with download progress + }, +); + +// Get stream URL +final urlResult = await animationRepository.getAnimationStreamUrl( + objectName: 'animations/dance.mp4', + expiry: Duration(hours: 1), +); +``` + +## AR Service + +### ArService + +**Location**: `lib/domain/services/ar_service.dart` + +High-level service that combines all repositories and provides a unified API for AR features. + +**Features**: +- Automatic token refresh when tokens are expiring +- Simplified API for authentication and data access +- Centralized error handling +- Offline awareness + +**Usage**: + +```dart +final arService = getIt(); + +// Authentication +final authResult = await arService.authenticate( + username: 'user', + password: 'pass', +); + +// Check authentication status +final isAuth = await arService.isAuthenticated(); + +// Fetch animations (with automatic token refresh) +final animationsResult = await arService.fetchAnimations(page: 1, limit: 20); + +// Fetch markers +final markersResult = await arService.fetchMarkers(); + +// Fetch user assets +final assetsResult = await arService.fetchUserAssets(assetType: 'image'); + +// Download animation +final downloadResult = await arService.downloadAnimation( + objectName: 'dance.mp4', + destinationPath: '/path/to/save', + onProgress: (received, total) { + // Update progress + }, +); + +// Sign out +await arService.signOut(); +``` + +## Error Handling + +### Failure Types + +**Location**: `lib/core/error/failures.dart` + +- `NetworkFailure`: Network connectivity issues +- `AuthFailure`: Authentication errors +- `ServerFailure`: Server-side errors +- `CacheFailure`: Caching errors +- `StorageFailure`: Storage errors +- `ValidationFailure`: Validation errors +- `UnknownFailure`: Unknown errors + +### Exception Types + +**Location**: `lib/core/error/exceptions.dart` + +- `NetworkException`: Network-related exceptions +- `AuthException`: Authentication exceptions +- `ServerException`: Server-side exceptions +- `CacheException`: Cache-related exceptions +- `StorageException`: Storage exceptions +- `ValidationException`: Validation exceptions + +### Error Handling Pattern + +All repository methods return `Either` from the dartz package: + +```dart +final result = await repository.someMethod(); + +result.fold( + (failure) { + // Handle error + if (failure is NetworkFailure) { + showSnackBar('No internet connection'); + } else if (failure is AuthFailure) { + navigateToLogin(); + } else { + showSnackBar('An error occurred: ${failure.message}'); + } + }, + (data) { + // Handle success + updateUI(data); + }, +); +``` + +### Retry Logic + +Network requests automatically retry up to 3 times on transient failures: + +```dart +final data = await retry( + () => apiClient.getData(), + retryIf: (e) => e is NetworkException, + maxAttempts: 3, +); +``` + +### Offline Awareness + +All repositories check network connectivity before making API calls: + +```dart +if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); +} +``` + +## Testing + +### Test Structure + +``` +test/ +└── unit/ + ├── core/ + │ └── network/ + │ └── network_info_test.dart + ├── data/ + │ ├── datasources/ + │ │ ├── auth_api_client_test.dart + │ │ └── secure_storage_service_test.dart + │ └── repositories/ + │ ├── auth_repository_test.dart + │ └── animation_repository_test.dart + └── domain/ + └── services/ + └── ar_service_test.dart +``` + +### Running Tests + +```bash +# Run all tests +flutter test + +# Run specific test file +flutter test test/unit/data/repositories/auth_repository_test.dart + +# Run with coverage +flutter test --coverage +``` + +### Test Example + +```dart +test('should return AuthToken when login is successful', () async { + // Arrange + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockApiClient.login(username: any, password: any)) + .thenAnswer((_) async => tokenModel); + + // Act + final result = await repository.login( + username: 'user', + password: 'pass', + ); + + // Assert + expect(result.isRight(), true); + verify(mockSecureStorage.saveAccessToken(any)).called(1); +}); +``` + +## Configuration + +### Environment Variables + +Configure the backend integration in `.env` file: + +```env +# API Configuration +API_BASE_URL=https://api.example.com +AUTH_TOKEN_ENDPOINT=/auth/token +AUTH_REFRESH_ENDPOINT=/auth/refresh + +# MinIO/S3 Configuration +MINIO_ENDPOINT=play.min.io +MINIO_PORT=9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_USE_SSL=true +MINIO_BUCKET=ar-animations + +# Feature Flags +ENABLE_LOGGING=true +ENABLE_AR_FEATURES=true +``` + +### Accessing Configuration + +```dart +// In code +final apiBaseUrl = AppConfig.apiBaseUrl; +final minioEndpoint = AppConfig.minioEndpoint; +final enableArFeatures = AppConfig.enableArFeatures; +``` + +### Dependency Injection Setup + +All services are registered in `lib/core/di/injection_container.dart`: + +```dart +// Initialize DI +await configureDependencies(); + +// Access services +final arService = getIt(); +final authRepository = getIt(); +final minioClient = getIt(); +``` + +## Code Generation + +Generate required code files: + +```bash +# Generate JSON serialization and DI configuration +flutter packages pub run build_runner build --delete-conflicting-outputs +``` + +This generates: +- `*.g.dart` files for JSON serialization +- `injection_container.config.dart` for dependency injection +- `*_test.mocks.dart` files for test mocks + +## API Endpoints + +### Authentication +- `POST /auth/token` - Login +- `POST /auth/refresh` - Refresh token +- `POST /auth/logout` - Logout + +### Animations +- `GET /animations` - Get animations list +- `GET /animations/{id}` - Get animation by ID +- `GET /animations/search` - Search animations + +### Markers +- `GET /markers` - Get markers list +- `GET /markers/{id}` - Get marker by ID + +### User Assets +- `GET /user/assets` - Get user assets list +- `GET /user/assets/{id}` - Get user asset by ID + +## Best Practices + +1. **Always check network connectivity** before making API calls +2. **Use dependency injection** for all services +3. **Handle errors with Either pattern** for type-safe error handling +4. **Implement retry logic** for transient network failures +5. **Secure token storage** using Flutter Secure Storage +6. **Automatic token refresh** when tokens are expiring +7. **Write unit tests** for all repositories and services +8. **Use const constructors** where possible for performance +9. **Follow Clean Architecture** principles for maintainability +10. **Document API changes** in this file + +## Troubleshooting + +### Common Issues + +1. **Token Expired**: Automatically handled by ArService with token refresh +2. **Network Error**: Check internet connectivity and retry +3. **MinIO Connection Failed**: Verify MinIO configuration in .env +4. **JSON Parsing Error**: Ensure API response matches model definitions + +### Debug Logging + +Enable logging in `.env`: + +```env +ENABLE_LOGGING=true +DEBUG_MODE=true +``` + +## Future Enhancements + +- [ ] Implement caching layer for offline support +- [ ] Add GraphQL support +- [ ] Implement WebSocket for real-time updates +- [ ] Add biometric authentication +- [ ] Implement request queuing for offline mode +- [ ] Add analytics integration +- [ ] Implement A/B testing framework diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 22b64e2..3a7ee8e 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -5,15 +5,32 @@ enum Environment { development, production } class AppConfig { static late Environment _environment; static late String _apiBaseUrl; + static late String _authTokenEndpoint; + static late String _authRefreshEndpoint; static late bool _enableLogging; static late bool _enableArFeatures; + static late String _minioEndpoint; + static late int _minioPort; + static late String _minioAccessKey; + static late String _minioSecretKey; + static late bool _minioUseSSL; + static late String _minioBucket; static Environment get environment => _environment; static String get apiBaseUrl => _apiBaseUrl; + static String get authTokenEndpoint => _authTokenEndpoint; + static String get authRefreshEndpoint => _authRefreshEndpoint; static bool get enableLogging => _enableLogging; static bool get enableArFeatures => _enableArFeatures; static bool get isDevelopment => _environment == Environment.development; static bool get isProduction => _environment == Environment.production; + + static String get minioEndpoint => _minioEndpoint; + static int get minioPort => _minioPort; + static String get minioAccessKey => _minioAccessKey; + static String get minioSecretKey => _minioSecretKey; + static bool get minioUseSSL => _minioUseSSL; + static String get minioBucket => _minioBucket; static Future initialize() async { await dotenv.load(fileName: '.env'); @@ -22,7 +39,16 @@ class AppConfig { _environment = env == 'production' ? Environment.production : Environment.development; _apiBaseUrl = dotenv.env['API_BASE_URL'] ?? 'https://api.example.com'; + _authTokenEndpoint = dotenv.env['AUTH_TOKEN_ENDPOINT'] ?? '/auth/token'; + _authRefreshEndpoint = dotenv.env['AUTH_REFRESH_ENDPOINT'] ?? '/auth/refresh'; _enableLogging = dotenv.env['ENABLE_LOGGING'] == 'true'; _enableArFeatures = dotenv.env['ENABLE_AR_FEATURES'] == 'true'; + + _minioEndpoint = dotenv.env['MINIO_ENDPOINT'] ?? 'play.min.io'; + _minioPort = int.tryParse(dotenv.env['MINIO_PORT'] ?? '9000') ?? 9000; + _minioAccessKey = dotenv.env['MINIO_ACCESS_KEY'] ?? ''; + _minioSecretKey = dotenv.env['MINIO_SECRET_KEY'] ?? ''; + _minioUseSSL = dotenv.env['MINIO_USE_SSL'] == 'true'; + _minioBucket = dotenv.env['MINIO_BUCKET'] ?? 'ar-animations'; } } diff --git a/lib/core/di/injection_container.config.dart b/lib/core/di/injection_container.config.dart new file mode 100644 index 0000000..b31edbc --- /dev/null +++ b/lib/core/di/injection_container.config.dart @@ -0,0 +1,94 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ************************************************************************** +// InjectableConfigGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_lambdas +// ignore_for_file: lines_longer_than_80_chars +// coverage:ignore-file + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:connectivity_plus/connectivity_plus.dart' as _i3; +import 'package:dio/dio.dart' as _i4; +import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i5; +import 'package:get_it/get_it.dart' as _i1; +import 'package:injectable/injectable.dart' as _i2; + +import '../../data/datasources/local/secure_storage_service.dart' as _i11; +import '../../data/datasources/remote/animation_api_client.dart' as _i6; +import '../../data/datasources/remote/auth_api_client.dart' as _i7; +import '../../data/datasources/remote/marker_api_client.dart' as _i9; +import '../../data/datasources/remote/minio_client.dart' as _i10; +import '../../data/datasources/remote/user_asset_api_client.dart' as _i13; +import '../../data/repositories/animation_repository.dart' as _i16; +import '../../data/repositories/auth_repository.dart' as _i17; +import '../../data/repositories/marker_repository.dart' as _i18; +import '../../data/repositories/user_asset_repository.dart' as _i14; +import '../../domain/services/ar_service.dart' as _i15; +import '../network/network_info.dart' as _i8; +import 'injection_container.dart' as _i12; + +extension GetItInjectableX on _i1.GetIt { + // initializes the registration of main-scope dependencies inside of GetIt + _i1.GetIt init({ + String? environment, + _i2.EnvironmentFilter? environmentFilter, + }) { + final gh = _i2.GetItHelper( + this, + environment, + environmentFilter, + ); + final registerModule = _$RegisterModule(); + gh.singleton<_i3.Connectivity>(registerModule.connectivity); + gh.singleton<_i4.Dio>(registerModule.dio); + gh.singleton<_i5.FlutterSecureStorage>(registerModule.secureStorage); + gh.lazySingleton<_i6.AnimationApiClient>( + () => _i6.AnimationApiClient(gh<_i4.Dio>())); + gh.lazySingleton<_i7.AuthApiClient>( + () => _i7.AuthApiClient(gh<_i4.Dio>())); + gh.lazySingleton<_i8.NetworkInfo>( + () => _i8.NetworkInfoImpl(gh<_i3.Connectivity>())); + gh.lazySingleton<_i9.MarkerApiClient>( + () => _i9.MarkerApiClient(gh<_i4.Dio>())); + gh.lazySingleton<_i10.MinioClientService>( + () => _i10.MinioClientService()); + gh.lazySingleton<_i11.SecureStorageService>( + () => _i11.SecureStorageService(gh<_i5.FlutterSecureStorage>())); + gh.lazySingleton<_i13.UserAssetApiClient>( + () => _i13.UserAssetApiClient(gh<_i4.Dio>())); + gh.lazySingleton<_i14.UserAssetRepository>(() => + _i14.UserAssetRepositoryImpl( + apiClient: gh<_i13.UserAssetApiClient>(), + secureStorage: gh<_i11.SecureStorageService>(), + networkInfo: gh<_i8.NetworkInfo>(), + )); + gh.lazySingleton<_i15.ArService>(() => _i15.ArService( + authRepository: gh<_i17.AuthRepository>(), + animationRepository: gh<_i16.AnimationRepository>(), + markerRepository: gh<_i18.MarkerRepository>(), + userAssetRepository: gh<_i14.UserAssetRepository>(), + )); + gh.lazySingleton<_i16.AnimationRepository>(() => + _i16.AnimationRepositoryImpl( + apiClient: gh<_i6.AnimationApiClient>(), + minioClient: gh<_i10.MinioClientService>(), + secureStorage: gh<_i11.SecureStorageService>(), + networkInfo: gh<_i8.NetworkInfo>(), + )); + gh.lazySingleton<_i17.AuthRepository>(() => _i17.AuthRepositoryImpl( + apiClient: gh<_i7.AuthApiClient>(), + secureStorage: gh<_i11.SecureStorageService>(), + networkInfo: gh<_i8.NetworkInfo>(), + )); + gh.lazySingleton<_i18.MarkerRepository>(() => _i18.MarkerRepositoryImpl( + apiClient: gh<_i9.MarkerApiClient>(), + secureStorage: gh<_i11.SecureStorageService>(), + networkInfo: gh<_i8.NetworkInfo>(), + )); + return this; + } +} + +class _$RegisterModule extends _i12.RegisterModule {} diff --git a/lib/core/di/injection_container.dart b/lib/core/di/injection_container.dart index c620e06..2b144b5 100644 --- a/lib/core/di/injection_container.dart +++ b/lib/core/di/injection_container.dart @@ -1,6 +1,8 @@ import 'package:get_it/get_it.dart'; import 'package:injectable/injectable.dart'; import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'injection_container.config.dart'; @@ -21,4 +23,14 @@ abstract class RegisterModule { sendTimeout: const Duration(seconds: 30), ), ); + + @singleton + FlutterSecureStorage get secureStorage => const FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, + ), + ); + + @singleton + Connectivity get connectivity => Connectivity(); } diff --git a/lib/core/error/exceptions.dart b/lib/core/error/exceptions.dart new file mode 100644 index 0000000..7d69a02 --- /dev/null +++ b/lib/core/error/exceptions.dart @@ -0,0 +1,56 @@ +class NetworkException implements Exception { + final String message; + final int? statusCode; + + NetworkException(this.message, [this.statusCode]); + + @override + String toString() => 'NetworkException: $message'; +} + +class AuthException implements Exception { + final String message; + final int? statusCode; + + AuthException(this.message, [this.statusCode]); + + @override + String toString() => 'AuthException: $message'; +} + +class ServerException implements Exception { + final String message; + final int? statusCode; + + ServerException(this.message, [this.statusCode]); + + @override + String toString() => 'ServerException: $message'; +} + +class CacheException implements Exception { + final String message; + + CacheException(this.message); + + @override + String toString() => 'CacheException: $message'; +} + +class StorageException implements Exception { + final String message; + + StorageException(this.message); + + @override + String toString() => 'StorageException: $message'; +} + +class ValidationException implements Exception { + final String message; + + ValidationException(this.message); + + @override + String toString() => 'ValidationException: $message'; +} diff --git a/lib/core/error/failures.dart b/lib/core/error/failures.dart new file mode 100644 index 0000000..0c1e646 --- /dev/null +++ b/lib/core/error/failures.dart @@ -0,0 +1,37 @@ +abstract class Failure { + final String message; + final int? statusCode; + + const Failure(this.message, [this.statusCode]); + + @override + String toString() => message; +} + +class NetworkFailure extends Failure { + const NetworkFailure(super.message, [super.statusCode]); +} + +class AuthFailure extends Failure { + const AuthFailure(super.message, [super.statusCode]); +} + +class ServerFailure extends Failure { + const ServerFailure(super.message, [super.statusCode]); +} + +class CacheFailure extends Failure { + const CacheFailure(super.message); +} + +class StorageFailure extends Failure { + const StorageFailure(super.message); +} + +class ValidationFailure extends Failure { + const ValidationFailure(super.message); +} + +class UnknownFailure extends Failure { + const UnknownFailure(super.message); +} diff --git a/lib/core/network/network_info.dart b/lib/core/network/network_info.dart new file mode 100644 index 0000000..a0fffe5 --- /dev/null +++ b/lib/core/network/network_info.dart @@ -0,0 +1,27 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:injectable/injectable.dart'; + +abstract class NetworkInfo { + Future get isConnected; + Stream get onConnectivityChanged; +} + +@LazySingleton(as: NetworkInfo) +class NetworkInfoImpl implements NetworkInfo { + final Connectivity connectivity; + + NetworkInfoImpl(this.connectivity); + + @override + Future get isConnected async { + final result = await connectivity.checkConnectivity(); + return result != ConnectivityResult.none; + } + + @override + Stream get onConnectivityChanged { + return connectivity.onConnectivityChanged.map((result) { + return result != ConnectivityResult.none; + }); + } +} diff --git a/lib/data/datasources/local/secure_storage_service.dart b/lib/data/datasources/local/secure_storage_service.dart new file mode 100644 index 0000000..b9c524a --- /dev/null +++ b/lib/data/datasources/local/secure_storage_service.dart @@ -0,0 +1,109 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:injectable/injectable.dart'; +import '../../../core/error/exceptions.dart'; + +@lazySingleton +class SecureStorageService { + final FlutterSecureStorage secureStorage; + + static const String _accessTokenKey = 'access_token'; + static const String _refreshTokenKey = 'refresh_token'; + static const String _tokenExpiryKey = 'token_expiry'; + static const String _tokenTypeKey = 'token_type'; + + SecureStorageService(this.secureStorage); + + Future saveAccessToken(String token) async { + try { + await secureStorage.write(key: _accessTokenKey, value: token); + } catch (e) { + throw StorageException('Failed to save access token: $e'); + } + } + + Future getAccessToken() async { + try { + return await secureStorage.read(key: _accessTokenKey); + } catch (e) { + throw StorageException('Failed to read access token: $e'); + } + } + + Future saveRefreshToken(String token) async { + try { + await secureStorage.write(key: _refreshTokenKey, value: token); + } catch (e) { + throw StorageException('Failed to save refresh token: $e'); + } + } + + Future getRefreshToken() async { + try { + return await secureStorage.read(key: _refreshTokenKey); + } catch (e) { + throw StorageException('Failed to read refresh token: $e'); + } + } + + Future saveTokenExpiry(DateTime expiry) async { + try { + await secureStorage.write( + key: _tokenExpiryKey, + value: expiry.toIso8601String(), + ); + } catch (e) { + throw StorageException('Failed to save token expiry: $e'); + } + } + + Future getTokenExpiry() async { + try { + final expiryString = await secureStorage.read(key: _tokenExpiryKey); + return expiryString != null ? DateTime.parse(expiryString) : null; + } catch (e) { + throw StorageException('Failed to read token expiry: $e'); + } + } + + Future saveTokenType(String tokenType) async { + try { + await secureStorage.write(key: _tokenTypeKey, value: tokenType); + } catch (e) { + throw StorageException('Failed to save token type: $e'); + } + } + + Future getTokenType() async { + try { + return await secureStorage.read(key: _tokenTypeKey); + } catch (e) { + throw StorageException('Failed to read token type: $e'); + } + } + + Future clearTokens() async { + try { + await secureStorage.delete(key: _accessTokenKey); + await secureStorage.delete(key: _refreshTokenKey); + await secureStorage.delete(key: _tokenExpiryKey); + await secureStorage.delete(key: _tokenTypeKey); + } catch (e) { + throw StorageException('Failed to clear tokens: $e'); + } + } + + Future hasValidToken() async { + try { + final accessToken = await getAccessToken(); + final expiry = await getTokenExpiry(); + + if (accessToken == null || expiry == null) { + return false; + } + + return DateTime.now().isBefore(expiry); + } catch (e) { + return false; + } + } +} diff --git a/lib/data/datasources/remote/animation_api_client.dart b/lib/data/datasources/remote/animation_api_client.dart new file mode 100644 index 0000000..f07cb61 --- /dev/null +++ b/lib/data/datasources/remote/animation_api_client.dart @@ -0,0 +1,149 @@ +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import '../../../core/config/app_config.dart'; +import '../../../core/error/exceptions.dart'; +import '../../models/animation_metadata_model.dart'; + +@lazySingleton +class AnimationApiClient { + final Dio dio; + + AnimationApiClient(this.dio); + + Future> getAnimations({ + String? accessToken, + int page = 1, + int limit = 20, + }) async { + try { + final response = await dio.get( + '${AppConfig.apiBaseUrl}/animations', + queryParameters: { + 'page': page, + 'limit': limit, + }, + options: Options( + headers: accessToken != null + ? {'Authorization': 'Bearer $accessToken'} + : null, + ), + ); + + if (response.statusCode == 200) { + final List data = response.data['animations'] ?? response.data; + return data + .map((json) => AnimationMetadataModel.fromJson(json)) + .toList(); + } else { + throw ServerException( + 'Failed to fetch animations with status: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw AuthException('Unauthorized', 401); + } else if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException('No internet connection'); + } else { + throw NetworkException('Network error: ${e.message}'); + } + } catch (e) { + throw ServerException('Unexpected error fetching animations: $e'); + } + } + + Future getAnimationById({ + required String id, + String? accessToken, + }) async { + try { + final response = await dio.get( + '${AppConfig.apiBaseUrl}/animations/$id', + options: Options( + headers: accessToken != null + ? {'Authorization': 'Bearer $accessToken'} + : null, + ), + ); + + if (response.statusCode == 200) { + return AnimationMetadataModel.fromJson(response.data); + } else { + throw ServerException( + 'Failed to fetch animation with status: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw AuthException('Unauthorized', 401); + } else if (e.response?.statusCode == 404) { + throw ServerException('Animation not found', 404); + } else if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException('No internet connection'); + } else { + throw NetworkException('Network error: ${e.message}'); + } + } catch (e) { + throw ServerException('Unexpected error fetching animation: $e'); + } + } + + Future> searchAnimations({ + required String query, + String? accessToken, + List? tags, + }) async { + try { + final queryParams = { + 'query': query, + }; + + if (tags != null && tags.isNotEmpty) { + queryParams['tags'] = tags.join(','); + } + + final response = await dio.get( + '${AppConfig.apiBaseUrl}/animations/search', + queryParameters: queryParams, + options: Options( + headers: accessToken != null + ? {'Authorization': 'Bearer $accessToken'} + : null, + ), + ); + + if (response.statusCode == 200) { + final List data = response.data['animations'] ?? response.data; + return data + .map((json) => AnimationMetadataModel.fromJson(json)) + .toList(); + } else { + throw ServerException( + 'Failed to search animations with status: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw AuthException('Unauthorized', 401); + } else if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException('No internet connection'); + } else { + throw NetworkException('Network error: ${e.message}'); + } + } catch (e) { + throw ServerException('Unexpected error searching animations: $e'); + } + } +} diff --git a/lib/data/datasources/remote/auth_api_client.dart b/lib/data/datasources/remote/auth_api_client.dart new file mode 100644 index 0000000..226a1dd --- /dev/null +++ b/lib/data/datasources/remote/auth_api_client.dart @@ -0,0 +1,115 @@ +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import '../../../core/config/app_config.dart'; +import '../../../core/error/exceptions.dart'; +import '../../models/auth_token_model.dart'; + +@lazySingleton +class AuthApiClient { + final Dio dio; + + AuthApiClient(this.dio); + + Future login({ + required String username, + required String password, + }) async { + try { + final response = await dio.post( + '${AppConfig.apiBaseUrl}${AppConfig.authTokenEndpoint}', + data: { + 'username': username, + 'password': password, + 'grant_type': 'password', + }, + ); + + if (response.statusCode == 200) { + return AuthTokenModel.fromJson(response.data); + } else { + throw AuthException( + 'Login failed with status: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw AuthException('Invalid credentials', 401); + } else if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException('No internet connection'); + } else { + throw NetworkException('Network error: ${e.message}'); + } + } catch (e) { + throw ServerException('Unexpected error during login: $e'); + } + } + + Future refreshToken(String refreshToken) async { + try { + final response = await dio.post( + '${AppConfig.apiBaseUrl}${AppConfig.authRefreshEndpoint}', + data: { + 'refresh_token': refreshToken, + 'grant_type': 'refresh_token', + }, + ); + + if (response.statusCode == 200) { + return AuthTokenModel.fromJson(response.data); + } else { + throw AuthException( + 'Token refresh failed with status: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw AuthException('Invalid or expired refresh token', 401); + } else if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException('No internet connection'); + } else { + throw NetworkException('Network error: ${e.message}'); + } + } catch (e) { + throw ServerException('Unexpected error during token refresh: $e'); + } + } + + Future logout(String accessToken) async { + try { + final response = await dio.post( + '${AppConfig.apiBaseUrl}/auth/logout', + options: Options( + headers: { + 'Authorization': 'Bearer $accessToken', + }, + ), + ); + + if (response.statusCode != 200 && response.statusCode != 204) { + throw ServerException( + 'Logout failed with status: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException('No internet connection'); + } else { + throw NetworkException('Network error: ${e.message}'); + } + } catch (e) { + throw ServerException('Unexpected error during logout: $e'); + } + } +} diff --git a/lib/data/datasources/remote/marker_api_client.dart b/lib/data/datasources/remote/marker_api_client.dart new file mode 100644 index 0000000..165b62b --- /dev/null +++ b/lib/data/datasources/remote/marker_api_client.dart @@ -0,0 +1,98 @@ +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import '../../../core/config/app_config.dart'; +import '../../../core/error/exceptions.dart'; +import '../../models/marker_definition_model.dart'; + +@lazySingleton +class MarkerApiClient { + final Dio dio; + + MarkerApiClient(this.dio); + + Future> getMarkers({ + String? accessToken, + int page = 1, + int limit = 20, + }) async { + try { + final response = await dio.get( + '${AppConfig.apiBaseUrl}/markers', + queryParameters: { + 'page': page, + 'limit': limit, + }, + options: Options( + headers: accessToken != null + ? {'Authorization': 'Bearer $accessToken'} + : null, + ), + ); + + if (response.statusCode == 200) { + final List data = response.data['markers'] ?? response.data; + return data + .map((json) => MarkerDefinitionModel.fromJson(json)) + .toList(); + } else { + throw ServerException( + 'Failed to fetch markers with status: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw AuthException('Unauthorized', 401); + } else if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException('No internet connection'); + } else { + throw NetworkException('Network error: ${e.message}'); + } + } catch (e) { + throw ServerException('Unexpected error fetching markers: $e'); + } + } + + Future getMarkerById({ + required String id, + String? accessToken, + }) async { + try { + final response = await dio.get( + '${AppConfig.apiBaseUrl}/markers/$id', + options: Options( + headers: accessToken != null + ? {'Authorization': 'Bearer $accessToken'} + : null, + ), + ); + + if (response.statusCode == 200) { + return MarkerDefinitionModel.fromJson(response.data); + } else { + throw ServerException( + 'Failed to fetch marker with status: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw AuthException('Unauthorized', 401); + } else if (e.response?.statusCode == 404) { + throw ServerException('Marker not found', 404); + } else if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException('No internet connection'); + } else { + throw NetworkException('Network error: ${e.message}'); + } + } catch (e) { + throw ServerException('Unexpected error fetching marker: $e'); + } + } +} diff --git a/lib/data/datasources/remote/minio_client.dart b/lib/data/datasources/remote/minio_client.dart new file mode 100644 index 0000000..feb303f --- /dev/null +++ b/lib/data/datasources/remote/minio_client.dart @@ -0,0 +1,174 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:minio/minio.dart'; +import 'package:injectable/injectable.dart'; +import '../../../core/config/app_config.dart'; +import '../../../core/error/exceptions.dart'; + +typedef ProgressCallback = void Function(int received, int total); + +@lazySingleton +class MinioClientService { + late final Minio _minio; + + MinioClientService() { + _initializeClient(); + } + + void _initializeClient() { + _minio = Minio( + endPoint: AppConfig.minioEndpoint, + port: AppConfig.minioPort, + accessKey: AppConfig.minioAccessKey, + secretKey: AppConfig.minioSecretKey, + useSSL: AppConfig.minioUseSSL, + ); + } + + Future> streamObject({ + required String objectName, + String? bucket, + }) async { + try { + final bucketName = bucket ?? AppConfig.minioBucket; + + final exists = await _minio.bucketExists(bucketName); + if (!exists) { + throw StorageException('Bucket $bucketName does not exist'); + } + + return _minio.getObject(bucketName, objectName); + } catch (e) { + if (e is StorageException) rethrow; + throw StorageException('Failed to stream object: $e'); + } + } + + Future downloadObject({ + required String objectName, + required String destinationPath, + String? bucket, + ProgressCallback? onProgress, + }) async { + try { + final bucketName = bucket ?? AppConfig.minioBucket; + + final exists = await _minio.bucketExists(bucketName); + if (!exists) { + throw StorageException('Bucket $bucketName does not exist'); + } + + final stat = await _minio.statObject(bucketName, objectName); + final totalSize = stat.size ?? 0; + + final stream = await streamObject(objectName: objectName, bucket: bucket); + final file = File(destinationPath); + final sink = file.openWrite(); + + int receivedBytes = 0; + + await for (final chunk in stream) { + sink.add(chunk); + receivedBytes += chunk.length; + + if (onProgress != null && totalSize > 0) { + onProgress(receivedBytes, totalSize); + } + } + + await sink.close(); + } catch (e) { + if (e is StorageException) rethrow; + throw StorageException('Failed to download object: $e'); + } + } + + Future getObjectBytes({ + required String objectName, + String? bucket, + }) async { + try { + final bucketName = bucket ?? AppConfig.minioBucket; + + final exists = await _minio.bucketExists(bucketName); + if (!exists) { + throw StorageException('Bucket $bucketName does not exist'); + } + + final stream = await streamObject(objectName: objectName, bucket: bucket); + final bytes = []; + + await for (final chunk in stream) { + bytes.addAll(chunk); + } + + return Uint8List.fromList(bytes); + } catch (e) { + if (e is StorageException) rethrow; + throw StorageException('Failed to get object bytes: $e'); + } + } + + Future getPresignedUrl({ + required String objectName, + String? bucket, + Duration expiry = const Duration(hours: 1), + }) async { + try { + final bucketName = bucket ?? AppConfig.minioBucket; + + final exists = await _minio.bucketExists(bucketName); + if (!exists) { + throw StorageException('Bucket $bucketName does not exist'); + } + + return await _minio.presignedGetObject( + bucketName, + objectName, + expires: expiry.inSeconds, + ); + } catch (e) { + if (e is StorageException) rethrow; + throw StorageException('Failed to generate presigned URL: $e'); + } + } + + Future objectExists({ + required String objectName, + String? bucket, + }) async { + try { + final bucketName = bucket ?? AppConfig.minioBucket; + + final exists = await _minio.bucketExists(bucketName); + if (!exists) { + return false; + } + + await _minio.statObject(bucketName, objectName); + return true; + } catch (e) { + return false; + } + } + + Future getObjectSize({ + required String objectName, + String? bucket, + }) async { + try { + final bucketName = bucket ?? AppConfig.minioBucket; + + final exists = await _minio.bucketExists(bucketName); + if (!exists) { + throw StorageException('Bucket $bucketName does not exist'); + } + + final stat = await _minio.statObject(bucketName, objectName); + return stat.size; + } catch (e) { + if (e is StorageException) rethrow; + throw StorageException('Failed to get object size: $e'); + } + } +} diff --git a/lib/data/datasources/remote/user_asset_api_client.dart b/lib/data/datasources/remote/user_asset_api_client.dart new file mode 100644 index 0000000..12097b3 --- /dev/null +++ b/lib/data/datasources/remote/user_asset_api_client.dart @@ -0,0 +1,99 @@ +import 'package:dio/dio.dart'; +import 'package:injectable/injectable.dart'; +import '../../../core/config/app_config.dart'; +import '../../../core/error/exceptions.dart'; +import '../../models/user_asset_model.dart'; + +@lazySingleton +class UserAssetApiClient { + final Dio dio; + + UserAssetApiClient(this.dio); + + Future> getUserAssets({ + required String accessToken, + int page = 1, + int limit = 20, + String? assetType, + }) async { + try { + final queryParams = { + 'page': page, + 'limit': limit, + }; + + if (assetType != null) { + queryParams['asset_type'] = assetType; + } + + final response = await dio.get( + '${AppConfig.apiBaseUrl}/user/assets', + queryParameters: queryParams, + options: Options( + headers: {'Authorization': 'Bearer $accessToken'}, + ), + ); + + if (response.statusCode == 200) { + final List data = response.data['assets'] ?? response.data; + return data.map((json) => UserAssetModel.fromJson(json)).toList(); + } else { + throw ServerException( + 'Failed to fetch user assets with status: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw AuthException('Unauthorized', 401); + } else if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException('No internet connection'); + } else { + throw NetworkException('Network error: ${e.message}'); + } + } catch (e) { + throw ServerException('Unexpected error fetching user assets: $e'); + } + } + + Future getUserAssetById({ + required String id, + required String accessToken, + }) async { + try { + final response = await dio.get( + '${AppConfig.apiBaseUrl}/user/assets/$id', + options: Options( + headers: {'Authorization': 'Bearer $accessToken'}, + ), + ); + + if (response.statusCode == 200) { + return UserAssetModel.fromJson(response.data); + } else { + throw ServerException( + 'Failed to fetch user asset with status: ${response.statusCode}', + response.statusCode, + ); + } + } on DioException catch (e) { + if (e.response?.statusCode == 401) { + throw AuthException('Unauthorized', 401); + } else if (e.response?.statusCode == 404) { + throw ServerException('User asset not found', 404); + } else if (e.type == DioExceptionType.connectionTimeout || + e.type == DioExceptionType.receiveTimeout) { + throw NetworkException('Connection timeout'); + } else if (e.type == DioExceptionType.connectionError) { + throw NetworkException('No internet connection'); + } else { + throw NetworkException('Network error: ${e.message}'); + } + } catch (e) { + throw ServerException('Unexpected error fetching user asset: $e'); + } + } +} diff --git a/lib/data/models/animation_metadata_model.dart b/lib/data/models/animation_metadata_model.dart new file mode 100644 index 0000000..312e815 --- /dev/null +++ b/lib/data/models/animation_metadata_model.dart @@ -0,0 +1,79 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../domain/entities/animation_metadata.dart'; + +part 'animation_metadata_model.g.dart'; + +@JsonSerializable() +class AnimationMetadataModel { + final String id; + final String name; + final String description; + + @JsonKey(name: 'file_url') + final String fileUrl; + + @JsonKey(name: 'thumbnail_url') + final String thumbnailUrl; + + @JsonKey(name: 'file_size_mb') + final double fileSizeInMb; + + @JsonKey(name: 'duration_seconds') + final int durationInSeconds; + + final List tags; + + @JsonKey(name: 'created_at') + final String createdAt; + + @JsonKey(name: 'updated_at') + final String? updatedAt; + + const AnimationMetadataModel({ + required this.id, + required this.name, + required this.description, + required this.fileUrl, + required this.thumbnailUrl, + required this.fileSizeInMb, + required this.durationInSeconds, + required this.tags, + required this.createdAt, + this.updatedAt, + }); + + factory AnimationMetadataModel.fromJson(Map json) => + _$AnimationMetadataModelFromJson(json); + + Map toJson() => _$AnimationMetadataModelToJson(this); + + AnimationMetadata toEntity() { + return AnimationMetadata( + id: id, + name: name, + description: description, + fileUrl: fileUrl, + thumbnailUrl: thumbnailUrl, + fileSizeInMb: fileSizeInMb, + durationInSeconds: durationInSeconds, + tags: tags, + createdAt: DateTime.parse(createdAt), + updatedAt: updatedAt != null ? DateTime.parse(updatedAt!) : null, + ); + } + + factory AnimationMetadataModel.fromEntity(AnimationMetadata entity) { + return AnimationMetadataModel( + id: entity.id, + name: entity.name, + description: entity.description, + fileUrl: entity.fileUrl, + thumbnailUrl: entity.thumbnailUrl, + fileSizeInMb: entity.fileSizeInMb, + durationInSeconds: entity.durationInSeconds, + tags: entity.tags, + createdAt: entity.createdAt.toIso8601String(), + updatedAt: entity.updatedAt?.toIso8601String(), + ); + } +} diff --git a/lib/data/models/auth_token_model.dart b/lib/data/models/auth_token_model.dart new file mode 100644 index 0000000..aec5818 --- /dev/null +++ b/lib/data/models/auth_token_model.dart @@ -0,0 +1,50 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../domain/entities/auth_token.dart'; + +part 'auth_token_model.g.dart'; + +@JsonSerializable() +class AuthTokenModel { + @JsonKey(name: 'access_token') + final String accessToken; + + @JsonKey(name: 'refresh_token') + final String refreshToken; + + @JsonKey(name: 'expires_in') + final int expiresIn; + + @JsonKey(name: 'token_type') + final String tokenType; + + const AuthTokenModel({ + required this.accessToken, + required this.refreshToken, + required this.expiresIn, + this.tokenType = 'Bearer', + }); + + factory AuthTokenModel.fromJson(Map json) => + _$AuthTokenModelFromJson(json); + + Map toJson() => _$AuthTokenModelToJson(this); + + AuthToken toEntity() { + return AuthToken( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: DateTime.now().add(Duration(seconds: expiresIn)), + tokenType: tokenType, + ); + } + + factory AuthTokenModel.fromEntity(AuthToken entity) { + final expiresIn = entity.expiresAt.difference(DateTime.now()).inSeconds; + return AuthTokenModel( + accessToken: entity.accessToken, + refreshToken: entity.refreshToken, + expiresIn: expiresIn > 0 ? expiresIn : 0, + tokenType: entity.tokenType, + ); + } +} diff --git a/lib/data/models/marker_definition_model.dart b/lib/data/models/marker_definition_model.dart new file mode 100644 index 0000000..46b758d --- /dev/null +++ b/lib/data/models/marker_definition_model.dart @@ -0,0 +1,60 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../domain/entities/marker_definition.dart'; + +part 'marker_definition_model.g.dart'; + +@JsonSerializable() +class MarkerDefinitionModel { + final String id; + final String name; + + @JsonKey(name: 'image_url') + final String imageUrl; + + final double width; + final double height; + + @JsonKey(name: 'animation_id') + final String? animationId; + + final Map metadata; + + const MarkerDefinitionModel({ + required this.id, + required this.name, + required this.imageUrl, + required this.width, + required this.height, + this.animationId, + this.metadata = const {}, + }); + + factory MarkerDefinitionModel.fromJson(Map json) => + _$MarkerDefinitionModelFromJson(json); + + Map toJson() => _$MarkerDefinitionModelToJson(this); + + MarkerDefinition toEntity() { + return MarkerDefinition( + id: id, + name: name, + imageUrl: imageUrl, + width: width, + height: height, + animationId: animationId, + metadata: metadata, + ); + } + + factory MarkerDefinitionModel.fromEntity(MarkerDefinition entity) { + return MarkerDefinitionModel( + id: entity.id, + name: entity.name, + imageUrl: entity.imageUrl, + width: entity.width, + height: entity.height, + animationId: entity.animationId, + metadata: entity.metadata, + ); + } +} diff --git a/lib/data/models/user_asset_model.dart b/lib/data/models/user_asset_model.dart new file mode 100644 index 0000000..c4fc49f --- /dev/null +++ b/lib/data/models/user_asset_model.dart @@ -0,0 +1,70 @@ +import 'package:json_annotation/json_annotation.dart'; +import '../../domain/entities/user_asset.dart'; + +part 'user_asset_model.g.dart'; + +@JsonSerializable() +class UserAssetModel { + final String id; + + @JsonKey(name: 'user_id') + final String userId; + + @JsonKey(name: 'asset_type') + final String assetType; + + final String name; + + @JsonKey(name: 'file_url') + final String fileUrl; + + @JsonKey(name: 'file_size_mb') + final double fileSizeInMb; + + @JsonKey(name: 'uploaded_at') + final String uploadedAt; + + final Map metadata; + + const UserAssetModel({ + required this.id, + required this.userId, + required this.assetType, + required this.name, + required this.fileUrl, + required this.fileSizeInMb, + required this.uploadedAt, + this.metadata = const {}, + }); + + factory UserAssetModel.fromJson(Map json) => + _$UserAssetModelFromJson(json); + + Map toJson() => _$UserAssetModelToJson(this); + + UserAsset toEntity() { + return UserAsset( + id: id, + userId: userId, + assetType: assetType, + name: name, + fileUrl: fileUrl, + fileSizeInMb: fileSizeInMb, + uploadedAt: DateTime.parse(uploadedAt), + metadata: metadata, + ); + } + + factory UserAssetModel.fromEntity(UserAsset entity) { + return UserAssetModel( + id: entity.id, + userId: entity.userId, + assetType: entity.assetType, + name: entity.name, + fileUrl: entity.fileUrl, + fileSizeInMb: entity.fileSizeInMb, + uploadedAt: entity.uploadedAt.toIso8601String(), + metadata: entity.metadata, + ); + } +} diff --git a/lib/data/repositories/animation_repository.dart b/lib/data/repositories/animation_repository.dart new file mode 100644 index 0000000..de0f955 --- /dev/null +++ b/lib/data/repositories/animation_repository.dart @@ -0,0 +1,203 @@ +import 'package:dartz/dartz.dart'; +import 'package:injectable/injectable.dart'; +import 'package:retry/retry.dart'; +import '../../core/error/exceptions.dart'; +import '../../core/error/failures.dart'; +import '../../core/network/network_info.dart'; +import '../../domain/entities/animation_metadata.dart'; +import '../datasources/local/secure_storage_service.dart'; +import '../datasources/remote/animation_api_client.dart'; +import '../datasources/remote/minio_client.dart'; + +abstract class AnimationRepository { + Future>> getAnimations({ + int page = 1, + int limit = 20, + }); + + Future> getAnimationById(String id); + + Future>> searchAnimations({ + required String query, + List? tags, + }); + + Future> downloadAnimation({ + required String objectName, + required String destinationPath, + ProgressCallback? onProgress, + }); + + Future> getAnimationStreamUrl({ + required String objectName, + Duration expiry = const Duration(hours: 1), + }); +} + +@LazySingleton(as: AnimationRepository) +class AnimationRepositoryImpl implements AnimationRepository { + final AnimationApiClient apiClient; + final MinioClientService minioClient; + final SecureStorageService secureStorage; + final NetworkInfo networkInfo; + + AnimationRepositoryImpl({ + required this.apiClient, + required this.minioClient, + required this.secureStorage, + required this.networkInfo, + }); + + Future _getAccessToken() async { + try { + return await secureStorage.getAccessToken(); + } catch (e) { + return null; + } + } + + @override + Future>> getAnimations({ + int page = 1, + int limit = 20, + }) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + final accessToken = await _getAccessToken(); + + final models = await retry( + () => apiClient.getAnimations( + accessToken: accessToken, + page: page, + limit: limit, + ), + retryIf: (e) => e is NetworkException, + maxAttempts: 3, + ); + + final entities = models.map((model) => model.toEntity()).toList(); + return Right(entities); + } on AuthException catch (e) { + return Left(AuthFailure(e.message, e.statusCode)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message, e.statusCode)); + } on ServerException catch (e) { + return Left(ServerFailure(e.message, e.statusCode)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future> getAnimationById(String id) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + final accessToken = await _getAccessToken(); + + final model = await retry( + () => apiClient.getAnimationById(id: id, accessToken: accessToken), + retryIf: (e) => e is NetworkException, + maxAttempts: 3, + ); + + return Right(model.toEntity()); + } on AuthException catch (e) { + return Left(AuthFailure(e.message, e.statusCode)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message, e.statusCode)); + } on ServerException catch (e) { + return Left(ServerFailure(e.message, e.statusCode)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future>> searchAnimations({ + required String query, + List? tags, + }) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + final accessToken = await _getAccessToken(); + + final models = await retry( + () => apiClient.searchAnimations( + query: query, + accessToken: accessToken, + tags: tags, + ), + retryIf: (e) => e is NetworkException, + maxAttempts: 3, + ); + + final entities = models.map((model) => model.toEntity()).toList(); + return Right(entities); + } on AuthException catch (e) { + return Left(AuthFailure(e.message, e.statusCode)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message, e.statusCode)); + } on ServerException catch (e) { + return Left(ServerFailure(e.message, e.statusCode)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future> downloadAnimation({ + required String objectName, + required String destinationPath, + ProgressCallback? onProgress, + }) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + await retry( + () => minioClient.downloadObject( + objectName: objectName, + destinationPath: destinationPath, + onProgress: onProgress, + ), + retryIf: (e) => e is StorageException && e.message.contains('timeout'), + maxAttempts: 3, + ); + + return const Right(null); + } on StorageException catch (e) { + return Left(StorageFailure(e.message)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future> getAnimationStreamUrl({ + required String objectName, + Duration expiry = const Duration(hours: 1), + }) async { + try { + final url = await minioClient.getPresignedUrl( + objectName: objectName, + expiry: expiry, + ); + + return Right(url); + } on StorageException catch (e) { + return Left(StorageFailure(e.message)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } +} diff --git a/lib/data/repositories/auth_repository.dart b/lib/data/repositories/auth_repository.dart new file mode 100644 index 0000000..a1f9ef9 --- /dev/null +++ b/lib/data/repositories/auth_repository.dart @@ -0,0 +1,168 @@ +import 'package:dartz/dartz.dart'; +import 'package:injectable/injectable.dart'; +import 'package:retry/retry.dart'; +import '../../core/error/exceptions.dart'; +import '../../core/error/failures.dart'; +import '../../core/network/network_info.dart'; +import '../../domain/entities/auth_token.dart'; +import '../datasources/local/secure_storage_service.dart'; +import '../datasources/remote/auth_api_client.dart'; + +abstract class AuthRepository { + Future> login({ + required String username, + required String password, + }); + + Future> refreshToken(); + + Future> logout(); + + Future> getCachedToken(); + + Future isAuthenticated(); +} + +@LazySingleton(as: AuthRepository) +class AuthRepositoryImpl implements AuthRepository { + final AuthApiClient apiClient; + final SecureStorageService secureStorage; + final NetworkInfo networkInfo; + + AuthRepositoryImpl({ + required this.apiClient, + required this.secureStorage, + required this.networkInfo, + }); + + @override + Future> login({ + required String username, + required String password, + }) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + final tokenModel = await retry( + () => apiClient.login(username: username, password: password), + retryIf: (e) => e is NetworkException, + maxAttempts: 3, + ); + + final token = tokenModel.toEntity(); + + await secureStorage.saveAccessToken(token.accessToken); + await secureStorage.saveRefreshToken(token.refreshToken); + await secureStorage.saveTokenExpiry(token.expiresAt); + await secureStorage.saveTokenType(token.tokenType); + + return Right(token); + } on AuthException catch (e) { + return Left(AuthFailure(e.message, e.statusCode)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message, e.statusCode)); + } on StorageException catch (e) { + return Left(StorageFailure(e.message)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future> refreshToken() async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + final refreshToken = await secureStorage.getRefreshToken(); + + if (refreshToken == null) { + return const Left(AuthFailure('No refresh token available')); + } + + final tokenModel = await retry( + () => apiClient.refreshToken(refreshToken), + retryIf: (e) => e is NetworkException, + maxAttempts: 3, + ); + + final token = tokenModel.toEntity(); + + await secureStorage.saveAccessToken(token.accessToken); + await secureStorage.saveRefreshToken(token.refreshToken); + await secureStorage.saveTokenExpiry(token.expiresAt); + await secureStorage.saveTokenType(token.tokenType); + + return Right(token); + } on AuthException catch (e) { + await secureStorage.clearTokens(); + return Left(AuthFailure(e.message, e.statusCode)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message, e.statusCode)); + } on StorageException catch (e) { + return Left(StorageFailure(e.message)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future> logout() async { + try { + final accessToken = await secureStorage.getAccessToken(); + + if (accessToken != null && await networkInfo.isConnected) { + try { + await apiClient.logout(accessToken); + } catch (e) { + } + } + + await secureStorage.clearTokens(); + return const Right(null); + } on StorageException catch (e) { + return Left(StorageFailure(e.message)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future> getCachedToken() async { + try { + final accessToken = await secureStorage.getAccessToken(); + final refreshToken = await secureStorage.getRefreshToken(); + final expiry = await secureStorage.getTokenExpiry(); + final tokenType = await secureStorage.getTokenType(); + + if (accessToken == null || refreshToken == null || expiry == null) { + return const Right(null); + } + + final token = AuthToken( + accessToken: accessToken, + refreshToken: refreshToken, + expiresAt: expiry, + tokenType: tokenType ?? 'Bearer', + ); + + return Right(token); + } on StorageException catch (e) { + return Left(StorageFailure(e.message)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future isAuthenticated() async { + try { + return await secureStorage.hasValidToken(); + } catch (e) { + return false; + } + } +} diff --git a/lib/data/repositories/marker_repository.dart b/lib/data/repositories/marker_repository.dart new file mode 100644 index 0000000..81f7c6e --- /dev/null +++ b/lib/data/repositories/marker_repository.dart @@ -0,0 +1,101 @@ +import 'package:dartz/dartz.dart'; +import 'package:injectable/injectable.dart'; +import 'package:retry/retry.dart'; +import '../../core/error/exceptions.dart'; +import '../../core/error/failures.dart'; +import '../../core/network/network_info.dart'; +import '../../domain/entities/marker_definition.dart'; +import '../datasources/local/secure_storage_service.dart'; +import '../datasources/remote/marker_api_client.dart'; + +abstract class MarkerRepository { + Future>> getMarkers({ + int page = 1, + int limit = 20, + }); + + Future> getMarkerById(String id); +} + +@LazySingleton(as: MarkerRepository) +class MarkerRepositoryImpl implements MarkerRepository { + final MarkerApiClient apiClient; + final SecureStorageService secureStorage; + final NetworkInfo networkInfo; + + MarkerRepositoryImpl({ + required this.apiClient, + required this.secureStorage, + required this.networkInfo, + }); + + Future _getAccessToken() async { + try { + return await secureStorage.getAccessToken(); + } catch (e) { + return null; + } + } + + @override + Future>> getMarkers({ + int page = 1, + int limit = 20, + }) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + final accessToken = await _getAccessToken(); + + final models = await retry( + () => apiClient.getMarkers( + accessToken: accessToken, + page: page, + limit: limit, + ), + retryIf: (e) => e is NetworkException, + maxAttempts: 3, + ); + + final entities = models.map((model) => model.toEntity()).toList(); + return Right(entities); + } on AuthException catch (e) { + return Left(AuthFailure(e.message, e.statusCode)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message, e.statusCode)); + } on ServerException catch (e) { + return Left(ServerFailure(e.message, e.statusCode)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future> getMarkerById(String id) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + final accessToken = await _getAccessToken(); + + final model = await retry( + () => apiClient.getMarkerById(id: id, accessToken: accessToken), + retryIf: (e) => e is NetworkException, + maxAttempts: 3, + ); + + return Right(model.toEntity()); + } on AuthException catch (e) { + return Left(AuthFailure(e.message, e.statusCode)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message, e.statusCode)); + } on ServerException catch (e) { + return Left(ServerFailure(e.message, e.statusCode)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } +} diff --git a/lib/data/repositories/user_asset_repository.dart b/lib/data/repositories/user_asset_repository.dart new file mode 100644 index 0000000..627650f --- /dev/null +++ b/lib/data/repositories/user_asset_repository.dart @@ -0,0 +1,112 @@ +import 'package:dartz/dartz.dart'; +import 'package:injectable/injectable.dart'; +import 'package:retry/retry.dart'; +import '../../core/error/exceptions.dart'; +import '../../core/error/failures.dart'; +import '../../core/network/network_info.dart'; +import '../../domain/entities/user_asset.dart'; +import '../datasources/local/secure_storage_service.dart'; +import '../datasources/remote/user_asset_api_client.dart'; + +abstract class UserAssetRepository { + Future>> getUserAssets({ + int page = 1, + int limit = 20, + String? assetType, + }); + + Future> getUserAssetById(String id); +} + +@LazySingleton(as: UserAssetRepository) +class UserAssetRepositoryImpl implements UserAssetRepository { + final UserAssetApiClient apiClient; + final SecureStorageService secureStorage; + final NetworkInfo networkInfo; + + UserAssetRepositoryImpl({ + required this.apiClient, + required this.secureStorage, + required this.networkInfo, + }); + + Future _getAccessToken() async { + try { + return await secureStorage.getAccessToken(); + } catch (e) { + return null; + } + } + + @override + Future>> getUserAssets({ + int page = 1, + int limit = 20, + String? assetType, + }) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + final accessToken = await _getAccessToken(); + + if (accessToken == null) { + return const Left(AuthFailure('No access token available')); + } + + final models = await retry( + () => apiClient.getUserAssets( + accessToken: accessToken, + page: page, + limit: limit, + assetType: assetType, + ), + retryIf: (e) => e is NetworkException, + maxAttempts: 3, + ); + + final entities = models.map((model) => model.toEntity()).toList(); + return Right(entities); + } on AuthException catch (e) { + return Left(AuthFailure(e.message, e.statusCode)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message, e.statusCode)); + } on ServerException catch (e) { + return Left(ServerFailure(e.message, e.statusCode)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } + + @override + Future> getUserAssetById(String id) async { + if (!await networkInfo.isConnected) { + return const Left(NetworkFailure('No internet connection')); + } + + try { + final accessToken = await _getAccessToken(); + + if (accessToken == null) { + return const Left(AuthFailure('No access token available')); + } + + final model = await retry( + () => apiClient.getUserAssetById(id: id, accessToken: accessToken), + retryIf: (e) => e is NetworkException, + maxAttempts: 3, + ); + + return Right(model.toEntity()); + } on AuthException catch (e) { + return Left(AuthFailure(e.message, e.statusCode)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message, e.statusCode)); + } on ServerException catch (e) { + return Left(ServerFailure(e.message, e.statusCode)); + } catch (e) { + return Left(UnknownFailure(e.toString())); + } + } +} diff --git a/lib/domain/entities/animation_metadata.dart b/lib/domain/entities/animation_metadata.dart new file mode 100644 index 0000000..62f918c --- /dev/null +++ b/lib/domain/entities/animation_metadata.dart @@ -0,0 +1,51 @@ +class AnimationMetadata { + final String id; + final String name; + final String description; + final String fileUrl; + final String thumbnailUrl; + final double fileSizeInMb; + final int durationInSeconds; + final List tags; + final DateTime createdAt; + final DateTime? updatedAt; + + const AnimationMetadata({ + required this.id, + required this.name, + required this.description, + required this.fileUrl, + required this.thumbnailUrl, + required this.fileSizeInMb, + required this.durationInSeconds, + required this.tags, + required this.createdAt, + this.updatedAt, + }); + + AnimationMetadata copyWith({ + String? id, + String? name, + String? description, + String? fileUrl, + String? thumbnailUrl, + double? fileSizeInMb, + int? durationInSeconds, + List? tags, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return AnimationMetadata( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + fileUrl: fileUrl ?? this.fileUrl, + thumbnailUrl: thumbnailUrl ?? this.thumbnailUrl, + fileSizeInMb: fileSizeInMb ?? this.fileSizeInMb, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + tags: tags ?? this.tags, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/lib/domain/entities/auth_token.dart b/lib/domain/entities/auth_token.dart new file mode 100644 index 0000000..c17420f --- /dev/null +++ b/lib/domain/entities/auth_token.dart @@ -0,0 +1,32 @@ +class AuthToken { + final String accessToken; + final String refreshToken; + final DateTime expiresAt; + final String tokenType; + + const AuthToken({ + required this.accessToken, + required this.refreshToken, + required this.expiresAt, + this.tokenType = 'Bearer', + }); + + bool get isExpired => DateTime.now().isAfter(expiresAt); + + bool get isExpiringSoon => + DateTime.now().add(const Duration(minutes: 5)).isAfter(expiresAt); + + AuthToken copyWith({ + String? accessToken, + String? refreshToken, + DateTime? expiresAt, + String? tokenType, + }) { + return AuthToken( + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + expiresAt: expiresAt ?? this.expiresAt, + tokenType: tokenType ?? this.tokenType, + ); + } +} diff --git a/lib/domain/entities/marker_definition.dart b/lib/domain/entities/marker_definition.dart new file mode 100644 index 0000000..ad42298 --- /dev/null +++ b/lib/domain/entities/marker_definition.dart @@ -0,0 +1,39 @@ +class MarkerDefinition { + final String id; + final String name; + final String imageUrl; + final double width; + final double height; + final String? animationId; + final Map metadata; + + const MarkerDefinition({ + required this.id, + required this.name, + required this.imageUrl, + required this.width, + required this.height, + this.animationId, + this.metadata = const {}, + }); + + MarkerDefinition copyWith({ + String? id, + String? name, + String? imageUrl, + double? width, + double? height, + String? animationId, + Map? metadata, + }) { + return MarkerDefinition( + id: id ?? this.id, + name: name ?? this.name, + imageUrl: imageUrl ?? this.imageUrl, + width: width ?? this.width, + height: height ?? this.height, + animationId: animationId ?? this.animationId, + metadata: metadata ?? this.metadata, + ); + } +} diff --git a/lib/domain/entities/user_asset.dart b/lib/domain/entities/user_asset.dart new file mode 100644 index 0000000..b5d46e2 --- /dev/null +++ b/lib/domain/entities/user_asset.dart @@ -0,0 +1,43 @@ +class UserAsset { + final String id; + final String userId; + final String assetType; + final String name; + final String fileUrl; + final double fileSizeInMb; + final DateTime uploadedAt; + final Map metadata; + + const UserAsset({ + required this.id, + required this.userId, + required this.assetType, + required this.name, + required this.fileUrl, + required this.fileSizeInMb, + required this.uploadedAt, + this.metadata = const {}, + }); + + UserAsset copyWith({ + String? id, + String? userId, + String? assetType, + String? name, + String? fileUrl, + double? fileSizeInMb, + DateTime? uploadedAt, + Map? metadata, + }) { + return UserAsset( + id: id ?? this.id, + userId: userId ?? this.userId, + assetType: assetType ?? this.assetType, + name: name ?? this.name, + fileUrl: fileUrl ?? this.fileUrl, + fileSizeInMb: fileSizeInMb ?? this.fileSizeInMb, + uploadedAt: uploadedAt ?? this.uploadedAt, + metadata: metadata ?? this.metadata, + ); + } +} diff --git a/lib/domain/services/ar_service.dart b/lib/domain/services/ar_service.dart new file mode 100644 index 0000000..6ecd147 --- /dev/null +++ b/lib/domain/services/ar_service.dart @@ -0,0 +1,197 @@ +import 'package:dartz/dartz.dart'; +import 'package:injectable/injectable.dart'; +import '../../core/error/failures.dart'; +import '../../data/datasources/remote/minio_client.dart'; +import '../../data/repositories/animation_repository.dart'; +import '../../data/repositories/auth_repository.dart'; +import '../../data/repositories/marker_repository.dart'; +import '../../data/repositories/user_asset_repository.dart'; +import '../entities/animation_metadata.dart'; +import '../entities/auth_token.dart'; +import '../entities/marker_definition.dart'; +import '../entities/user_asset.dart'; + +@lazySingleton +class ArService { + final AuthRepository authRepository; + final AnimationRepository animationRepository; + final MarkerRepository markerRepository; + final UserAssetRepository userAssetRepository; + + ArService({ + required this.authRepository, + required this.animationRepository, + required this.markerRepository, + required this.userAssetRepository, + }); + + Future> authenticate({ + required String username, + required String password, + }) async { + return await authRepository.login(username: username, password: password); + } + + Future> refreshAuthentication() async { + return await authRepository.refreshToken(); + } + + Future isAuthenticated() async { + return await authRepository.isAuthenticated(); + } + + Future> getCurrentToken() async { + return await authRepository.getCachedToken(); + } + + Future> signOut() async { + return await authRepository.logout(); + } + + Future>> fetchAnimations({ + int page = 1, + int limit = 20, + }) async { + final cachedTokenResult = await authRepository.getCachedToken(); + + return cachedTokenResult.fold( + (failure) => Left(failure), + (token) async { + if (token != null && token.isExpiringSoon) { + await authRepository.refreshToken(); + } + + return await animationRepository.getAnimations(page: page, limit: limit); + }, + ); + } + + Future> fetchAnimationById(String id) async { + final cachedTokenResult = await authRepository.getCachedToken(); + + return cachedTokenResult.fold( + (failure) => Left(failure), + (token) async { + if (token != null && token.isExpiringSoon) { + await authRepository.refreshToken(); + } + + return await animationRepository.getAnimationById(id); + }, + ); + } + + Future>> searchAnimations({ + required String query, + List? tags, + }) async { + final cachedTokenResult = await authRepository.getCachedToken(); + + return cachedTokenResult.fold( + (failure) => Left(failure), + (token) async { + if (token != null && token.isExpiringSoon) { + await authRepository.refreshToken(); + } + + return await animationRepository.searchAnimations( + query: query, + tags: tags, + ); + }, + ); + } + + Future> downloadAnimation({ + required String objectName, + required String destinationPath, + ProgressCallback? onProgress, + }) async { + return await animationRepository.downloadAnimation( + objectName: objectName, + destinationPath: destinationPath, + onProgress: onProgress, + ); + } + + Future> getAnimationStreamUrl({ + required String objectName, + Duration expiry = const Duration(hours: 1), + }) async { + return await animationRepository.getAnimationStreamUrl( + objectName: objectName, + expiry: expiry, + ); + } + + Future>> fetchMarkers({ + int page = 1, + int limit = 20, + }) async { + final cachedTokenResult = await authRepository.getCachedToken(); + + return cachedTokenResult.fold( + (failure) => Left(failure), + (token) async { + if (token != null && token.isExpiringSoon) { + await authRepository.refreshToken(); + } + + return await markerRepository.getMarkers(page: page, limit: limit); + }, + ); + } + + Future> fetchMarkerById(String id) async { + final cachedTokenResult = await authRepository.getCachedToken(); + + return cachedTokenResult.fold( + (failure) => Left(failure), + (token) async { + if (token != null && token.isExpiringSoon) { + await authRepository.refreshToken(); + } + + return await markerRepository.getMarkerById(id); + }, + ); + } + + Future>> fetchUserAssets({ + int page = 1, + int limit = 20, + String? assetType, + }) async { + final cachedTokenResult = await authRepository.getCachedToken(); + + return cachedTokenResult.fold( + (failure) => Left(failure), + (token) async { + if (token != null && token.isExpiringSoon) { + await authRepository.refreshToken(); + } + + return await userAssetRepository.getUserAssets( + page: page, + limit: limit, + assetType: assetType, + ); + }, + ); + } + + Future> fetchUserAssetById(String id) async { + final cachedTokenResult = await authRepository.getCachedToken(); + + return cachedTokenResult.fold( + (failure) => Left(failure), + (token) async { + if (token != null && token.isExpiringSoon) { + await authRepository.refreshToken(); + } + + return await userAssetRepository.getUserAssetById(id); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 05dd83a..acd481e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,10 @@ dependencies: dio: ^5.4.0 json_annotation: ^4.8.1 flutter_cache_manager: ^3.3.1 + minio: ^4.0.4 + connectivity_plus: ^5.0.2 + retry: ^3.1.2 + dartz: ^0.10.1 # Storage & Security flutter_secure_storage: ^9.0.0 @@ -52,6 +56,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^3.0.0 + mockito: ^5.4.4 # Code Generation build_runner: ^2.4.7 diff --git a/test/unit/core/network/network_info_test.dart b/test/unit/core/network/network_info_test.dart new file mode 100644 index 0000000..273d928 --- /dev/null +++ b/test/unit/core/network/network_info_test.dart @@ -0,0 +1,67 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_ar_app/core/network/network_info.dart'; + +import 'network_info_test.mocks.dart'; + +@GenerateMocks([Connectivity]) +void main() { + late NetworkInfoImpl networkInfo; + late MockConnectivity mockConnectivity; + + setUp(() { + mockConnectivity = MockConnectivity(); + networkInfo = NetworkInfoImpl(mockConnectivity); + }); + + group('isConnected', () { + test('should return true when connected to wifi', () async { + when(mockConnectivity.checkConnectivity()) + .thenAnswer((_) async => ConnectivityResult.wifi); + + final result = await networkInfo.isConnected; + + expect(result, true); + }); + + test('should return true when connected to mobile', () async { + when(mockConnectivity.checkConnectivity()) + .thenAnswer((_) async => ConnectivityResult.mobile); + + final result = await networkInfo.isConnected; + + expect(result, true); + }); + + test('should return false when not connected', () async { + when(mockConnectivity.checkConnectivity()) + .thenAnswer((_) async => ConnectivityResult.none); + + final result = await networkInfo.isConnected; + + expect(result, false); + }); + }); + + group('onConnectivityChanged', () { + test('should emit true when connection is established', () async { + when(mockConnectivity.onConnectivityChanged) + .thenAnswer((_) => Stream.value(ConnectivityResult.wifi)); + + final stream = networkInfo.onConnectivityChanged; + + expect(await stream.first, true); + }); + + test('should emit false when connection is lost', () async { + when(mockConnectivity.onConnectivityChanged) + .thenAnswer((_) => Stream.value(ConnectivityResult.none)); + + final stream = networkInfo.onConnectivityChanged; + + expect(await stream.first, false); + }); + }); +} diff --git a/test/unit/core/network/network_info_test.mocks.dart b/test/unit/core/network/network_info_test.mocks.dart new file mode 100644 index 0000000..5e73c37 --- /dev/null +++ b/test/unit/core/network/network_info_test.mocks.dart @@ -0,0 +1,26 @@ +// Mocks generated by Mockito +// Do not manually edit this file. + +import 'dart:async' as _i3; +import 'package:connectivity_plus/connectivity_plus.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +class MockConnectivity extends _i1.Mock implements _i2.Connectivity { + MockConnectivity() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.ConnectivityResult> checkConnectivity() => + (super.noSuchMethod( + Invocation.method(#checkConnectivity, []), + returnValue: _i3.Future<_i2.ConnectivityResult>.value(_i2.ConnectivityResult.none), + ) as _i3.Future<_i2.ConnectivityResult>); + + @override + _i3.Stream<_i2.ConnectivityResult> get onConnectivityChanged => + (super.noSuchMethod( + Invocation.getter(#onConnectivityChanged), + returnValue: _i3.Stream<_i2.ConnectivityResult>.empty(), + ) as _i3.Stream<_i2.ConnectivityResult>); +} diff --git a/test/unit/data/datasources/auth_api_client_test.dart b/test/unit/data/datasources/auth_api_client_test.dart new file mode 100644 index 0000000..e35e766 --- /dev/null +++ b/test/unit/data/datasources/auth_api_client_test.dart @@ -0,0 +1,183 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_ar_app/core/error/exceptions.dart'; +import 'package:flutter_ar_app/data/datasources/remote/auth_api_client.dart'; +import 'package:flutter_ar_app/data/models/auth_token_model.dart'; + +import 'auth_api_client_test.mocks.dart'; + +@GenerateMocks([Dio]) +void main() { + late AuthApiClient apiClient; + late MockDio mockDio; + + setUp(() { + mockDio = MockDio(); + apiClient = AuthApiClient(mockDio); + }); + + group('login', () { + const username = 'testuser'; + const password = 'testpass'; + + test('should return AuthTokenModel when login is successful', () async { + final responseData = { + 'access_token': 'access_token', + 'refresh_token': 'refresh_token', + 'expires_in': 3600, + 'token_type': 'Bearer', + }; + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + )); + + final result = await apiClient.login( + username: username, + password: password, + ); + + expect(result, isA()); + expect(result.accessToken, 'access_token'); + expect(result.refreshToken, 'refresh_token'); + expect(result.expiresIn, 3600); + }); + + test('should throw AuthException when credentials are invalid', () async { + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenThrow(DioException( + response: Response( + statusCode: 401, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + type: DioExceptionType.badResponse, + )); + + expect( + () => apiClient.login(username: username, password: password), + throwsA(isA()), + ); + }); + + test('should throw NetworkException when connection times out', () async { + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenThrow(DioException( + requestOptions: RequestOptions(path: ''), + type: DioExceptionType.connectionTimeout, + )); + + expect( + () => apiClient.login(username: username, password: password), + throwsA(isA()), + ); + }); + + test('should throw NetworkException when there is no connection', () async { + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenThrow(DioException( + requestOptions: RequestOptions(path: ''), + type: DioExceptionType.connectionError, + )); + + expect( + () => apiClient.login(username: username, password: password), + throwsA(isA()), + ); + }); + }); + + group('refreshToken', () { + const refreshToken = 'refresh_token'; + + test('should return AuthTokenModel when refresh is successful', () async { + final responseData = { + 'access_token': 'new_access_token', + 'refresh_token': 'new_refresh_token', + 'expires_in': 3600, + 'token_type': 'Bearer', + }; + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: responseData, + statusCode: 200, + requestOptions: RequestOptions(path: ''), + )); + + final result = await apiClient.refreshToken(refreshToken); + + expect(result, isA()); + expect(result.accessToken, 'new_access_token'); + expect(result.refreshToken, 'new_refresh_token'); + }); + + test('should throw AuthException when refresh token is invalid', () async { + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenThrow(DioException( + response: Response( + statusCode: 401, + requestOptions: RequestOptions(path: ''), + ), + requestOptions: RequestOptions(path: ''), + type: DioExceptionType.badResponse, + )); + + expect( + () => apiClient.refreshToken(refreshToken), + throwsA(isA()), + ); + }); + }); + + group('logout', () { + const accessToken = 'access_token'; + + test('should complete successfully when logout succeeds', () async { + when(mockDio.post( + any, + options: anyNamed('options'), + )).thenAnswer((_) async => Response( + statusCode: 200, + requestOptions: RequestOptions(path: ''), + )); + + expect( + () => apiClient.logout(accessToken), + returnsNormally, + ); + }); + + test('should throw NetworkException when connection fails', () async { + when(mockDio.post( + any, + options: anyNamed('options'), + )).thenThrow(DioException( + requestOptions: RequestOptions(path: ''), + type: DioExceptionType.connectionError, + )); + + expect( + () => apiClient.logout(accessToken), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/unit/data/datasources/auth_api_client_test.mocks.dart b/test/unit/data/datasources/auth_api_client_test.mocks.dart new file mode 100644 index 0000000..b29816a --- /dev/null +++ b/test/unit/data/datasources/auth_api_client_test.mocks.dart @@ -0,0 +1,62 @@ +// Mocks generated by Mockito +// Do not manually edit this file. + +import 'dart:async' as _i3; +import 'package:dio/dio.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +class MockDio extends _i1.Mock implements _i2.Dio { + MockDio() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future<_i2.Response> post( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i2.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i3.Future<_i2.Response>.value(_FakeResponse_0( + this, + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + )), + ) as _i3.Future<_i2.Response>); +} + +class _FakeResponse_0 extends _i1.SmartFake implements _i2.Response { + _FakeResponse_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} diff --git a/test/unit/data/datasources/secure_storage_service_test.dart b/test/unit/data/datasources/secure_storage_service_test.dart new file mode 100644 index 0000000..e608e87 --- /dev/null +++ b/test/unit/data/datasources/secure_storage_service_test.dart @@ -0,0 +1,188 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_ar_app/core/error/exceptions.dart'; +import 'package:flutter_ar_app/data/datasources/local/secure_storage_service.dart'; + +import 'secure_storage_service_test.mocks.dart'; + +@GenerateMocks([FlutterSecureStorage]) +void main() { + late SecureStorageService service; + late MockFlutterSecureStorage mockSecureStorage; + + setUp(() { + mockSecureStorage = MockFlutterSecureStorage(); + service = SecureStorageService(mockSecureStorage); + }); + + group('saveAccessToken', () { + test('should save access token successfully', () async { + const token = 'access_token'; + when(mockSecureStorage.write( + key: anyNamed('key'), + value: anyNamed('value'), + )).thenAnswer((_) async => {}); + + await service.saveAccessToken(token); + + verify(mockSecureStorage.write( + key: 'access_token', + value: token, + )).called(1); + }); + + test('should throw StorageException when save fails', () async { + const token = 'access_token'; + when(mockSecureStorage.write( + key: anyNamed('key'), + value: anyNamed('value'), + )).thenThrow(Exception('Storage error')); + + expect( + () => service.saveAccessToken(token), + throwsA(isA()), + ); + }); + }); + + group('getAccessToken', () { + test('should return access token when it exists', () async { + const token = 'access_token'; + when(mockSecureStorage.read(key: anyNamed('key'))) + .thenAnswer((_) async => token); + + final result = await service.getAccessToken(); + + expect(result, token); + verify(mockSecureStorage.read(key: 'access_token')).called(1); + }); + + test('should return null when token does not exist', () async { + when(mockSecureStorage.read(key: anyNamed('key'))) + .thenAnswer((_) async => null); + + final result = await service.getAccessToken(); + + expect(result, null); + }); + }); + + group('saveRefreshToken', () { + test('should save refresh token successfully', () async { + const token = 'refresh_token'; + when(mockSecureStorage.write( + key: anyNamed('key'), + value: anyNamed('value'), + )).thenAnswer((_) async => {}); + + await service.saveRefreshToken(token); + + verify(mockSecureStorage.write( + key: 'refresh_token', + value: token, + )).called(1); + }); + }); + + group('getRefreshToken', () { + test('should return refresh token when it exists', () async { + const token = 'refresh_token'; + when(mockSecureStorage.read(key: anyNamed('key'))) + .thenAnswer((_) async => token); + + final result = await service.getRefreshToken(); + + expect(result, token); + }); + }); + + group('saveTokenExpiry', () { + test('should save token expiry successfully', () async { + final expiry = DateTime.now().add(const Duration(hours: 1)); + when(mockSecureStorage.write( + key: anyNamed('key'), + value: anyNamed('value'), + )).thenAnswer((_) async => {}); + + await service.saveTokenExpiry(expiry); + + verify(mockSecureStorage.write( + key: 'token_expiry', + value: expiry.toIso8601String(), + )).called(1); + }); + }); + + group('getTokenExpiry', () { + test('should return token expiry when it exists', () async { + final expiry = DateTime.now().add(const Duration(hours: 1)); + when(mockSecureStorage.read(key: anyNamed('key'))) + .thenAnswer((_) async => expiry.toIso8601String()); + + final result = await service.getTokenExpiry(); + + expect(result, isNotNull); + expect(result!.difference(expiry).inSeconds, lessThan(1)); + }); + + test('should return null when expiry does not exist', () async { + when(mockSecureStorage.read(key: anyNamed('key'))) + .thenAnswer((_) async => null); + + final result = await service.getTokenExpiry(); + + expect(result, null); + }); + }); + + group('clearTokens', () { + test('should clear all tokens successfully', () async { + when(mockSecureStorage.delete(key: anyNamed('key'))) + .thenAnswer((_) async => {}); + + await service.clearTokens(); + + verify(mockSecureStorage.delete(key: 'access_token')).called(1); + verify(mockSecureStorage.delete(key: 'refresh_token')).called(1); + verify(mockSecureStorage.delete(key: 'token_expiry')).called(1); + verify(mockSecureStorage.delete(key: 'token_type')).called(1); + }); + }); + + group('hasValidToken', () { + test('should return true when valid token exists', () async { + final expiry = DateTime.now().add(const Duration(hours: 1)); + when(mockSecureStorage.read(key: 'access_token')) + .thenAnswer((_) async => 'token'); + when(mockSecureStorage.read(key: 'token_expiry')) + .thenAnswer((_) async => expiry.toIso8601String()); + + final result = await service.hasValidToken(); + + expect(result, true); + }); + + test('should return false when token is expired', () async { + final expiry = DateTime.now().subtract(const Duration(hours: 1)); + when(mockSecureStorage.read(key: 'access_token')) + .thenAnswer((_) async => 'token'); + when(mockSecureStorage.read(key: 'token_expiry')) + .thenAnswer((_) async => expiry.toIso8601String()); + + final result = await service.hasValidToken(); + + expect(result, false); + }); + + test('should return false when token does not exist', () async { + when(mockSecureStorage.read(key: 'access_token')) + .thenAnswer((_) async => null); + + final result = await service.hasValidToken(); + + expect(result, false); + }); + }); +} diff --git a/test/unit/data/datasources/secure_storage_service_test.mocks.dart b/test/unit/data/datasources/secure_storage_service_test.mocks.dart new file mode 100644 index 0000000..0c16636 --- /dev/null +++ b/test/unit/data/datasources/secure_storage_service_test.mocks.dart @@ -0,0 +1,97 @@ +// Mocks generated by Mockito +// Do not manually edit this file. + +import 'dart:async' as _i3; +import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +class MockFlutterSecureStorage extends _i1.Mock implements _i2.FlutterSecureStorage { + MockFlutterSecureStorage() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future write({ + required String? key, + required String? value, + _i2.IOSOptions? iOptions, + _i2.AndroidOptions? aOptions, + _i2.LinuxOptions? lOptions, + _i2.WebOptions? webOptions, + _i2.MacOsOptions? mOptions, + _i2.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method( + #write, + [], + { + #key: key, + #value: value, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }, + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); + + @override + _i3.Future read({ + required String? key, + _i2.IOSOptions? iOptions, + _i2.AndroidOptions? aOptions, + _i2.LinuxOptions? lOptions, + _i2.WebOptions? webOptions, + _i2.MacOsOptions? mOptions, + _i2.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method( + #read, + [], + { + #key: key, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }, + ), + returnValue: _i3.Future.value(), + ) as _i3.Future); + + @override + _i3.Future delete({ + required String? key, + _i2.IOSOptions? iOptions, + _i2.AndroidOptions? aOptions, + _i2.LinuxOptions? lOptions, + _i2.WebOptions? webOptions, + _i2.MacOsOptions? mOptions, + _i2.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [], + { + #key: key, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }, + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); +} diff --git a/test/unit/data/repositories/animation_repository_test.dart b/test/unit/data/repositories/animation_repository_test.dart new file mode 100644 index 0000000..e93d500 --- /dev/null +++ b/test/unit/data/repositories/animation_repository_test.dart @@ -0,0 +1,287 @@ +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_ar_app/core/error/exceptions.dart'; +import 'package:flutter_ar_app/core/error/failures.dart'; +import 'package:flutter_ar_app/core/network/network_info.dart'; +import 'package:flutter_ar_app/data/datasources/local/secure_storage_service.dart'; +import 'package:flutter_ar_app/data/datasources/remote/animation_api_client.dart'; +import 'package:flutter_ar_app/data/datasources/remote/minio_client.dart'; +import 'package:flutter_ar_app/data/models/animation_metadata_model.dart'; +import 'package:flutter_ar_app/data/repositories/animation_repository.dart'; + +import 'animation_repository_test.mocks.dart'; + +@GenerateMocks([ + AnimationApiClient, + MinioClientService, + SecureStorageService, + NetworkInfo, +]) +void main() { + late AnimationRepositoryImpl repository; + late MockAnimationApiClient mockApiClient; + late MockMinioClientService mockMinioClient; + late MockSecureStorageService mockSecureStorage; + late MockNetworkInfo mockNetworkInfo; + + setUp(() { + mockApiClient = MockAnimationApiClient(); + mockMinioClient = MockMinioClientService(); + mockSecureStorage = MockSecureStorageService(); + mockNetworkInfo = MockNetworkInfo(); + + repository = AnimationRepositoryImpl( + apiClient: mockApiClient, + minioClient: mockMinioClient, + secureStorage: mockSecureStorage, + networkInfo: mockNetworkInfo, + ); + }); + + group('getAnimations', () { + final animationModels = [ + const AnimationMetadataModel( + id: '1', + name: 'Animation 1', + description: 'Description 1', + fileUrl: 'file1.mp4', + thumbnailUrl: 'thumb1.jpg', + fileSizeInMb: 10.5, + durationInSeconds: 30, + tags: ['tag1', 'tag2'], + createdAt: '2024-01-01T00:00:00Z', + ), + ]; + + test('should return list of AnimationMetadata when call is successful', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockSecureStorage.getAccessToken()) + .thenAnswer((_) async => 'token'); + when(mockApiClient.getAnimations( + accessToken: anyNamed('accessToken'), + page: anyNamed('page'), + limit: anyNamed('limit'), + )).thenAnswer((_) async => animationModels); + + final result = await repository.getAnimations(); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (animations) { + expect(animations.length, 1); + expect(animations[0].id, '1'); + expect(animations[0].name, 'Animation 1'); + }, + ); + }); + + test('should return NetworkFailure when there is no internet', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => false); + + final result = await repository.getAnimations(); + + expect(result.isLeft(), true); + result.fold( + (failure) { + expect(failure, isA()); + expect(failure.message, 'No internet connection'); + }, + (_) => fail('Should not return success'), + ); + }); + + test('should return ServerFailure when server returns error', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockSecureStorage.getAccessToken()) + .thenAnswer((_) async => 'token'); + when(mockApiClient.getAnimations( + accessToken: anyNamed('accessToken'), + page: anyNamed('page'), + limit: anyNamed('limit'), + )).thenThrow(ServerException('Server error', 500)); + + final result = await repository.getAnimations(); + + expect(result.isLeft(), true); + result.fold( + (failure) { + expect(failure, isA()); + expect(failure.statusCode, 500); + }, + (_) => fail('Should not return success'), + ); + }); + }); + + group('getAnimationById', () { + const animationModel = AnimationMetadataModel( + id: '1', + name: 'Animation 1', + description: 'Description 1', + fileUrl: 'file1.mp4', + thumbnailUrl: 'thumb1.jpg', + fileSizeInMb: 10.5, + durationInSeconds: 30, + tags: ['tag1', 'tag2'], + createdAt: '2024-01-01T00:00:00Z', + ); + + test('should return AnimationMetadata when call is successful', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockSecureStorage.getAccessToken()) + .thenAnswer((_) async => 'token'); + when(mockApiClient.getAnimationById( + id: anyNamed('id'), + accessToken: anyNamed('accessToken'), + )).thenAnswer((_) async => animationModel); + + final result = await repository.getAnimationById('1'); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (animation) { + expect(animation.id, '1'); + expect(animation.name, 'Animation 1'); + }, + ); + }); + }); + + group('searchAnimations', () { + final animationModels = [ + const AnimationMetadataModel( + id: '1', + name: 'Animation 1', + description: 'Description 1', + fileUrl: 'file1.mp4', + thumbnailUrl: 'thumb1.jpg', + fileSizeInMb: 10.5, + durationInSeconds: 30, + tags: ['tag1', 'tag2'], + createdAt: '2024-01-01T00:00:00Z', + ), + ]; + + test('should return list of AnimationMetadata when search is successful', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockSecureStorage.getAccessToken()) + .thenAnswer((_) async => 'token'); + when(mockApiClient.searchAnimations( + query: anyNamed('query'), + accessToken: anyNamed('accessToken'), + tags: anyNamed('tags'), + )).thenAnswer((_) async => animationModels); + + final result = await repository.searchAnimations(query: 'test'); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (animations) { + expect(animations.length, 1); + expect(animations[0].id, '1'); + }, + ); + }); + }); + + group('downloadAnimation', () { + test('should successfully download animation', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockMinioClient.downloadObject( + objectName: anyNamed('objectName'), + destinationPath: anyNamed('destinationPath'), + onProgress: anyNamed('onProgress'), + )).thenAnswer((_) async => {}); + + final result = await repository.downloadAnimation( + objectName: 'animation.mp4', + destinationPath: '/path/to/save', + ); + + expect(result.isRight(), true); + }); + + test('should return NetworkFailure when there is no internet', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => false); + + final result = await repository.downloadAnimation( + objectName: 'animation.mp4', + destinationPath: '/path/to/save', + ); + + expect(result.isLeft(), true); + result.fold( + (failure) { + expect(failure, isA()); + }, + (_) => fail('Should not return success'), + ); + }); + + test('should return StorageFailure when download fails', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockMinioClient.downloadObject( + objectName: anyNamed('objectName'), + destinationPath: anyNamed('destinationPath'), + onProgress: anyNamed('onProgress'), + )).thenThrow(StorageException('Download failed')); + + final result = await repository.downloadAnimation( + objectName: 'animation.mp4', + destinationPath: '/path/to/save', + ); + + expect(result.isLeft(), true); + result.fold( + (failure) { + expect(failure, isA()); + }, + (_) => fail('Should not return success'), + ); + }); + }); + + group('getAnimationStreamUrl', () { + test('should return presigned URL when successful', () async { + const url = 'https://minio.example.com/presigned-url'; + when(mockMinioClient.getPresignedUrl( + objectName: anyNamed('objectName'), + expiry: anyNamed('expiry'), + )).thenAnswer((_) async => url); + + final result = await repository.getAnimationStreamUrl( + objectName: 'animation.mp4', + ); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (resultUrl) => expect(resultUrl, url), + ); + }); + + test('should return StorageFailure when URL generation fails', () async { + when(mockMinioClient.getPresignedUrl( + objectName: anyNamed('objectName'), + expiry: anyNamed('expiry'), + )).thenThrow(StorageException('Failed to generate URL')); + + final result = await repository.getAnimationStreamUrl( + objectName: 'animation.mp4', + ); + + expect(result.isLeft(), true); + result.fold( + (failure) { + expect(failure, isA()); + }, + (_) => fail('Should not return success'), + ); + }); + }); +} diff --git a/test/unit/data/repositories/animation_repository_test.mocks.dart b/test/unit/data/repositories/animation_repository_test.mocks.dart new file mode 100644 index 0000000..d8798fa --- /dev/null +++ b/test/unit/data/repositories/animation_repository_test.mocks.dart @@ -0,0 +1,138 @@ +// Mocks generated by Mockito +// Do not manually edit this file. + +import 'dart:async' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:flutter_ar_app/data/datasources/remote/animation_api_client.dart' as _i3; +import 'package:flutter_ar_app/data/datasources/remote/minio_client.dart' as _i4; +import 'package:flutter_ar_app/data/datasources/local/secure_storage_service.dart' as _i5; +import 'package:flutter_ar_app/core/network/network_info.dart' as _i6; +import 'package:flutter_ar_app/data/models/animation_metadata_model.dart' as _i7; + +class MockAnimationApiClient extends _i1.Mock implements _i3.AnimationApiClient { + MockAnimationApiClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Future> getAnimations({ + String? accessToken, + int? page, + int? limit, + }) => + (super.noSuchMethod( + Invocation.method(#getAnimations, [], { + #accessToken: accessToken, + #page: page, + #limit: limit, + }), + returnValue: _i2.Future>.value([]), + ) as _i2.Future>); + + @override + _i2.Future<_i7.AnimationMetadataModel> getAnimationById({ + required String? id, + String? accessToken, + }) => + (super.noSuchMethod( + Invocation.method(#getAnimationById, [], { + #id: id, + #accessToken: accessToken, + }), + returnValue: _i2.Future<_i7.AnimationMetadataModel>.value( + _FakeAnimationMetadataModel_0(this, Invocation.method(#getAnimationById, [], {#id: id, #accessToken: accessToken})), + ), + ) as _i2.Future<_i7.AnimationMetadataModel>); + + @override + _i2.Future> searchAnimations({ + required String? query, + String? accessToken, + List? tags, + }) => + (super.noSuchMethod( + Invocation.method(#searchAnimations, [], { + #query: query, + #accessToken: accessToken, + #tags: tags, + }), + returnValue: _i2.Future>.value([]), + ) as _i2.Future>); +} + +class MockMinioClientService extends _i1.Mock implements _i4.MinioClientService { + MockMinioClientService() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Future downloadObject({ + required String? objectName, + required String? destinationPath, + String? bucket, + _i4.ProgressCallback? onProgress, + }) => + (super.noSuchMethod( + Invocation.method(#downloadObject, [], { + #objectName: objectName, + #destinationPath: destinationPath, + #bucket: bucket, + #onProgress: onProgress, + }), + returnValue: _i2.Future.value(), + returnValueForMissingStub: _i2.Future.value(), + ) as _i2.Future); + + @override + _i2.Future getPresignedUrl({ + required String? objectName, + String? bucket, + Duration? expiry, + }) => + (super.noSuchMethod( + Invocation.method(#getPresignedUrl, [], { + #objectName: objectName, + #bucket: bucket, + #expiry: expiry, + }), + returnValue: _i2.Future.value(''), + ) as _i2.Future); +} + +class MockSecureStorageService extends _i1.Mock implements _i5.SecureStorageService { + MockSecureStorageService() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Future getAccessToken() => + (super.noSuchMethod( + Invocation.method(#getAccessToken, []), + returnValue: _i2.Future.value(), + ) as _i2.Future); +} + +class MockNetworkInfo extends _i1.Mock implements _i6.NetworkInfo { + MockNetworkInfo() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Future get isConnected => + (super.noSuchMethod( + Invocation.getter(#isConnected), + returnValue: _i2.Future.value(false), + ) as _i2.Future); + + @override + _i2.Stream get onConnectivityChanged => + (super.noSuchMethod( + Invocation.getter(#onConnectivityChanged), + returnValue: _i2.Stream.empty(), + ) as _i2.Stream); +} + +class _FakeAnimationMetadataModel_0 extends _i1.SmartFake implements _i7.AnimationMetadataModel { + _FakeAnimationMetadataModel_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} diff --git a/test/unit/data/repositories/auth_repository_test.dart b/test/unit/data/repositories/auth_repository_test.dart new file mode 100644 index 0000000..9bd2acd --- /dev/null +++ b/test/unit/data/repositories/auth_repository_test.dart @@ -0,0 +1,272 @@ +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_ar_app/core/error/exceptions.dart'; +import 'package:flutter_ar_app/core/error/failures.dart'; +import 'package:flutter_ar_app/core/network/network_info.dart'; +import 'package:flutter_ar_app/data/datasources/local/secure_storage_service.dart'; +import 'package:flutter_ar_app/data/datasources/remote/auth_api_client.dart'; +import 'package:flutter_ar_app/data/models/auth_token_model.dart'; +import 'package:flutter_ar_app/data/repositories/auth_repository.dart'; +import 'package:flutter_ar_app/domain/entities/auth_token.dart'; + +import 'auth_repository_test.mocks.dart'; + +@GenerateMocks([ + AuthApiClient, + SecureStorageService, + NetworkInfo, +]) +void main() { + late AuthRepositoryImpl repository; + late MockAuthApiClient mockApiClient; + late MockSecureStorageService mockSecureStorage; + late MockNetworkInfo mockNetworkInfo; + + setUp(() { + mockApiClient = MockAuthApiClient(); + mockSecureStorage = MockSecureStorageService(); + mockNetworkInfo = MockNetworkInfo(); + + repository = AuthRepositoryImpl( + apiClient: mockApiClient, + secureStorage: mockSecureStorage, + networkInfo: mockNetworkInfo, + ); + }); + + group('login', () { + const username = 'testuser'; + const password = 'testpass'; + final tokenModel = AuthTokenModel( + accessToken: 'access_token', + refreshToken: 'refresh_token', + expiresIn: 3600, + ); + + test('should return AuthToken when login is successful', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockApiClient.login( + username: username, + password: password, + )).thenAnswer((_) async => tokenModel); + when(mockSecureStorage.saveAccessToken(any)).thenAnswer((_) async => {}); + when(mockSecureStorage.saveRefreshToken(any)).thenAnswer((_) async => {}); + when(mockSecureStorage.saveTokenExpiry(any)).thenAnswer((_) async => {}); + when(mockSecureStorage.saveTokenType(any)).thenAnswer((_) async => {}); + + final result = await repository.login( + username: username, + password: password, + ); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (token) { + expect(token.accessToken, 'access_token'); + expect(token.refreshToken, 'refresh_token'); + }, + ); + + verify(mockSecureStorage.saveAccessToken('access_token')).called(1); + verify(mockSecureStorage.saveRefreshToken('refresh_token')).called(1); + }); + + test('should return NetworkFailure when there is no internet connection', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => false); + + final result = await repository.login( + username: username, + password: password, + ); + + expect(result.isLeft(), true); + result.fold( + (failure) { + expect(failure, isA()); + expect(failure.message, 'No internet connection'); + }, + (_) => fail('Should not return success'), + ); + + verifyNever(mockApiClient.login( + username: anyNamed('username'), + password: anyNamed('password'), + )); + }); + + test('should return AuthFailure when credentials are invalid', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockApiClient.login( + username: username, + password: password, + )).thenThrow(AuthException('Invalid credentials', 401)); + + final result = await repository.login( + username: username, + password: password, + ); + + expect(result.isLeft(), true); + result.fold( + (failure) { + expect(failure, isA()); + expect(failure.message, 'Invalid credentials'); + expect(failure.statusCode, 401); + }, + (_) => fail('Should not return success'), + ); + }); + }); + + group('refreshToken', () { + const refreshToken = 'refresh_token'; + final tokenModel = AuthTokenModel( + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + expiresIn: 3600, + ); + + test('should return new AuthToken when refresh is successful', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockSecureStorage.getRefreshToken()) + .thenAnswer((_) async => refreshToken); + when(mockApiClient.refreshToken(refreshToken)) + .thenAnswer((_) async => tokenModel); + when(mockSecureStorage.saveAccessToken(any)).thenAnswer((_) async => {}); + when(mockSecureStorage.saveRefreshToken(any)).thenAnswer((_) async => {}); + when(mockSecureStorage.saveTokenExpiry(any)).thenAnswer((_) async => {}); + when(mockSecureStorage.saveTokenType(any)).thenAnswer((_) async => {}); + + final result = await repository.refreshToken(); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (token) { + expect(token.accessToken, 'new_access_token'); + expect(token.refreshToken, 'new_refresh_token'); + }, + ); + }); + + test('should return AuthFailure when refresh token is not available', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockSecureStorage.getRefreshToken()).thenAnswer((_) async => null); + + final result = await repository.refreshToken(); + + expect(result.isLeft(), true); + result.fold( + (failure) { + expect(failure, isA()); + expect(failure.message, 'No refresh token available'); + }, + (_) => fail('Should not return success'), + ); + }); + + test('should clear tokens when refresh fails with AuthException', () async { + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockSecureStorage.getRefreshToken()) + .thenAnswer((_) async => refreshToken); + when(mockApiClient.refreshToken(refreshToken)) + .thenThrow(AuthException('Invalid or expired refresh token', 401)); + when(mockSecureStorage.clearTokens()).thenAnswer((_) async => {}); + + final result = await repository.refreshToken(); + + expect(result.isLeft(), true); + verify(mockSecureStorage.clearTokens()).called(1); + }); + }); + + group('logout', () { + test('should successfully logout and clear tokens', () async { + const accessToken = 'access_token'; + when(mockSecureStorage.getAccessToken()) + .thenAnswer((_) async => accessToken); + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockApiClient.logout(accessToken)).thenAnswer((_) async => {}); + when(mockSecureStorage.clearTokens()).thenAnswer((_) async => {}); + + final result = await repository.logout(); + + expect(result.isRight(), true); + verify(mockSecureStorage.clearTokens()).called(1); + }); + + test('should clear tokens even when API call fails', () async { + const accessToken = 'access_token'; + when(mockSecureStorage.getAccessToken()) + .thenAnswer((_) async => accessToken); + when(mockNetworkInfo.isConnected).thenAnswer((_) async => true); + when(mockApiClient.logout(accessToken)) + .thenThrow(NetworkException('Network error')); + when(mockSecureStorage.clearTokens()).thenAnswer((_) async => {}); + + final result = await repository.logout(); + + expect(result.isRight(), true); + verify(mockSecureStorage.clearTokens()).called(1); + }); + }); + + group('getCachedToken', () { + test('should return AuthToken when cached token exists', () async { + final expiry = DateTime.now().add(const Duration(hours: 1)); + when(mockSecureStorage.getAccessToken()) + .thenAnswer((_) async => 'access_token'); + when(mockSecureStorage.getRefreshToken()) + .thenAnswer((_) async => 'refresh_token'); + when(mockSecureStorage.getTokenExpiry()).thenAnswer((_) async => expiry); + when(mockSecureStorage.getTokenType()).thenAnswer((_) async => 'Bearer'); + + final result = await repository.getCachedToken(); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (token) { + expect(token, isNotNull); + expect(token!.accessToken, 'access_token'); + expect(token.refreshToken, 'refresh_token'); + }, + ); + }); + + test('should return null when no cached token exists', () async { + when(mockSecureStorage.getAccessToken()).thenAnswer((_) async => null); + when(mockSecureStorage.getRefreshToken()).thenAnswer((_) async => null); + when(mockSecureStorage.getTokenExpiry()).thenAnswer((_) async => null); + + final result = await repository.getCachedToken(); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (token) => expect(token, isNull), + ); + }); + }); + + group('isAuthenticated', () { + test('should return true when valid token exists', () async { + when(mockSecureStorage.hasValidToken()).thenAnswer((_) async => true); + + final result = await repository.isAuthenticated(); + + expect(result, true); + }); + + test('should return false when no valid token exists', () async { + when(mockSecureStorage.hasValidToken()).thenAnswer((_) async => false); + + final result = await repository.isAuthenticated(); + + expect(result, false); + }); + }); +} diff --git a/test/unit/data/repositories/auth_repository_test.mocks.dart b/test/unit/data/repositories/auth_repository_test.mocks.dart new file mode 100644 index 0000000..1cecf88 --- /dev/null +++ b/test/unit/data/repositories/auth_repository_test.mocks.dart @@ -0,0 +1,204 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in flutter_ar_app/test/unit/data/repositories/auth_repository_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i5; + +import 'package:flutter_ar_app/core/network/network_info.dart' as _i3; +import 'package:flutter_ar_app/data/datasources/local/secure_storage_service.dart' + as _i4; +import 'package:flutter_ar_app/data/datasources/remote/auth_api_client.dart' + as _i2; +import 'package:flutter_ar_app/data/models/auth_token_model.dart' as _i6; +import 'package:mockito/mockito.dart' as _i1; + +class MockAuthApiClient extends _i1.Mock implements _i2.AuthApiClient { + MockAuthApiClient() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future<_i6.AuthTokenModel> login({ + required String? username, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method( + #login, + [], + { + #username: username, + #password: password, + }, + ), + returnValue: _i5.Future<_i6.AuthTokenModel>.value(_FakeAuthTokenModel_0( + this, + Invocation.method( + #login, + [], + { + #username: username, + #password: password, + }, + ), + )), + ) as _i5.Future<_i6.AuthTokenModel>); + + @override + _i5.Future<_i6.AuthTokenModel> refreshToken(String? refreshToken) => + (super.noSuchMethod( + Invocation.method( + #refreshToken, + [refreshToken], + ), + returnValue: _i5.Future<_i6.AuthTokenModel>.value(_FakeAuthTokenModel_0( + this, + Invocation.method( + #refreshToken, + [refreshToken], + ), + )), + ) as _i5.Future<_i6.AuthTokenModel>); + + @override + _i5.Future logout(String? accessToken) => (super.noSuchMethod( + Invocation.method( + #logout, + [accessToken], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); +} + +class MockSecureStorageService extends _i1.Mock + implements _i4.SecureStorageService { + MockSecureStorageService() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future saveAccessToken(String? token) => (super.noSuchMethod( + Invocation.method( + #saveAccessToken, + [token], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future getAccessToken() => (super.noSuchMethod( + Invocation.method( + #getAccessToken, + [], + ), + returnValue: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future saveRefreshToken(String? token) => (super.noSuchMethod( + Invocation.method( + #saveRefreshToken, + [token], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future getRefreshToken() => (super.noSuchMethod( + Invocation.method( + #getRefreshToken, + [], + ), + returnValue: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future saveTokenExpiry(DateTime? expiry) => (super.noSuchMethod( + Invocation.method( + #saveTokenExpiry, + [expiry], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future getTokenExpiry() => (super.noSuchMethod( + Invocation.method( + #getTokenExpiry, + [], + ), + returnValue: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future saveTokenType(String? tokenType) => (super.noSuchMethod( + Invocation.method( + #saveTokenType, + [tokenType], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future getTokenType() => (super.noSuchMethod( + Invocation.method( + #getTokenType, + [], + ), + returnValue: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future clearTokens() => (super.noSuchMethod( + Invocation.method( + #clearTokens, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + _i5.Future hasValidToken() => (super.noSuchMethod( + Invocation.method( + #hasValidToken, + [], + ), + returnValue: _i5.Future.value(false), + ) as _i5.Future); +} + +class MockNetworkInfo extends _i1.Mock implements _i3.NetworkInfo { + MockNetworkInfo() { + _i1.throwOnMissingStub(this); + } + + @override + _i5.Future get isConnected => (super.noSuchMethod( + Invocation.getter(#isConnected), + returnValue: _i5.Future.value(false), + ) as _i5.Future); + + @override + _i5.Stream get onConnectivityChanged => (super.noSuchMethod( + Invocation.getter(#onConnectivityChanged), + returnValue: _i5.Stream.empty(), + ) as _i5.Stream); +} + +class _FakeAuthTokenModel_0 extends _i1.SmartFake + implements _i6.AuthTokenModel { + _FakeAuthTokenModel_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} diff --git a/test/unit/domain/services/ar_service_test.dart b/test/unit/domain/services/ar_service_test.dart new file mode 100644 index 0000000..3a442c6 --- /dev/null +++ b/test/unit/domain/services/ar_service_test.dart @@ -0,0 +1,273 @@ +import 'package:dartz/dartz.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_ar_app/core/error/failures.dart'; +import 'package:flutter_ar_app/data/repositories/animation_repository.dart'; +import 'package:flutter_ar_app/data/repositories/auth_repository.dart'; +import 'package:flutter_ar_app/data/repositories/marker_repository.dart'; +import 'package:flutter_ar_app/data/repositories/user_asset_repository.dart'; +import 'package:flutter_ar_app/domain/entities/animation_metadata.dart'; +import 'package:flutter_ar_app/domain/entities/auth_token.dart'; +import 'package:flutter_ar_app/domain/entities/marker_definition.dart'; +import 'package:flutter_ar_app/domain/entities/user_asset.dart'; +import 'package:flutter_ar_app/domain/services/ar_service.dart'; + +import 'ar_service_test.mocks.dart'; + +@GenerateMocks([ + AuthRepository, + AnimationRepository, + MarkerRepository, + UserAssetRepository, +]) +void main() { + late ArService arService; + late MockAuthRepository mockAuthRepository; + late MockAnimationRepository mockAnimationRepository; + late MockMarkerRepository mockMarkerRepository; + late MockUserAssetRepository mockUserAssetRepository; + + setUp(() { + mockAuthRepository = MockAuthRepository(); + mockAnimationRepository = MockAnimationRepository(); + mockMarkerRepository = MockMarkerRepository(); + mockUserAssetRepository = MockUserAssetRepository(); + + arService = ArService( + authRepository: mockAuthRepository, + animationRepository: mockAnimationRepository, + markerRepository: mockMarkerRepository, + userAssetRepository: mockUserAssetRepository, + ); + }); + + group('authenticate', () { + final authToken = AuthToken( + accessToken: 'access_token', + refreshToken: 'refresh_token', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + test('should return AuthToken when authentication is successful', () async { + when(mockAuthRepository.login( + username: anyNamed('username'), + password: anyNamed('password'), + )).thenAnswer((_) async => Right(authToken)); + + final result = await arService.authenticate( + username: 'user', + password: 'pass', + ); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (token) { + expect(token.accessToken, 'access_token'); + }, + ); + }); + + test('should return AuthFailure when authentication fails', () async { + when(mockAuthRepository.login( + username: anyNamed('username'), + password: anyNamed('password'), + )).thenAnswer((_) async => const Left(AuthFailure('Invalid credentials', 401))); + + final result = await arService.authenticate( + username: 'user', + password: 'pass', + ); + + expect(result.isLeft(), true); + result.fold( + (failure) { + expect(failure, isA()); + }, + (_) => fail('Should not return success'), + ); + }); + }); + + group('fetchAnimations', () { + final token = AuthToken( + accessToken: 'access_token', + refreshToken: 'refresh_token', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final animations = [ + AnimationMetadata( + id: '1', + name: 'Animation 1', + description: 'Description', + fileUrl: 'file.mp4', + thumbnailUrl: 'thumb.jpg', + fileSizeInMb: 10.5, + durationInSeconds: 30, + tags: const ['tag1'], + createdAt: DateTime.now(), + ), + ]; + + test('should return animations when token is valid', () async { + when(mockAuthRepository.getCachedToken()) + .thenAnswer((_) async => Right(token)); + when(mockAnimationRepository.getAnimations( + page: anyNamed('page'), + limit: anyNamed('limit'), + )).thenAnswer((_) async => Right(animations)); + + final result = await arService.fetchAnimations(); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (anims) { + expect(anims.length, 1); + expect(anims[0].id, '1'); + }, + ); + }); + + test('should refresh token when it is expiring soon', () async { + final expiringToken = AuthToken( + accessToken: 'access_token', + refreshToken: 'refresh_token', + expiresAt: DateTime.now().add(const Duration(minutes: 3)), + ); + + final newToken = AuthToken( + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + when(mockAuthRepository.getCachedToken()) + .thenAnswer((_) async => Right(expiringToken)); + when(mockAuthRepository.refreshToken()) + .thenAnswer((_) async => Right(newToken)); + when(mockAnimationRepository.getAnimations( + page: anyNamed('page'), + limit: anyNamed('limit'), + )).thenAnswer((_) async => Right(animations)); + + final result = await arService.fetchAnimations(); + + expect(result.isRight(), true); + verify(mockAuthRepository.refreshToken()).called(1); + }); + }); + + group('fetchMarkers', () { + final token = AuthToken( + accessToken: 'access_token', + refreshToken: 'refresh_token', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final markers = [ + const MarkerDefinition( + id: '1', + name: 'Marker 1', + imageUrl: 'marker.jpg', + width: 10.0, + height: 10.0, + ), + ]; + + test('should return markers when token is valid', () async { + when(mockAuthRepository.getCachedToken()) + .thenAnswer((_) async => Right(token)); + when(mockMarkerRepository.getMarkers( + page: anyNamed('page'), + limit: anyNamed('limit'), + )).thenAnswer((_) async => Right(markers)); + + final result = await arService.fetchMarkers(); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (mrks) { + expect(mrks.length, 1); + expect(mrks[0].id, '1'); + }, + ); + }); + }); + + group('fetchUserAssets', () { + final token = AuthToken( + accessToken: 'access_token', + refreshToken: 'refresh_token', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final assets = [ + UserAsset( + id: '1', + userId: 'user1', + assetType: 'image', + name: 'Asset 1', + fileUrl: 'file.jpg', + fileSizeInMb: 5.0, + uploadedAt: DateTime.now(), + ), + ]; + + test('should return user assets when token is valid', () async { + when(mockAuthRepository.getCachedToken()) + .thenAnswer((_) async => Right(token)); + when(mockUserAssetRepository.getUserAssets( + page: anyNamed('page'), + limit: anyNamed('limit'), + assetType: anyNamed('assetType'), + )).thenAnswer((_) async => Right(assets)); + + final result = await arService.fetchUserAssets(); + + expect(result.isRight(), true); + result.fold( + (failure) => fail('Should not return failure'), + (assts) { + expect(assts.length, 1); + expect(assts[0].id, '1'); + }, + ); + }); + }); + + group('isAuthenticated', () { + test('should return true when user is authenticated', () async { + when(mockAuthRepository.isAuthenticated()) + .thenAnswer((_) async => true); + + final result = await arService.isAuthenticated(); + + expect(result, true); + }); + + test('should return false when user is not authenticated', () async { + when(mockAuthRepository.isAuthenticated()) + .thenAnswer((_) async => false); + + final result = await arService.isAuthenticated(); + + expect(result, false); + }); + }); + + group('signOut', () { + test('should successfully sign out', () async { + when(mockAuthRepository.logout()) + .thenAnswer((_) async => const Right(null)); + + final result = await arService.signOut(); + + expect(result.isRight(), true); + verify(mockAuthRepository.logout()).called(1); + }); + }); +} diff --git a/test/unit/domain/services/ar_service_test.mocks.dart b/test/unit/domain/services/ar_service_test.mocks.dart new file mode 100644 index 0000000..cbe32ef --- /dev/null +++ b/test/unit/domain/services/ar_service_test.mocks.dart @@ -0,0 +1,148 @@ +// Mocks generated by Mockito +// Do not manually edit this file. + +import 'dart:async' as _i2; +import 'package:dartz/dartz.dart' as _i3; +import 'package:mockito/mockito.dart' as _i1; +import 'package:flutter_ar_app/data/repositories/auth_repository.dart' as _i4; +import 'package:flutter_ar_app/data/repositories/animation_repository.dart' as _i5; +import 'package:flutter_ar_app/data/repositories/marker_repository.dart' as _i6; +import 'package:flutter_ar_app/data/repositories/user_asset_repository.dart' as _i7; +import 'package:flutter_ar_app/domain/entities/auth_token.dart' as _i8; +import 'package:flutter_ar_app/domain/entities/animation_metadata.dart' as _i9; +import 'package:flutter_ar_app/domain/entities/marker_definition.dart' as _i10; +import 'package:flutter_ar_app/domain/entities/user_asset.dart' as _i11; +import 'package:flutter_ar_app/core/error/failures.dart' as _i12; + +class MockAuthRepository extends _i1.Mock implements _i4.AuthRepository { + MockAuthRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Future<_i3.Either<_i12.Failure, _i8.AuthToken>> login({ + required String? username, + required String? password, + }) => + (super.noSuchMethod( + Invocation.method(#login, [], {#username: username, #password: password}), + returnValue: _i2.Future<_i3.Either<_i12.Failure, _i8.AuthToken>>.value( + _FakeEither_0<_i12.Failure, _i8.AuthToken>( + this, + Invocation.method(#login, [], {#username: username, #password: password}), + ), + ), + ) as _i2.Future<_i3.Either<_i12.Failure, _i8.AuthToken>>); + + @override + _i2.Future<_i3.Either<_i12.Failure, _i8.AuthToken>> refreshToken() => + (super.noSuchMethod( + Invocation.method(#refreshToken, []), + returnValue: _i2.Future<_i3.Either<_i12.Failure, _i8.AuthToken>>.value( + _FakeEither_0<_i12.Failure, _i8.AuthToken>( + this, + Invocation.method(#refreshToken, []), + ), + ), + ) as _i2.Future<_i3.Either<_i12.Failure, _i8.AuthToken>>); + + @override + _i2.Future<_i3.Either<_i12.Failure, void>> logout() => + (super.noSuchMethod( + Invocation.method(#logout, []), + returnValue: _i2.Future<_i3.Either<_i12.Failure, void>>.value( + _FakeEither_0<_i12.Failure, void>( + this, + Invocation.method(#logout, []), + ), + ), + ) as _i2.Future<_i3.Either<_i12.Failure, void>>); + + @override + _i2.Future<_i3.Either<_i12.Failure, _i8.AuthToken?>> getCachedToken() => + (super.noSuchMethod( + Invocation.method(#getCachedToken, []), + returnValue: _i2.Future<_i3.Either<_i12.Failure, _i8.AuthToken?>>.value( + _FakeEither_0<_i12.Failure, _i8.AuthToken?>( + this, + Invocation.method(#getCachedToken, []), + ), + ), + ) as _i2.Future<_i3.Either<_i12.Failure, _i8.AuthToken?>>); + + @override + _i2.Future isAuthenticated() => + (super.noSuchMethod( + Invocation.method(#isAuthenticated, []), + returnValue: _i2.Future.value(false), + ) as _i2.Future); +} + +class MockAnimationRepository extends _i1.Mock implements _i5.AnimationRepository { + MockAnimationRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Future<_i3.Either<_i12.Failure, List<_i9.AnimationMetadata>>> getAnimations({ + int? page, + int? limit, + }) => + (super.noSuchMethod( + Invocation.method(#getAnimations, [], {#page: page, #limit: limit}), + returnValue: _i2.Future<_i3.Either<_i12.Failure, List<_i9.AnimationMetadata>>>.value( + _FakeEither_0<_i12.Failure, List<_i9.AnimationMetadata>>( + this, + Invocation.method(#getAnimations, [], {#page: page, #limit: limit}), + ), + ), + ) as _i2.Future<_i3.Either<_i12.Failure, List<_i9.AnimationMetadata>>>); +} + +class MockMarkerRepository extends _i1.Mock implements _i6.MarkerRepository { + MockMarkerRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Future<_i3.Either<_i12.Failure, List<_i10.MarkerDefinition>>> getMarkers({ + int? page, + int? limit, + }) => + (super.noSuchMethod( + Invocation.method(#getMarkers, [], {#page: page, #limit: limit}), + returnValue: _i2.Future<_i3.Either<_i12.Failure, List<_i10.MarkerDefinition>>>.value( + _FakeEither_0<_i12.Failure, List<_i10.MarkerDefinition>>( + this, + Invocation.method(#getMarkers, [], {#page: page, #limit: limit}), + ), + ), + ) as _i2.Future<_i3.Either<_i12.Failure, List<_i10.MarkerDefinition>>>); +} + +class MockUserAssetRepository extends _i1.Mock implements _i7.UserAssetRepository { + MockUserAssetRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.Future<_i3.Either<_i12.Failure, List<_i11.UserAsset>>> getUserAssets({ + int? page, + int? limit, + String? assetType, + }) => + (super.noSuchMethod( + Invocation.method(#getUserAssets, [], {#page: page, #limit: limit, #assetType: assetType}), + returnValue: _i2.Future<_i3.Either<_i12.Failure, List<_i11.UserAsset>>>.value( + _FakeEither_0<_i12.Failure, List<_i11.UserAsset>>( + this, + Invocation.method(#getUserAssets, [], {#page: page, #limit: limit, #assetType: assetType}), + ), + ), + ) as _i2.Future<_i3.Either<_i12.Failure, List<_i11.UserAsset>>>); +} + +class _FakeEither_0 extends _i1.SmartFake implements _i3.Either { + _FakeEither_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +}