mirror of
				https://gitlab.com/foxixus/neomovies_mobile.git
				synced 2025-10-28 10:38:50 +05:00 
			
		
		
		
	Compare commits
	
		
			11 Commits
		
	
	
		
			c9ea5527a8
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 748bf975ca | |||
| 87dc2795ef | |||
| 06bd83278b | |||
|  | dfebd7f9e6 | ||
|  | 6b59750621 | ||
|  | 02c2abd5fb | ||
|  | 1e5451859f | ||
|  | 93ce51e02a | ||
| c8ee6d75b2 | |||
|  | 1f0cf828da | ||
|  | fa88fd20c8 | 
							
								
								
									
										75
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										75
									
								
								.github/workflows/release.yml
									
									
									
									
										vendored
									
									
								
							| @@ -66,6 +66,14 @@ jobs: | ||||
|           channel: 'stable' | ||||
|           cache: true | ||||
|  | ||||
|       - name: Update version from tag | ||||
|         if: startsWith(github.ref, 'refs/tags/') | ||||
|         run: | | ||||
|           VERSION_NAME=${GITHUB_REF#refs/tags/v} | ||||
|           BUILD_NUMBER=$(echo $VERSION_NAME | sed 's/[^0-9]//g') | ||||
|           echo "Updating version to $VERSION_NAME+$BUILD_NUMBER" | ||||
|           sed -i "s/^version: .*/version: $VERSION_NAME+$BUILD_NUMBER/" pubspec.yaml | ||||
|  | ||||
|       - name: Get dependencies | ||||
|         run: flutter pub get | ||||
|  | ||||
| @@ -182,17 +190,6 @@ jobs: | ||||
|           ### What's Changed | ||||
|           See the [full commit history](${{ github.server_url }}/${{ github.repository }}/compare/${{ github.event.before }}...${{ github.sha }}) | ||||
|           EOF | ||||
|  | ||||
|       - name: Delete previous release if exists | ||||
|         run: | | ||||
|           RELEASE_ID=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | ||||
|             "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ steps.version.outputs.version }}" \ | ||||
|             | jq -r '.id // empty') | ||||
|           if [ ! -z "$RELEASE_ID" ]; then | ||||
|             echo "Deleting previous release $RELEASE_ID" | ||||
|             curl -X DELETE -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ | ||||
|               "https://api.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID" | ||||
|           fi | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|  | ||||
| @@ -212,12 +209,68 @@ jobs: | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|  | ||||
|       - name: Publish to Telegram | ||||
|         run: | | ||||
|           # Prepare Telegram message | ||||
|           VERSION="${{ steps.version.outputs.version }}" | ||||
|           COMMIT_SHA="${{ github.sha }}" | ||||
|           BRANCH="${{ github.ref_name }}" | ||||
|           RUN_NUMBER="${{ github.run_number }}" | ||||
|           REPO_URL="${{ github.server_url }}/${{ github.repository }}" | ||||
|            | ||||
|           # Create message text | ||||
|           MESSAGE="🚀 *NeoMovies Mobile ${VERSION}* | ||||
|            | ||||
|           📋 *Build Info:* | ||||
|           • Commit: \`${COMMIT_SHA:0:7}\` | ||||
|           • Branch: \`${BRANCH}\` | ||||
|           • Workflow Run: [#${RUN_NUMBER}](${REPO_URL}/actions/runs/${{ github.run_id }}) | ||||
|            | ||||
|           📦 *Downloads:* | ||||
|           • *ARM64 (arm64-v8a)*: ${{ steps.sizes.outputs.arm64_size }} - Recommended for modern devices | ||||
|           • *ARM32 (armeabi-v7a)*: ${{ steps.sizes.outputs.arm32_size }} - For older devices | ||||
|           • *x86_64*: ${{ steps.sizes.outputs.x64_size }} - For emulators | ||||
|            | ||||
|           🔗 [View Release](${REPO_URL}/releases/tag/${VERSION})" | ||||
|            | ||||
|           # Send message to Telegram | ||||
|           curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \ | ||||
|             -H "Content-Type: application/json" \ | ||||
|             -d "{ | ||||
|               \"chat_id\": \"${{ secrets.TELEGRAM_CHAT_ID }}\", | ||||
|               \"text\": \"$MESSAGE\", | ||||
|               \"parse_mode\": \"Markdown\", | ||||
|               \"disable_web_page_preview\": true | ||||
|             }" | ||||
|            | ||||
|           # Send APK files | ||||
|           echo "Uploading ARM64 APK to Telegram..." | ||||
|           curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument" \ | ||||
|             -F "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \ | ||||
|             -F "document=@./apks/app-arm64-v8a-release.apk" \ | ||||
|             -F "caption=📱 NeoMovies ${VERSION} - ARM64 (Recommended)" | ||||
|            | ||||
|           echo "Uploading ARM32 APK to Telegram..." | ||||
|           curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument" \ | ||||
|             -F "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \ | ||||
|             -F "document=@./apks/app-armeabi-v7a-release.apk" \ | ||||
|             -F "caption=📱 NeoMovies ${VERSION} - ARM32 (For older devices)" | ||||
|            | ||||
|           echo "Uploading x86_64 APK to Telegram..." | ||||
|           curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument" \ | ||||
|             -F "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \ | ||||
|             -F "document=@./apks/app-x86_64-release.apk" \ | ||||
|             -F "caption=📱 NeoMovies ${VERSION} - x86_64 (For emulators)" | ||||
|            | ||||
|           echo "Telegram notification sent successfully!" | ||||
|  | ||||
|       - name: Summary | ||||
|         run: | | ||||
|           echo "## Release Created Successfully!" >> $GITHUB_STEP_SUMMARY | ||||
|           echo "" >> $GITHUB_STEP_SUMMARY | ||||
|           echo "**Version:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY | ||||
|           echo "**Release URL:** ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY | ||||
|           echo "**Telegram:** Published to channel" >> $GITHUB_STEP_SUMMARY | ||||
|           echo "" >> $GITHUB_STEP_SUMMARY | ||||
|           echo "### APK Files:" >> $GITHUB_STEP_SUMMARY | ||||
|           echo "- ARM64: ${{ steps.sizes.outputs.arm64_size }}" >> $GITHUB_STEP_SUMMARY | ||||
|   | ||||
| @@ -57,6 +57,15 @@ build:apk:arm64: | ||||
| build:apk:arm: | ||||
|   stage: build | ||||
|   image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION} | ||||
|   before_script: | ||||
|     # Update version from tag if present | ||||
|     - | | ||||
|       if [ -n "$CI_COMMIT_TAG" ]; then | ||||
|         VERSION_NAME="${CI_COMMIT_TAG#v}" | ||||
|         BUILD_NUMBER=$(echo $CI_COMMIT_TAG | sed 's/[^0-9]//g') | ||||
|         echo "Updating version to $VERSION_NAME+$BUILD_NUMBER" | ||||
|         sed -i "s/^version: .*/version: $VERSION_NAME+$BUILD_NUMBER/" pubspec.yaml | ||||
|       fi | ||||
|   script: | ||||
|     - flutter pub get | ||||
|     - mkdir -p debug-symbols | ||||
|   | ||||
							
								
								
									
										44
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										44
									
								
								README.md
									
									
									
									
									
								
							| @@ -4,26 +4,6 @@ | ||||
|  | ||||
| [](https://github.com/Neo-Open-Source/neomovies-mobile/releases/latest) | ||||
|  | ||||
| ## Возможности | ||||
|  | ||||
| - 📱 Кроссплатформенное приложение (Android/iOS(пока не реализовано)) | ||||
| - 🎥 Просмотр фильмов и сериалов через WebView | ||||
| - 🌙 Поддержка динамической темы | ||||
| - 💾 Локальное кэширование данных | ||||
| - 🔒 Безопасное хранение данных | ||||
| - 🚀 Быстрая загрузка контента | ||||
| - 🎨 Современный Material Design интерфейс | ||||
|  | ||||
| ## Технологии | ||||
|  | ||||
| - **Flutter** - основной фреймворк | ||||
| - **Provider** - управление состоянием | ||||
| - **Hive** - локальная база данных | ||||
| - **HTTP** - сетевые запросы | ||||
| - **WebView** - воспроизведение видео | ||||
| - **Cached Network Image** - кэширование изображений | ||||
| - **Google Fonts** - красивые шрифты | ||||
|  | ||||
| ## Установка | ||||
|  | ||||
| 1. Клонируйте репозиторий: | ||||
| @@ -39,7 +19,7 @@ flutter pub get | ||||
|  | ||||
| 3. Создайте файл `.env` в корне проекта: | ||||
| ``` | ||||
| API_URL=your_api_url_here | ||||
| API_URL=api.neomovies.ru | ||||
| ``` | ||||
|  | ||||
| 4. Запустите приложение: | ||||
| @@ -54,11 +34,6 @@ flutter run | ||||
| flutter build apk --release | ||||
| ``` | ||||
|  | ||||
| ### iOS | ||||
| ```bash | ||||
| flutter build ios --release | ||||
| ``` | ||||
|  | ||||
| ## Структура проекта | ||||
|  | ||||
| ``` | ||||
| @@ -77,20 +52,15 @@ lib/ | ||||
| - **Flutter SDK**: 3.8.1+ | ||||
| - **Dart**: 3.8.1+ | ||||
| - **Android**: API 21+ (Android 5.0+) | ||||
| - **iOS**: iOS 11.0+ | ||||
|  | ||||
| ## Участие в разработке | ||||
|  | ||||
| 1. Форкните репозиторий | ||||
| 2. Создайте ветку для новой функции (`git checkout -b feature/amazing-feature`) | ||||
| 3. Внесите изменения и закоммитьте (`git commit -m 'Add amazing feature'`) | ||||
| 4. Отправьте изменения в ветку (`git push origin feature/amazing-feature`) | ||||
| 5. Создайте Pull Request | ||||
|  | ||||
| ## Лицензия | ||||
|  | ||||
| Этот проект лицензирован под Apache 2.0 License - подробности в файле [LICENSE](LICENSE). | ||||
| Apache 2.0 License - [LICENSE](LICENSE). | ||||
|  | ||||
| ## Контакты | ||||
|  | ||||
| Если у вас есть вопросы или предложения, создайте issue в этом репозитории. | ||||
| neo.movies.mail@gmail.com | ||||
|  | ||||
| ## Благодарность | ||||
|  | ||||
| Огромная благодарность создателям проекта [LAMPAC](https://github.com/immisterio/Lampac) | ||||
| @@ -102,10 +102,7 @@ class ApiClient { | ||||
|  | ||||
|   // ---- External IDs (IMDb) ---- | ||||
|   Future<String?> getImdbId(String mediaId, String mediaType) async { | ||||
|     // This would need to be implemented in NeoMoviesApiClient | ||||
|     // For now, return null or implement a stub | ||||
|     // TODO: Add getExternalIds endpoint to backend | ||||
|     return null; | ||||
|     return _neoClient.getExternalIds(mediaId, mediaType); | ||||
|   } | ||||
|  | ||||
|   // ---- Auth ---- | ||||
|   | ||||
| @@ -186,17 +186,28 @@ class NeoMoviesApiClient { | ||||
|   /// Get movie by ID | ||||
|   Future<Movie> getMovieById(String id) async { | ||||
|     final uri = Uri.parse('$apiUrl/movies/$id'); | ||||
|     print('Fetching movie from: $uri'); | ||||
|     final response = await _client.get(uri); | ||||
|  | ||||
|     print('Response status: ${response.statusCode}'); | ||||
|     print('Response body: ${response.body.substring(0, response.body.length > 500 ? 500 : response.body.length)}...'); | ||||
|  | ||||
|     if (response.statusCode == 200) { | ||||
|       final apiResponse = json.decode(response.body); | ||||
|       print('Decoded API response type: ${apiResponse.runtimeType}'); | ||||
|       print('API response keys: ${apiResponse is Map ? apiResponse.keys.toList() : 'Not a map'}'); | ||||
|        | ||||
|       // API returns: {"success": true, "data": {...}} | ||||
|       final movieData = (apiResponse is Map && apiResponse['data'] != null)  | ||||
|           ? apiResponse['data']  | ||||
|           : apiResponse; | ||||
|        | ||||
|       print('Movie data keys: ${movieData is Map ? movieData.keys.toList() : 'Not a map'}'); | ||||
|       print('Movie data: $movieData'); | ||||
|        | ||||
|       return Movie.fromJson(movieData); | ||||
|     } else { | ||||
|       throw Exception('Failed to load movie: ${response.statusCode}'); | ||||
|       throw Exception('Failed to load movie: ${response.statusCode} - ${response.body}'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -227,17 +238,28 @@ class NeoMoviesApiClient { | ||||
|   /// Get TV show by ID | ||||
|   Future<Movie> getTvShowById(String id) async { | ||||
|     final uri = Uri.parse('$apiUrl/tv/$id'); | ||||
|     print('Fetching TV show from: $uri'); | ||||
|     final response = await _client.get(uri); | ||||
|  | ||||
|     print('Response status: ${response.statusCode}'); | ||||
|     print('Response body: ${response.body.substring(0, response.body.length > 500 ? 500 : response.body.length)}...'); | ||||
|  | ||||
|     if (response.statusCode == 200) { | ||||
|       final apiResponse = json.decode(response.body); | ||||
|       print('Decoded API response type: ${apiResponse.runtimeType}'); | ||||
|       print('API response keys: ${apiResponse is Map ? apiResponse.keys.toList() : 'Not a map'}'); | ||||
|        | ||||
|       // API returns: {"success": true, "data": {...}} | ||||
|       final tvData = (apiResponse is Map && apiResponse['data'] != null)  | ||||
|           ? apiResponse['data']  | ||||
|           : apiResponse; | ||||
|        | ||||
|       print('TV data keys: ${tvData is Map ? tvData.keys.toList() : 'Not a map'}'); | ||||
|       print('TV data: $tvData'); | ||||
|        | ||||
|       return Movie.fromJson(tvData); | ||||
|     } else { | ||||
|       throw Exception('Failed to load TV show: ${response.statusCode}'); | ||||
|       throw Exception('Failed to load TV show: ${response.statusCode} - ${response.body}'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -251,6 +273,30 @@ class NeoMoviesApiClient { | ||||
|     return _fetchMovies('/tv/search', page: page, query: query); | ||||
|   } | ||||
|  | ||||
|   // ============================================ | ||||
|   // External IDs (IMDb, TVDB, etc.) | ||||
|   // ============================================ | ||||
|  | ||||
|   /// Get external IDs (IMDb, TVDB) for a movie or TV show | ||||
|   Future<String?> getExternalIds(String mediaId, String mediaType) async { | ||||
|     try { | ||||
|       final uri = Uri.parse('$apiUrl/${mediaType}s/$mediaId/external-ids'); | ||||
|       final response = await _client.get(uri); | ||||
|  | ||||
|       if (response.statusCode == 200) { | ||||
|         final apiResponse = json.decode(response.body); | ||||
|         final data = (apiResponse is Map && apiResponse['data'] != null)  | ||||
|             ? apiResponse['data']  | ||||
|             : apiResponse; | ||||
|         return data['imdb_id'] as String?; | ||||
|       } | ||||
|       return null; | ||||
|     } catch (e) { | ||||
|       print('Error getting external IDs: $e'); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // ============================================ | ||||
|   // Unified Search | ||||
|   // ============================================ | ||||
|   | ||||
| @@ -8,10 +8,13 @@ class AuthResponse { | ||||
|   AuthResponse({required this.token, required this.user, required this.verified}); | ||||
|  | ||||
|   factory AuthResponse.fromJson(Map<String, dynamic> json) { | ||||
|     // Handle wrapped response with "data" field | ||||
|     final data = json['data'] ?? json; | ||||
|      | ||||
|     return AuthResponse( | ||||
|       token: json['token'] as String, | ||||
|       user: User.fromJson(json['user'] as Map<String, dynamic>), | ||||
|       verified: (json['verified'] as bool?) ?? (json['user']?['verified'] as bool? ?? true), | ||||
|       token: data['token'] as String, | ||||
|       user: User.fromJson(data['user'] as Map<String, dynamic>), | ||||
|       verified: (data['verified'] as bool?) ?? (data['user']?['verified'] as bool? ?? true), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -67,30 +67,79 @@ class Movie extends HiveObject { | ||||
|   }); | ||||
|  | ||||
|   factory Movie.fromJson(Map<String, dynamic> json) { | ||||
|     return Movie( | ||||
|       id: (json['id'] as num).toString(), // Ensure id is a string | ||||
|       title: (json['title'] ?? json['name'] ?? '') as String, | ||||
|       posterPath: json['poster_path'] as String?, | ||||
|       backdropPath: json['backdrop_path'] as String?, | ||||
|       overview: json['overview'] as String?, | ||||
|       releaseDate: json['release_date'] != null && json['release_date'].isNotEmpty | ||||
|           ? DateTime.tryParse(json['release_date'] as String) | ||||
|           : json['first_air_date'] != null && json['first_air_date'].isNotEmpty | ||||
|               ? DateTime.tryParse(json['first_air_date'] as String) | ||||
|               : null, | ||||
|       genres: List<String>.from(json['genres']?.map((g) => g['name']) ?? []), | ||||
|       voteAverage: (json['vote_average'] as num?)?.toDouble() ?? 0.0, | ||||
|       popularity: (json['popularity'] as num?)?.toDouble() ?? 0.0, | ||||
|       runtime: json['runtime'] is num | ||||
|           ? (json['runtime'] as num).toInt() | ||||
|           : (json['episode_run_time'] is List && (json['episode_run_time'] as List).isNotEmpty) | ||||
|               ? ((json['episode_run_time'] as List).first as num).toInt() | ||||
|               : null, | ||||
|       seasonsCount: json['number_of_seasons'] as int?, | ||||
|       episodesCount: json['number_of_episodes'] as int?, | ||||
|       tagline: json['tagline'] as String?, | ||||
|       mediaType: (json['media_type'] ?? (json['title'] != null ? 'movie' : 'tv')) as String, | ||||
|     ); | ||||
|     try { | ||||
|       print('Parsing Movie from JSON: ${json.keys.toList()}'); | ||||
|        | ||||
|       // Parse genres safely - API returns: [{"id": 18, "name": "Drama"}] | ||||
|       List<String> genresList = []; | ||||
|       if (json['genres'] != null && json['genres'] is List) { | ||||
|         genresList = (json['genres'] as List) | ||||
|             .map((g) { | ||||
|               if (g is Map && g.containsKey('name')) { | ||||
|                 return g['name'] as String? ?? ''; | ||||
|               } | ||||
|               return ''; | ||||
|             }) | ||||
|             .where((name) => name.isNotEmpty) | ||||
|             .toList(); | ||||
|         print('Parsed genres: $genresList'); | ||||
|       } | ||||
|  | ||||
|       // Parse dates safely | ||||
|       DateTime? parsedDate; | ||||
|       final releaseDate = json['release_date']; | ||||
|       final firstAirDate = json['first_air_date']; | ||||
|        | ||||
|       if (releaseDate != null && releaseDate.toString().isNotEmpty && releaseDate.toString() != 'null') { | ||||
|         parsedDate = DateTime.tryParse(releaseDate.toString()); | ||||
|       } else if (firstAirDate != null && firstAirDate.toString().isNotEmpty && firstAirDate.toString() != 'null') { | ||||
|         parsedDate = DateTime.tryParse(firstAirDate.toString()); | ||||
|       } | ||||
|  | ||||
|       // Parse runtime (movie) or episode_run_time (TV) | ||||
|       int? runtimeValue; | ||||
|       if (json['runtime'] != null && json['runtime'] is num && (json['runtime'] as num) > 0) { | ||||
|         runtimeValue = (json['runtime'] as num).toInt(); | ||||
|       } else if (json['episode_run_time'] != null && json['episode_run_time'] is List) { | ||||
|         final episodeRunTime = json['episode_run_time'] as List; | ||||
|         if (episodeRunTime.isNotEmpty && episodeRunTime.first is num) { | ||||
|           runtimeValue = (episodeRunTime.first as num).toInt(); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       // Determine media type | ||||
|       String mediaTypeValue = 'movie'; | ||||
|       if (json.containsKey('media_type') && json['media_type'] != null) { | ||||
|         mediaTypeValue = json['media_type'] as String; | ||||
|       } else if (json.containsKey('name') || json.containsKey('first_air_date')) { | ||||
|         mediaTypeValue = 'tv'; | ||||
|       } | ||||
|  | ||||
|       final movie = Movie( | ||||
|         id: (json['id'] as num).toString(), | ||||
|         title: (json['title'] ?? json['name'] ?? 'Untitled') as String, | ||||
|         posterPath: json['poster_path'] as String?, | ||||
|         backdropPath: json['backdrop_path'] as String?, | ||||
|         overview: json['overview'] as String?, | ||||
|         releaseDate: parsedDate, | ||||
|         genres: genresList, | ||||
|         voteAverage: (json['vote_average'] as num?)?.toDouble() ?? 0.0, | ||||
|         popularity: (json['popularity'] as num?)?.toDouble() ?? 0.0, | ||||
|         runtime: runtimeValue, | ||||
|         seasonsCount: json['number_of_seasons'] as int?, | ||||
|         episodesCount: json['number_of_episodes'] as int?, | ||||
|         tagline: json['tagline'] as String?, | ||||
|         mediaType: mediaTypeValue, | ||||
|       ); | ||||
|  | ||||
|       print('Successfully parsed movie: ${movie.title}'); | ||||
|       return movie; | ||||
|     } catch (e, stackTrace) { | ||||
|       print('❌ Error parsing Movie from JSON: $e'); | ||||
|       print('Stack trace: $stackTrace'); | ||||
|       print('JSON data: $json'); | ||||
|       rethrow; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toJson() => _$MovieToJson(this); | ||||
|   | ||||
| @@ -2,14 +2,30 @@ class User { | ||||
|   final String id; | ||||
|   final String name; | ||||
|   final String email; | ||||
|   final bool verified; | ||||
|  | ||||
|   User({required this.id, required this.name, required this.email}); | ||||
|   User({ | ||||
|     required this.id,  | ||||
|     required this.name,  | ||||
|     required this.email, | ||||
|     this.verified = true, | ||||
|   }); | ||||
|  | ||||
|   factory User.fromJson(Map<String, dynamic> json) { | ||||
|     return User( | ||||
|       id: json['_id'] as String? ?? '', | ||||
|       id: (json['_id'] ?? json['id'] ?? '') as String, | ||||
|       name: json['name'] as String? ?? '', | ||||
|       email: json['email'] as String? ?? '', | ||||
|       verified: json['verified'] as bool? ?? true, | ||||
|     ); | ||||
|   } | ||||
|    | ||||
|   Map<String, dynamic> toJson() { | ||||
|     return { | ||||
|       '_id': id, | ||||
|       'name': name, | ||||
|       'email': email, | ||||
|       'verified': verified, | ||||
|     }; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import 'package:neomovies_mobile/presentation/providers/favorites_provider.dart' | ||||
| import 'package:neomovies_mobile/presentation/providers/home_provider.dart'; | ||||
| import 'package:neomovies_mobile/presentation/providers/movie_detail_provider.dart'; | ||||
| import 'package:neomovies_mobile/presentation/providers/reactions_provider.dart'; | ||||
| import 'package:neomovies_mobile/presentation/providers/downloads_provider.dart'; | ||||
| import 'package:neomovies_mobile/presentation/screens/main_screen.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
|  | ||||
|   | ||||
| @@ -9,10 +9,12 @@ class DownloadsProvider with ChangeNotifier { | ||||
|   Timer? _progressTimer; | ||||
|   bool _isLoading = false; | ||||
|   String? _error; | ||||
|   String? _stackTrace; | ||||
|  | ||||
|   List<TorrentInfo> get torrents => List.unmodifiable(_torrents); | ||||
|   bool get isLoading => _isLoading; | ||||
|   String? get error => _error; | ||||
|   String? get stackTrace => _stackTrace; | ||||
|  | ||||
|   DownloadsProvider() { | ||||
|     _startProgressUpdates(); | ||||
| @@ -164,8 +166,9 @@ class DownloadsProvider with ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void _setError(String? error) { | ||||
|   void _setError(String? error, [String? stackTrace]) { | ||||
|     _error = error; | ||||
|     _stackTrace = stackTrace; | ||||
|     notifyListeners(); | ||||
|   } | ||||
| } | ||||
| @@ -24,6 +24,9 @@ class MovieDetailProvider with ChangeNotifier { | ||||
|   String? _error; | ||||
|   String? get error => _error; | ||||
|  | ||||
|   String? _stackTrace; | ||||
|   String? get stackTrace => _stackTrace; | ||||
|  | ||||
|   Future<void> loadMedia(int mediaId, String mediaType) async { | ||||
|     _isLoading = true; | ||||
|     _isImdbLoading = true; | ||||
| @@ -33,11 +36,15 @@ class MovieDetailProvider with ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|  | ||||
|     try { | ||||
|       print('Loading media: ID=$mediaId, type=$mediaType'); | ||||
|        | ||||
|       // Load movie/TV details | ||||
|       if (mediaType == 'movie') { | ||||
|         _movie = await _movieRepository.getMovieById(mediaId.toString()); | ||||
|         print('Movie loaded successfully: ${_movie?.title}'); | ||||
|       } else { | ||||
|         _movie = await _movieRepository.getTvById(mediaId.toString()); | ||||
|         print('TV show loaded successfully: ${_movie?.title}'); | ||||
|       } | ||||
|        | ||||
|       _isLoading = false; | ||||
| @@ -46,16 +53,20 @@ class MovieDetailProvider with ChangeNotifier { | ||||
|       // Try to load IMDb ID (non-blocking) | ||||
|       if (_movie != null) { | ||||
|         try { | ||||
|           print('Loading IMDb ID for $mediaType $mediaId'); | ||||
|           _imdbId = await _apiClient.getImdbId(mediaId.toString(), mediaType); | ||||
|           print('IMDb ID loaded: $_imdbId'); | ||||
|         } catch (e) { | ||||
|           // IMDb ID loading failed, but don't fail the whole screen | ||||
|           print('Failed to load IMDb ID: $e'); | ||||
|           _imdbId = null; | ||||
|         } | ||||
|       } | ||||
|     } catch (e) { | ||||
|     } catch (e, stackTrace) { | ||||
|       print('Error loading media: $e'); | ||||
|       print('Stack trace: $stackTrace'); | ||||
|       _error = e.toString(); | ||||
|       _stackTrace = stackTrace.toString(); | ||||
|       _isLoading = false; | ||||
|       notifyListeners(); | ||||
|     } finally { | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import '../../providers/downloads_provider.dart'; | ||||
| import '../../widgets/error_display.dart'; | ||||
| import '../../../data/models/torrent_info.dart'; | ||||
| import 'torrent_detail_screen.dart'; | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class DownloadsScreen extends StatefulWidget { | ||||
|   const DownloadsScreen({super.key}); | ||||
|  | ||||
| @@ -48,37 +47,13 @@ class _DownloadsScreenState extends State<DownloadsScreen> { | ||||
|           } | ||||
|  | ||||
|           if (provider.error != null) { | ||||
|             return Center( | ||||
|               child: Column( | ||||
|                 mainAxisAlignment: MainAxisAlignment.center, | ||||
|                 children: [ | ||||
|                   Icon( | ||||
|                     Icons.error_outline, | ||||
|                     size: 64, | ||||
|                     color: Colors.red.shade300, | ||||
|                   ), | ||||
|                   const SizedBox(height: 16), | ||||
|                   Text( | ||||
|                     'Ошибка загрузки', | ||||
|                     style: Theme.of(context).textTheme.headlineSmall, | ||||
|                   ), | ||||
|                   const SizedBox(height: 8), | ||||
|                   Text( | ||||
|                     provider.error!, | ||||
|                     textAlign: TextAlign.center, | ||||
|                     style: Theme.of(context).textTheme.bodyMedium?.copyWith( | ||||
|                       color: Colors.grey.shade600, | ||||
|                     ), | ||||
|                   ), | ||||
|                   const SizedBox(height: 16), | ||||
|                   ElevatedButton( | ||||
|                     onPressed: () { | ||||
|                       provider.refreshDownloads(); | ||||
|                     }, | ||||
|                     child: const Text('Попробовать снова'), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             return ErrorDisplay( | ||||
|               title: 'Ошибка загрузки торрентов', | ||||
|               error: provider.error!, | ||||
|               stackTrace: provider.stackTrace, | ||||
|               onRetry: () { | ||||
|                 provider.refreshDownloads(); | ||||
|               }, | ||||
|             ); | ||||
|           } | ||||
|  | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import 'package:neomovies_mobile/presentation/screens/auth/profile_screen.dart'; | ||||
| import 'package:neomovies_mobile/presentation/screens/favorites/favorites_screen.dart'; | ||||
| import 'package:neomovies_mobile/presentation/screens/search/search_screen.dart'; | ||||
| import 'package:neomovies_mobile/presentation/screens/home/home_screen.dart'; | ||||
| import 'package:neomovies_mobile/presentation/screens/downloads/downloads_screen.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
|  | ||||
| class MainScreen extends StatefulWidget { | ||||
| @@ -30,7 +31,7 @@ class _MainScreenState extends State<MainScreen> { | ||||
|     HomeScreen(), | ||||
|     SearchScreen(), | ||||
|     FavoritesScreen(), | ||||
|     Center(child: Text('Downloads Page')), | ||||
|     DownloadsScreen(), | ||||
|     ProfileScreen(), | ||||
|   ]; | ||||
|  | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import 'package:neomovies_mobile/presentation/providers/reactions_provider.dart' | ||||
| import 'package:neomovies_mobile/presentation/providers/movie_detail_provider.dart'; | ||||
| import 'package:neomovies_mobile/presentation/screens/player/video_player_screen.dart'; | ||||
| import 'package:neomovies_mobile/presentation/screens/torrent_selector/torrent_selector_screen.dart'; | ||||
| import 'package:neomovies_mobile/presentation/widgets/error_display.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
|  | ||||
| class MovieDetailScreen extends StatefulWidget { | ||||
| @@ -89,7 +90,15 @@ class _MovieDetailScreenState extends State<MovieDetailScreen> { | ||||
|           } | ||||
|  | ||||
|           if (provider.error != null) { | ||||
|             return Center(child: Text('Error: ${provider.error}')); | ||||
|             return ErrorDisplay( | ||||
|               title: 'Ошибка загрузки ${widget.mediaType == 'movie' ? 'фильма' : 'сериала'}', | ||||
|               error: provider.error!, | ||||
|               stackTrace: provider.stackTrace, | ||||
|               onRetry: () { | ||||
|                 Provider.of<MovieDetailProvider>(context, listen: false) | ||||
|                     .loadMedia(int.parse(widget.movieId), widget.mediaType); | ||||
|               }, | ||||
|             ); | ||||
|           } | ||||
|  | ||||
|           if (provider.movie == null) { | ||||
|   | ||||
							
								
								
									
										254
									
								
								lib/presentation/widgets/error_display.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										254
									
								
								lib/presentation/widgets/error_display.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,254 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
|  | ||||
| /// Widget that displays detailed error information for debugging | ||||
| class ErrorDisplay extends StatelessWidget { | ||||
|   final String title; | ||||
|   final String error; | ||||
|   final String? stackTrace; | ||||
|   final VoidCallback? onRetry; | ||||
|  | ||||
|   const ErrorDisplay({ | ||||
|     super.key, | ||||
|     this.title = 'Произошла ошибка', | ||||
|     required this.error, | ||||
|     this.stackTrace, | ||||
|     this.onRetry, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Center( | ||||
|       child: SingleChildScrollView( | ||||
|         padding: const EdgeInsets.all(16), | ||||
|         child: Column( | ||||
|           mainAxisAlignment: MainAxisAlignment.center, | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             // Error icon and title | ||||
|             Row( | ||||
|               mainAxisAlignment: MainAxisAlignment.center, | ||||
|               children: [ | ||||
|                 Icon( | ||||
|                   Icons.error_outline, | ||||
|                   size: 48, | ||||
|                   color: Colors.red.shade400, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|             const SizedBox(height: 16), | ||||
|              | ||||
|             // Title | ||||
|             Text( | ||||
|               title, | ||||
|               style: Theme.of(context).textTheme.headlineSmall?.copyWith( | ||||
|                 fontWeight: FontWeight.bold, | ||||
|                 color: Colors.red.shade700, | ||||
|               ), | ||||
|               textAlign: TextAlign.center, | ||||
|             ), | ||||
|             const SizedBox(height: 24), | ||||
|              | ||||
|             // Error message card | ||||
|             Container( | ||||
|               width: double.infinity, | ||||
|               padding: const EdgeInsets.all(16), | ||||
|               decoration: BoxDecoration( | ||||
|                 color: Colors.red.shade50, | ||||
|                 borderRadius: BorderRadius.circular(8), | ||||
|                 border: Border.all(color: Colors.red.shade200), | ||||
|               ), | ||||
|               child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   Row( | ||||
|                     children: [ | ||||
|                       Icon(Icons.info_outline, size: 20, color: Colors.red.shade700), | ||||
|                       const SizedBox(width: 8), | ||||
|                       Text( | ||||
|                         'Сообщение об ошибке:', | ||||
|                         style: TextStyle( | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                           color: Colors.red.shade700, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                   const SizedBox(height: 8), | ||||
|                   SelectableText( | ||||
|                     error, | ||||
|                     style: const TextStyle( | ||||
|                       fontFamily: 'monospace', | ||||
|                       fontSize: 13, | ||||
|                     ), | ||||
|                   ), | ||||
|                   const SizedBox(height: 12), | ||||
|                   Row( | ||||
|                     children: [ | ||||
|                       Expanded( | ||||
|                         child: OutlinedButton.icon( | ||||
|                           onPressed: () { | ||||
|                             Clipboard.setData(ClipboardData(text: error)); | ||||
|                             ScaffoldMessenger.of(context).showSnackBar( | ||||
|                               const SnackBar( | ||||
|                                 content: Text('Ошибка скопирована в буфер обмена'), | ||||
|                                 duration: Duration(seconds: 2), | ||||
|                               ), | ||||
|                             ); | ||||
|                           }, | ||||
|                           icon: const Icon(Icons.copy, size: 18), | ||||
|                           label: const Text('Копировать ошибку'), | ||||
|                           style: OutlinedButton.styleFrom( | ||||
|                             foregroundColor: Colors.red.shade700, | ||||
|                             side: BorderSide(color: Colors.red.shade300), | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|              | ||||
|             // Stack trace (if available) | ||||
|             if (stackTrace != null && stackTrace!.isNotEmpty) ...[ | ||||
|               const SizedBox(height: 16), | ||||
|               ExpansionTile( | ||||
|                 title: Row( | ||||
|                   children: [ | ||||
|                     Icon(Icons.bug_report, size: 20, color: Colors.orange.shade700), | ||||
|                     const SizedBox(width: 8), | ||||
|                     Text( | ||||
|                       'Stack Trace (для разработчиков)', | ||||
|                       style: TextStyle( | ||||
|                         fontWeight: FontWeight.bold, | ||||
|                         color: Colors.orange.shade700, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 backgroundColor: Colors.orange.shade50, | ||||
|                 collapsedBackgroundColor: Colors.orange.shade50, | ||||
|                 shape: RoundedRectangleBorder( | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                   side: BorderSide(color: Colors.orange.shade200), | ||||
|                 ), | ||||
|                 collapsedShape: RoundedRectangleBorder( | ||||
|                   borderRadius: BorderRadius.circular(8), | ||||
|                   side: BorderSide(color: Colors.orange.shade200), | ||||
|                 ), | ||||
|                 children: [ | ||||
|                   Container( | ||||
|                     width: double.infinity, | ||||
|                     padding: const EdgeInsets.all(16), | ||||
|                     decoration: BoxDecoration( | ||||
|                       color: Colors.grey.shade900, | ||||
|                       borderRadius: const BorderRadius.only( | ||||
|                         bottomLeft: Radius.circular(8), | ||||
|                         bottomRight: Radius.circular(8), | ||||
|                       ), | ||||
|                     ), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         SelectableText( | ||||
|                           stackTrace!, | ||||
|                           style: const TextStyle( | ||||
|                             fontFamily: 'monospace', | ||||
|                             fontSize: 11, | ||||
|                             color: Colors.greenAccent, | ||||
|                           ), | ||||
|                         ), | ||||
|                         const SizedBox(height: 12), | ||||
|                         OutlinedButton.icon( | ||||
|                           onPressed: () { | ||||
|                             Clipboard.setData(ClipboardData(text: stackTrace!)); | ||||
|                             ScaffoldMessenger.of(context).showSnackBar( | ||||
|                               const SnackBar( | ||||
|                                 content: Text('Stack trace скопирован в буфер обмена'), | ||||
|                                 duration: Duration(seconds: 2), | ||||
|                               ), | ||||
|                             ); | ||||
|                           }, | ||||
|                           icon: const Icon(Icons.copy, size: 18), | ||||
|                           label: const Text('Копировать stack trace'), | ||||
|                           style: OutlinedButton.styleFrom( | ||||
|                             foregroundColor: Colors.greenAccent, | ||||
|                             side: const BorderSide(color: Colors.greenAccent), | ||||
|                           ), | ||||
|                         ), | ||||
|                       ], | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|              | ||||
|             // Retry button | ||||
|             if (onRetry != null) ...[ | ||||
|               const SizedBox(height: 24), | ||||
|               Row( | ||||
|                 children: [ | ||||
|                   Expanded( | ||||
|                     child: ElevatedButton.icon( | ||||
|                       onPressed: onRetry, | ||||
|                       icon: const Icon(Icons.refresh), | ||||
|                       label: const Text('Попробовать снова'), | ||||
|                       style: ElevatedButton.styleFrom( | ||||
|                         padding: const EdgeInsets.symmetric(vertical: 16), | ||||
|                         backgroundColor: Theme.of(context).primaryColor, | ||||
|                         foregroundColor: Colors.white, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ], | ||||
|              | ||||
|             // Debug tips | ||||
|             const SizedBox(height: 24), | ||||
|             Container( | ||||
|               width: double.infinity, | ||||
|               padding: const EdgeInsets.all(12), | ||||
|               decoration: BoxDecoration( | ||||
|                 color: Colors.blue.shade50, | ||||
|                 borderRadius: BorderRadius.circular(8), | ||||
|                 border: Border.all(color: Colors.blue.shade200), | ||||
|               ), | ||||
|               child: Column( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                 children: [ | ||||
|                   Row( | ||||
|                     children: [ | ||||
|                       Icon(Icons.lightbulb_outline, size: 20, color: Colors.blue.shade700), | ||||
|                       const SizedBox(width: 8), | ||||
|                       Text( | ||||
|                         'Советы по отладке:', | ||||
|                         style: TextStyle( | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                           color: Colors.blue.shade700, | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                   const SizedBox(height: 8), | ||||
|                   Text( | ||||
|                     '• Скопируйте ошибку и отправьте разработчику\n' | ||||
|                     '• Проверьте соединение с интернетом\n' | ||||
|                     '• Проверьте логи Flutter в консоли\n' | ||||
|                     '• Попробуйте перезапустить приложение', | ||||
|                     style: TextStyle( | ||||
|                       fontSize: 12, | ||||
|                       color: Colors.blue.shade900, | ||||
|                       height: 1.5, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user