mirror of
https://gitlab.com/foxixus/neomovies_mobile.git
synced 2025-10-28 01:18:50 +05:00
Compare commits
16 Commits
ca41d27260
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 748bf975ca | |||
| 87dc2795ef | |||
| 06bd83278b | |||
|
|
dfebd7f9e6 | ||
|
|
6b59750621 | ||
|
|
02c2abd5fb | ||
|
|
1e5451859f | ||
|
|
93ce51e02a | ||
| c8ee6d75b2 | |||
|
|
1f0cf828da | ||
|
|
fa88fd20c8 | ||
| c9ea5527a8 | |||
|
|
1a610b8d8f | ||
| 499896b3dd | |||
|
|
3e664d726b | ||
| 7828b378d7 |
75
.github/workflows/release.yml
vendored
75
.github/workflows/release.yml
vendored
@@ -66,6 +66,14 @@ jobs:
|
|||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
cache: true
|
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
|
- name: Get dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -182,17 +190,6 @@ jobs:
|
|||||||
### What's Changed
|
### What's Changed
|
||||||
See the [full commit history](${{ github.server_url }}/${{ github.repository }}/compare/${{ github.event.before }}...${{ github.sha }})
|
See the [full commit history](${{ github.server_url }}/${{ github.repository }}/compare/${{ github.event.before }}...${{ github.sha }})
|
||||||
EOF
|
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:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -212,12 +209,68 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Summary
|
||||||
run: |
|
run: |
|
||||||
echo "## Release Created Successfully!" >> $GITHUB_STEP_SUMMARY
|
echo "## Release Created Successfully!" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Version:** ${{ steps.version.outputs.version }}" >> $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 "**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 "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "### APK Files:" >> $GITHUB_STEP_SUMMARY
|
echo "### APK Files:" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- ARM64: ${{ steps.sizes.outputs.arm64_size }}" >> $GITHUB_STEP_SUMMARY
|
echo "- ARM64: ${{ steps.sizes.outputs.arm64_size }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ build:apk:arm64:
|
|||||||
build:apk:arm:
|
build:apk:arm:
|
||||||
stage: build
|
stage: build
|
||||||
image: ghcr.io/cirruslabs/flutter:${FLUTTER_VERSION}
|
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:
|
script:
|
||||||
- flutter pub get
|
- flutter pub get
|
||||||
- mkdir -p debug-symbols
|
- mkdir -p debug-symbols
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -4,26 +4,6 @@
|
|||||||
|
|
||||||
[](https://github.com/Neo-Open-Source/neomovies-mobile/releases/latest)
|
[](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. Клонируйте репозиторий:
|
1. Клонируйте репозиторий:
|
||||||
@@ -39,7 +19,7 @@ flutter pub get
|
|||||||
|
|
||||||
3. Создайте файл `.env` в корне проекта:
|
3. Создайте файл `.env` в корне проекта:
|
||||||
```
|
```
|
||||||
API_URL=your_api_url_here
|
API_URL=api.neomovies.ru
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Запустите приложение:
|
4. Запустите приложение:
|
||||||
@@ -54,11 +34,6 @@ flutter run
|
|||||||
flutter build apk --release
|
flutter build apk --release
|
||||||
```
|
```
|
||||||
|
|
||||||
### iOS
|
|
||||||
```bash
|
|
||||||
flutter build ios --release
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -77,20 +52,15 @@ lib/
|
|||||||
- **Flutter SDK**: 3.8.1+
|
- **Flutter SDK**: 3.8.1+
|
||||||
- **Dart**: 3.8.1+
|
- **Dart**: 3.8.1+
|
||||||
- **Android**: API 21+ (Android 5.0+)
|
- **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)
|
||||||
@@ -48,7 +48,7 @@ dependencies {
|
|||||||
implementation(project(":torrentengine"))
|
implementation(project(":torrentengine"))
|
||||||
|
|
||||||
// Kotlin Coroutines
|
// Kotlin Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
|
||||||
|
|
||||||
// Gson для JSON сериализации
|
// Gson для JSON сериализации
|
||||||
implementation("com.google.code.gson:gson:2.11.0")
|
implementation("com.google.code.gson:gson:2.11.0")
|
||||||
|
|||||||
33
android/settings.gradle
Normal file
33
android/settings.gradle
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Legacy settings.gradle file for CI compatibility
|
||||||
|
// Main configuration is in settings.gradle.kts
|
||||||
|
|
||||||
|
pluginManagement {
|
||||||
|
def flutterSdkPath = {
|
||||||
|
def properties = new Properties()
|
||||||
|
file("local.properties").withInputStream { properties.load(it) }
|
||||||
|
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||||
|
return flutterSdkPath
|
||||||
|
}()
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
|
id "com.android.application" version "8.7.3" apply false
|
||||||
|
id "com.android.library" version "8.7.3" apply false
|
||||||
|
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include ":app"
|
||||||
|
project(":app").projectDir = file("app")
|
||||||
|
|
||||||
|
include ":torrentengine"
|
||||||
|
project(":torrentengine").projectDir = file("torrentengine")
|
||||||
@@ -34,6 +34,12 @@ android {
|
|||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "17"
|
jvmTarget = "17"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KAPT configuration for Kotlin 2.1.0 compatibility
|
||||||
|
kapt {
|
||||||
|
correctErrorTypes = true
|
||||||
|
useBuildCache = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -43,17 +49,17 @@ dependencies {
|
|||||||
implementation("com.google.android.material:material:1.12.0")
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
|
||||||
// Coroutines for async operations
|
// Coroutines for async operations
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")
|
||||||
|
|
||||||
// Lifecycle components
|
// Lifecycle components
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||||
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
|
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
|
||||||
|
|
||||||
// Room database for torrent state persistence
|
// Room database for torrent state persistence - updated for Kotlin 2.1.0
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.7.0-alpha09")
|
||||||
implementation("androidx.room:room-ktx:2.6.1")
|
implementation("androidx.room:room-ktx:2.7.0-alpha09")
|
||||||
kapt("androidx.room:room-compiler:2.6.1")
|
kapt("androidx.room:room-compiler:2.7.0-alpha09")
|
||||||
|
|
||||||
// WorkManager for background tasks
|
// WorkManager for background tasks
|
||||||
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
||||||
|
|||||||
@@ -102,10 +102,7 @@ class ApiClient {
|
|||||||
|
|
||||||
// ---- External IDs (IMDb) ----
|
// ---- External IDs (IMDb) ----
|
||||||
Future<String?> getImdbId(String mediaId, String mediaType) async {
|
Future<String?> getImdbId(String mediaId, String mediaType) async {
|
||||||
// This would need to be implemented in NeoMoviesApiClient
|
return _neoClient.getExternalIds(mediaId, mediaType);
|
||||||
// For now, return null or implement a stub
|
|
||||||
// TODO: Add getExternalIds endpoint to backend
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Auth ----
|
// ---- Auth ----
|
||||||
|
|||||||
@@ -186,17 +186,28 @@ class NeoMoviesApiClient {
|
|||||||
/// Get movie by ID
|
/// Get movie by ID
|
||||||
Future<Movie> getMovieById(String id) async {
|
Future<Movie> getMovieById(String id) async {
|
||||||
final uri = Uri.parse('$apiUrl/movies/$id');
|
final uri = Uri.parse('$apiUrl/movies/$id');
|
||||||
|
print('Fetching movie from: $uri');
|
||||||
final response = await _client.get(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) {
|
if (response.statusCode == 200) {
|
||||||
final apiResponse = json.decode(response.body);
|
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": {...}}
|
// API returns: {"success": true, "data": {...}}
|
||||||
final movieData = (apiResponse is Map && apiResponse['data'] != null)
|
final movieData = (apiResponse is Map && apiResponse['data'] != null)
|
||||||
? apiResponse['data']
|
? apiResponse['data']
|
||||||
: apiResponse;
|
: apiResponse;
|
||||||
|
|
||||||
|
print('Movie data keys: ${movieData is Map ? movieData.keys.toList() : 'Not a map'}');
|
||||||
|
print('Movie data: $movieData');
|
||||||
|
|
||||||
return Movie.fromJson(movieData);
|
return Movie.fromJson(movieData);
|
||||||
} else {
|
} 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
|
/// Get TV show by ID
|
||||||
Future<Movie> getTvShowById(String id) async {
|
Future<Movie> getTvShowById(String id) async {
|
||||||
final uri = Uri.parse('$apiUrl/tv/$id');
|
final uri = Uri.parse('$apiUrl/tv/$id');
|
||||||
|
print('Fetching TV show from: $uri');
|
||||||
final response = await _client.get(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) {
|
if (response.statusCode == 200) {
|
||||||
final apiResponse = json.decode(response.body);
|
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": {...}}
|
// API returns: {"success": true, "data": {...}}
|
||||||
final tvData = (apiResponse is Map && apiResponse['data'] != null)
|
final tvData = (apiResponse is Map && apiResponse['data'] != null)
|
||||||
? apiResponse['data']
|
? apiResponse['data']
|
||||||
: apiResponse;
|
: apiResponse;
|
||||||
|
|
||||||
|
print('TV data keys: ${tvData is Map ? tvData.keys.toList() : 'Not a map'}');
|
||||||
|
print('TV data: $tvData');
|
||||||
|
|
||||||
return Movie.fromJson(tvData);
|
return Movie.fromJson(tvData);
|
||||||
} else {
|
} 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);
|
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
|
// Unified Search
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ class AuthResponse {
|
|||||||
AuthResponse({required this.token, required this.user, required this.verified});
|
AuthResponse({required this.token, required this.user, required this.verified});
|
||||||
|
|
||||||
factory AuthResponse.fromJson(Map<String, dynamic> json) {
|
factory AuthResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
// Handle wrapped response with "data" field
|
||||||
|
final data = json['data'] ?? json;
|
||||||
|
|
||||||
return AuthResponse(
|
return AuthResponse(
|
||||||
token: json['token'] as String,
|
token: data['token'] as String,
|
||||||
user: User.fromJson(json['user'] as Map<String, dynamic>),
|
user: User.fromJson(data['user'] as Map<String, dynamic>),
|
||||||
verified: (json['verified'] as bool?) ?? (json['user']?['verified'] as bool? ?? true),
|
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) {
|
factory Movie.fromJson(Map<String, dynamic> json) {
|
||||||
return Movie(
|
try {
|
||||||
id: (json['id'] as num).toString(), // Ensure id is a string
|
print('Parsing Movie from JSON: ${json.keys.toList()}');
|
||||||
title: (json['title'] ?? json['name'] ?? '') as String,
|
|
||||||
posterPath: json['poster_path'] as String?,
|
// Parse genres safely - API returns: [{"id": 18, "name": "Drama"}]
|
||||||
backdropPath: json['backdrop_path'] as String?,
|
List<String> genresList = [];
|
||||||
overview: json['overview'] as String?,
|
if (json['genres'] != null && json['genres'] is List) {
|
||||||
releaseDate: json['release_date'] != null && json['release_date'].isNotEmpty
|
genresList = (json['genres'] as List)
|
||||||
? DateTime.tryParse(json['release_date'] as String)
|
.map((g) {
|
||||||
: json['first_air_date'] != null && json['first_air_date'].isNotEmpty
|
if (g is Map && g.containsKey('name')) {
|
||||||
? DateTime.tryParse(json['first_air_date'] as String)
|
return g['name'] as String? ?? '';
|
||||||
: null,
|
}
|
||||||
genres: List<String>.from(json['genres']?.map((g) => g['name']) ?? []),
|
return '';
|
||||||
voteAverage: (json['vote_average'] as num?)?.toDouble() ?? 0.0,
|
})
|
||||||
popularity: (json['popularity'] as num?)?.toDouble() ?? 0.0,
|
.where((name) => name.isNotEmpty)
|
||||||
runtime: json['runtime'] is num
|
.toList();
|
||||||
? (json['runtime'] as num).toInt()
|
print('Parsed genres: $genresList');
|
||||||
: (json['episode_run_time'] is List && (json['episode_run_time'] as List).isNotEmpty)
|
}
|
||||||
? ((json['episode_run_time'] as List).first as num).toInt()
|
|
||||||
: null,
|
// Parse dates safely
|
||||||
seasonsCount: json['number_of_seasons'] as int?,
|
DateTime? parsedDate;
|
||||||
episodesCount: json['number_of_episodes'] as int?,
|
final releaseDate = json['release_date'];
|
||||||
tagline: json['tagline'] as String?,
|
final firstAirDate = json['first_air_date'];
|
||||||
mediaType: (json['media_type'] ?? (json['title'] != null ? 'movie' : 'tv')) as String,
|
|
||||||
);
|
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);
|
Map<String, dynamic> toJson() => _$MovieToJson(this);
|
||||||
|
|||||||
@@ -2,14 +2,30 @@ class User {
|
|||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
final String email;
|
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) {
|
factory User.fromJson(Map<String, dynamic> json) {
|
||||||
return User(
|
return User(
|
||||||
id: json['_id'] as String? ?? '',
|
id: (json['_id'] ?? json['id'] ?? '') as String,
|
||||||
name: json['name'] as String? ?? '',
|
name: json['name'] as String? ?? '',
|
||||||
email: json['email'] 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/home_provider.dart';
|
||||||
import 'package:neomovies_mobile/presentation/providers/movie_detail_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/reactions_provider.dart';
|
||||||
|
import 'package:neomovies_mobile/presentation/providers/downloads_provider.dart';
|
||||||
import 'package:neomovies_mobile/presentation/screens/main_screen.dart';
|
import 'package:neomovies_mobile/presentation/screens/main_screen.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ class DownloadsProvider with ChangeNotifier {
|
|||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
String? _stackTrace;
|
||||||
|
|
||||||
List<TorrentInfo> get torrents => List.unmodifiable(_torrents);
|
List<TorrentInfo> get torrents => List.unmodifiable(_torrents);
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
|
String? get stackTrace => _stackTrace;
|
||||||
|
|
||||||
DownloadsProvider() {
|
DownloadsProvider() {
|
||||||
_startProgressUpdates();
|
_startProgressUpdates();
|
||||||
@@ -164,8 +166,9 @@ class DownloadsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _setError(String? error) {
|
void _setError(String? error, [String? stackTrace]) {
|
||||||
_error = error;
|
_error = error;
|
||||||
|
_stackTrace = stackTrace;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,9 @@ class MovieDetailProvider with ChangeNotifier {
|
|||||||
String? _error;
|
String? _error;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
|
|
||||||
|
String? _stackTrace;
|
||||||
|
String? get stackTrace => _stackTrace;
|
||||||
|
|
||||||
Future<void> loadMedia(int mediaId, String mediaType) async {
|
Future<void> loadMedia(int mediaId, String mediaType) async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_isImdbLoading = true;
|
_isImdbLoading = true;
|
||||||
@@ -33,11 +36,15 @@ class MovieDetailProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
print('Loading media: ID=$mediaId, type=$mediaType');
|
||||||
|
|
||||||
// Load movie/TV details
|
// Load movie/TV details
|
||||||
if (mediaType == 'movie') {
|
if (mediaType == 'movie') {
|
||||||
_movie = await _movieRepository.getMovieById(mediaId.toString());
|
_movie = await _movieRepository.getMovieById(mediaId.toString());
|
||||||
|
print('Movie loaded successfully: ${_movie?.title}');
|
||||||
} else {
|
} else {
|
||||||
_movie = await _movieRepository.getTvById(mediaId.toString());
|
_movie = await _movieRepository.getTvById(mediaId.toString());
|
||||||
|
print('TV show loaded successfully: ${_movie?.title}');
|
||||||
}
|
}
|
||||||
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -46,16 +53,20 @@ class MovieDetailProvider with ChangeNotifier {
|
|||||||
// Try to load IMDb ID (non-blocking)
|
// Try to load IMDb ID (non-blocking)
|
||||||
if (_movie != null) {
|
if (_movie != null) {
|
||||||
try {
|
try {
|
||||||
|
print('Loading IMDb ID for $mediaType $mediaId');
|
||||||
_imdbId = await _apiClient.getImdbId(mediaId.toString(), mediaType);
|
_imdbId = await _apiClient.getImdbId(mediaId.toString(), mediaType);
|
||||||
|
print('IMDb ID loaded: $_imdbId');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// IMDb ID loading failed, but don't fail the whole screen
|
// IMDb ID loading failed, but don't fail the whole screen
|
||||||
print('Failed to load IMDb ID: $e');
|
print('Failed to load IMDb ID: $e');
|
||||||
_imdbId = null;
|
_imdbId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
print('Error loading media: $e');
|
print('Error loading media: $e');
|
||||||
|
print('Stack trace: $stackTrace');
|
||||||
_error = e.toString();
|
_error = e.toString();
|
||||||
|
_stackTrace = stackTrace.toString();
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../providers/downloads_provider.dart';
|
import '../../providers/downloads_provider.dart';
|
||||||
|
import '../../widgets/error_display.dart';
|
||||||
import '../../../data/models/torrent_info.dart';
|
import '../../../data/models/torrent_info.dart';
|
||||||
import 'torrent_detail_screen.dart';
|
import 'torrent_detail_screen.dart';
|
||||||
import 'package:auto_route/auto_route.dart';
|
|
||||||
|
|
||||||
@RoutePage()
|
|
||||||
class DownloadsScreen extends StatefulWidget {
|
class DownloadsScreen extends StatefulWidget {
|
||||||
const DownloadsScreen({super.key});
|
const DownloadsScreen({super.key});
|
||||||
|
|
||||||
@@ -48,37 +47,13 @@ class _DownloadsScreenState extends State<DownloadsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider.error != null) {
|
if (provider.error != null) {
|
||||||
return Center(
|
return ErrorDisplay(
|
||||||
child: Column(
|
title: 'Ошибка загрузки торрентов',
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
error: provider.error!,
|
||||||
children: [
|
stackTrace: provider.stackTrace,
|
||||||
Icon(
|
onRetry: () {
|
||||||
Icons.error_outline,
|
provider.refreshDownloads();
|
||||||
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('Попробовать снова'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/favorites/favorites_screen.dart';
|
||||||
import 'package:neomovies_mobile/presentation/screens/search/search_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/home/home_screen.dart';
|
||||||
|
import 'package:neomovies_mobile/presentation/screens/downloads/downloads_screen.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class MainScreen extends StatefulWidget {
|
class MainScreen extends StatefulWidget {
|
||||||
@@ -30,7 +31,7 @@ class _MainScreenState extends State<MainScreen> {
|
|||||||
HomeScreen(),
|
HomeScreen(),
|
||||||
SearchScreen(),
|
SearchScreen(),
|
||||||
FavoritesScreen(),
|
FavoritesScreen(),
|
||||||
Center(child: Text('Downloads Page')),
|
DownloadsScreen(),
|
||||||
ProfileScreen(),
|
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/providers/movie_detail_provider.dart';
|
||||||
import 'package:neomovies_mobile/presentation/screens/player/video_player_screen.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/screens/torrent_selector/torrent_selector_screen.dart';
|
||||||
|
import 'package:neomovies_mobile/presentation/widgets/error_display.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class MovieDetailScreen extends StatefulWidget {
|
class MovieDetailScreen extends StatefulWidget {
|
||||||
@@ -89,7 +90,15 @@ class _MovieDetailScreenState extends State<MovieDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider.error != null) {
|
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) {
|
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