2025-07-13 14:01:29 +03:00
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:intl/intl.dart';
|
|
|
|
|
|
import 'package:neomovies_mobile/presentation/providers/auth_provider.dart';
|
|
|
|
|
|
import 'package:neomovies_mobile/presentation/providers/favorites_provider.dart';
|
|
|
|
|
|
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';
|
2025-07-19 18:13:13 +03:00
|
|
|
|
import 'package:neomovies_mobile/presentation/screens/torrent_selector/torrent_selector_screen.dart';
|
2025-07-13 14:01:29 +03:00
|
|
|
|
import 'package:provider/provider.dart';
|
|
|
|
|
|
|
|
|
|
|
|
class MovieDetailScreen extends StatefulWidget {
|
|
|
|
|
|
final String movieId;
|
|
|
|
|
|
final String mediaType;
|
|
|
|
|
|
|
|
|
|
|
|
const MovieDetailScreen({super.key, required this.movieId, this.mediaType = 'movie'});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
State<MovieDetailScreen> createState() => _MovieDetailScreenState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _MovieDetailScreenState extends State<MovieDetailScreen> {
|
|
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
|
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
|
|
|
|
// Load movie details and reactions
|
|
|
|
|
|
Provider.of<MovieDetailProvider>(context, listen: false).loadMedia(int.parse(widget.movieId), widget.mediaType);
|
|
|
|
|
|
Provider.of<ReactionsProvider>(context, listen: false).loadReactionsForMedia(widget.mediaType, widget.movieId);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-19 18:13:13 +03:00
|
|
|
|
void _openTorrentSelector(BuildContext context, String? imdbId, String title) {
|
|
|
|
|
|
if (imdbId == null || imdbId.isEmpty) {
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
const SnackBar(
|
|
|
|
|
|
content: Text('IMDB ID не найден. Невозможно загрузить торренты.'),
|
|
|
|
|
|
duration: Duration(seconds: 3),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
|
builder: (context) => TorrentSelectorScreen(
|
|
|
|
|
|
imdbId: imdbId,
|
|
|
|
|
|
mediaType: widget.mediaType,
|
|
|
|
|
|
title: title,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-13 14:01:29 +03:00
|
|
|
|
void _openPlayer(BuildContext context, String? imdbId, String title) {
|
|
|
|
|
|
if (imdbId == null || imdbId.isEmpty) {
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
const SnackBar(
|
|
|
|
|
|
content: Text('IMDB ID not found. Cannot open player.'),
|
|
|
|
|
|
duration: Duration(seconds: 3),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Navigator.of(context).push(
|
|
|
|
|
|
MaterialPageRoute(
|
|
|
|
|
|
builder: (context) => VideoPlayerScreen(
|
|
|
|
|
|
mediaId: imdbId,
|
|
|
|
|
|
mediaType: widget.mediaType,
|
|
|
|
|
|
title: title,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
|
|
|
|
|
|
|
return Scaffold(
|
|
|
|
|
|
appBar: AppBar(
|
|
|
|
|
|
backgroundColor: Colors.transparent,
|
|
|
|
|
|
elevation: 0,
|
|
|
|
|
|
),
|
|
|
|
|
|
body: Consumer<MovieDetailProvider>(
|
|
|
|
|
|
builder: (context, provider, child) {
|
|
|
|
|
|
if (provider.isLoading) {
|
|
|
|
|
|
return const Center(child: CircularProgressIndicator());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (provider.error != null) {
|
|
|
|
|
|
return Center(child: Text('Error: ${provider.error}'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (provider.movie == null) {
|
|
|
|
|
|
return const Center(child: Text('Movie not found'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final movie = provider.movie!;
|
|
|
|
|
|
|
|
|
|
|
|
return SingleChildScrollView(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
// Poster
|
|
|
|
|
|
Center(
|
|
|
|
|
|
child: ConstrainedBox(
|
|
|
|
|
|
constraints: const BoxConstraints(maxWidth: 300),
|
|
|
|
|
|
child: AspectRatio(
|
|
|
|
|
|
aspectRatio: 2 / 3,
|
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
|
child: CachedNetworkImage(
|
|
|
|
|
|
imageUrl: movie.fullPosterUrl,
|
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
|
placeholder: (context, url) => const Center(child: CircularProgressIndicator()),
|
|
|
|
|
|
errorWidget: (context, url, error) => const Icon(Icons.error),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
|
|
|
|
|
|
// Title
|
|
|
|
|
|
Text(
|
|
|
|
|
|
movie.title,
|
|
|
|
|
|
style: textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
|
|
|
|
|
|
|
// Tagline
|
|
|
|
|
|
if (movie.tagline != null && movie.tagline!.isNotEmpty)
|
|
|
|
|
|
Text(
|
|
|
|
|
|
movie.tagline!,
|
|
|
|
|
|
style: textTheme.titleMedium?.copyWith(color: textTheme.bodySmall?.color),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
|
|
|
|
|
|
// Meta Info
|
|
|
|
|
|
Wrap(
|
|
|
|
|
|
spacing: 8.0,
|
|
|
|
|
|
runSpacing: 4.0,
|
|
|
|
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Text('Рейтинг: ${movie.voteAverage?.toStringAsFixed(1) ?? 'N/A'}'),
|
|
|
|
|
|
const Text('|'),
|
|
|
|
|
|
if (movie.mediaType == 'tv')
|
|
|
|
|
|
Text('${movie.seasonsCount ?? '-'} сез., ${movie.episodesCount ?? '-'} сер.')
|
|
|
|
|
|
else if (movie.runtime != null)
|
|
|
|
|
|
Text('${movie.runtime} мин.'),
|
|
|
|
|
|
const Text('|'),
|
|
|
|
|
|
if (movie.releaseDate != null)
|
|
|
|
|
|
Text(DateFormat('d MMMM yyyy', 'ru').format(movie.releaseDate!)),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
|
|
|
|
|
|
// Genres
|
|
|
|
|
|
if (movie.genres != null && movie.genres!.isNotEmpty)
|
|
|
|
|
|
Wrap(
|
|
|
|
|
|
spacing: 8.0,
|
|
|
|
|
|
runSpacing: 8.0,
|
|
|
|
|
|
children: movie.genres!
|
|
|
|
|
|
.map((genre) => Chip(
|
|
|
|
|
|
label: Text(genre),
|
|
|
|
|
|
backgroundColor: colorScheme.secondaryContainer,
|
|
|
|
|
|
labelStyle: textTheme.bodySmall?.copyWith(color: colorScheme.onSecondaryContainer),
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0),
|
|
|
|
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
|
|
|
|
))
|
|
|
|
|
|
.toList(),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
|
|
|
|
|
|
// Reactions Section
|
|
|
|
|
|
_buildReactionsSection(context),
|
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
|
|
|
|
|
|
// Overview
|
|
|
|
|
|
Text(
|
|
|
|
|
|
'Описание',
|
|
|
|
|
|
style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
movie.overview ?? 'Описание недоступно.',
|
|
|
|
|
|
style: textTheme.bodyMedium,
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
|
|
|
|
|
|
// Action Buttons
|
|
|
|
|
|
Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Consumer<MovieDetailProvider>(
|
|
|
|
|
|
builder: (context, provider, child) {
|
|
|
|
|
|
final imdbId = provider.imdbId;
|
|
|
|
|
|
final isImdbLoading = provider.isImdbLoading;
|
|
|
|
|
|
|
|
|
|
|
|
return ElevatedButton.icon(
|
|
|
|
|
|
onPressed: (isImdbLoading || imdbId == null)
|
|
|
|
|
|
? null // Делаем кнопку неактивной во время загрузки или если нет ID
|
|
|
|
|
|
: () {
|
|
|
|
|
|
_openPlayer(context, imdbId, provider.movie!.title);
|
|
|
|
|
|
},
|
|
|
|
|
|
icon: isImdbLoading
|
|
|
|
|
|
? Container(
|
|
|
|
|
|
width: 24,
|
|
|
|
|
|
height: 24,
|
|
|
|
|
|
padding: const EdgeInsets.all(2.0),
|
|
|
|
|
|
child: const CircularProgressIndicator(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
strokeWidth: 3,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
: const Icon(Icons.play_arrow),
|
|
|
|
|
|
label: const Text('Смотреть'),
|
|
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
|
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
|
|
|
|
|
foregroundColor: Theme.of(context).colorScheme.onPrimary,
|
|
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(20),
|
|
|
|
|
|
),
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
|
|
|
|
).copyWith(
|
|
|
|
|
|
// Устанавливаем цвет для неактивного состояния
|
2025-07-19 18:13:13 +03:00
|
|
|
|
backgroundColor: WidgetStateProperty.resolveWith<Color?>(
|
|
|
|
|
|
(Set<WidgetState> states) {
|
|
|
|
|
|
if (states.contains(WidgetState.disabled)) {
|
2025-07-13 14:01:29 +03:00
|
|
|
|
return Colors.grey;
|
|
|
|
|
|
}
|
|
|
|
|
|
return Theme.of(context).colorScheme.primary;
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: 16),
|
|
|
|
|
|
Consumer<FavoritesProvider>(
|
|
|
|
|
|
builder: (context, favoritesProvider, child) {
|
|
|
|
|
|
final isFavorite = favoritesProvider.isFavorite(widget.movieId);
|
|
|
|
|
|
return IconButton(
|
|
|
|
|
|
onPressed: () {
|
|
|
|
|
|
final authProvider = context.read<AuthProvider>();
|
|
|
|
|
|
if (!authProvider.isAuthenticated) {
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
const SnackBar(
|
|
|
|
|
|
content: Text('Войдите в аккаунт, чтобы добавлять в избранное.'),
|
|
|
|
|
|
duration: Duration(seconds: 2),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isFavorite) {
|
|
|
|
|
|
favoritesProvider.removeFavorite(widget.movieId);
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
const SnackBar(
|
|
|
|
|
|
content: Text('Удалено из избранного'),
|
|
|
|
|
|
duration: Duration(seconds: 2),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
favoritesProvider.addFavorite(movie);
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
const SnackBar(
|
|
|
|
|
|
content: Text('Добавлено в избранное'),
|
|
|
|
|
|
duration: Duration(seconds: 2),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
icon: Icon(isFavorite ? Icons.favorite : Icons.favorite_border),
|
|
|
|
|
|
iconSize: 28,
|
|
|
|
|
|
style: IconButton.styleFrom(
|
|
|
|
|
|
backgroundColor: isFavorite ? Colors.red.withOpacity(0.1) : colorScheme.secondaryContainer,
|
|
|
|
|
|
foregroundColor: isFavorite ? Colors.red : colorScheme.onSecondaryContainer,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2025-07-19 18:13:13 +03:00
|
|
|
|
// Download button
|
|
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
|
Consumer<MovieDetailProvider>(
|
|
|
|
|
|
builder: (context, provider, child) {
|
|
|
|
|
|
final imdbId = provider.imdbId;
|
|
|
|
|
|
final isImdbLoading = provider.isImdbLoading;
|
|
|
|
|
|
|
|
|
|
|
|
return IconButton(
|
|
|
|
|
|
onPressed: (isImdbLoading || imdbId == null)
|
|
|
|
|
|
? null
|
|
|
|
|
|
: () => _openTorrentSelector(context, imdbId, movie.title),
|
|
|
|
|
|
icon: isImdbLoading
|
|
|
|
|
|
? const SizedBox(
|
|
|
|
|
|
width: 20,
|
|
|
|
|
|
height: 20,
|
|
|
|
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
|
|
|
|
)
|
|
|
|
|
|
: const Icon(Icons.download),
|
|
|
|
|
|
iconSize: 28,
|
|
|
|
|
|
style: IconButton.styleFrom(
|
|
|
|
|
|
backgroundColor: colorScheme.primaryContainer,
|
|
|
|
|
|
foregroundColor: colorScheme.onPrimaryContainer,
|
|
|
|
|
|
),
|
|
|
|
|
|
tooltip: 'Скачать торрент',
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2025-07-13 14:01:29 +03:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildReactionsSection(BuildContext context) {
|
|
|
|
|
|
final authProvider = context.watch<AuthProvider>();
|
|
|
|
|
|
|
|
|
|
|
|
// Define the reactions with their icons and backend types
|
|
|
|
|
|
// Map of UI reaction types to backend types and icons
|
|
|
|
|
|
final List<Map<String, dynamic>> reactions = [
|
|
|
|
|
|
{'uiType': 'like', 'backendType': 'fire', 'icon': Icons.local_fire_department},
|
|
|
|
|
|
{'uiType': 'nice', 'backendType': 'nice', 'icon': Icons.thumb_up_alt},
|
|
|
|
|
|
{'uiType': 'think', 'backendType': 'think', 'icon': Icons.psychology},
|
|
|
|
|
|
{'uiType': 'bore', 'backendType': 'bore', 'icon': Icons.sentiment_dissatisfied},
|
|
|
|
|
|
{'uiType': 'shit', 'backendType': 'shit', 'icon': Icons.thumb_down_alt},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
return Consumer<ReactionsProvider>(
|
|
|
|
|
|
builder: (context, provider, child) {
|
|
|
|
|
|
// Debug: Print current reaction data
|
|
|
|
|
|
// print('REACTIONS DEBUG:');
|
|
|
|
|
|
// print('- User reaction: ${provider.userReaction}');
|
|
|
|
|
|
// print('- Reaction counts: ${provider.reactionCounts}');
|
|
|
|
|
|
|
|
|
|
|
|
if (provider.isLoading && provider.reactionCounts.isEmpty) {
|
|
|
|
|
|
return const Center(child: CircularProgressIndicator());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (provider.error != null) {
|
|
|
|
|
|
return Center(child: Text('Error loading reactions: ${provider.error}'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Text(
|
|
|
|
|
|
'Реакции',
|
|
|
|
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
Row(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
|
|
|
|
children: reactions.map((reaction) {
|
|
|
|
|
|
final uiType = reaction['uiType'] as String;
|
|
|
|
|
|
final backendType = reaction['backendType'] as String;
|
|
|
|
|
|
final icon = reaction['icon'] as IconData;
|
|
|
|
|
|
final count = provider.reactionCounts[backendType] ?? 0;
|
|
|
|
|
|
final isSelected = provider.userReaction == backendType;
|
|
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
IconButton(
|
|
|
|
|
|
icon: Icon(icon),
|
|
|
|
|
|
iconSize: 28,
|
|
|
|
|
|
color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
|
|
|
|
|
onPressed: () {
|
|
|
|
|
|
if (!authProvider.isAuthenticated) {
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
const SnackBar(
|
|
|
|
|
|
content: Text('Login to your account to leave a reaction.'),
|
|
|
|
|
|
duration: Duration(seconds: 2),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
provider.setReaction(widget.mediaType, widget.movieId, backendType);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
count.toString(),
|
|
|
|
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
|
|
|
|
color: isSelected ? Theme.of(context).colorScheme.primary : null,
|
|
|
|
|
|
fontWeight: isSelected ? FontWeight.bold : null,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}).toList(),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|