Line data Source code
1 : import 'package:flutter/material.dart';
2 : import 'package:provider/provider.dart';
3 : import 'package:cached_network_image/cached_network_image.dart';
4 :
5 : import '../../providers/favorites_provider.dart';
6 :
7 : import '../../../domain/entities/models/mdl_the_movie.dart';
8 :
9 : /// Displays detailed information about a specific movie.
10 : ///
11 : /// The [MovieDetailsPage] widget shows the movie's poster, title, release date, genres, rating, and other details.
12 : /// It also allows users to mark the movie as a favorite and view additional information.
13 : ///
14 : /// ### Properties
15 : /// - [movie]: The [TheMovie] instance to display details for.
16 : ///
17 : /// ### Methods
18 : /// - [build]: Builds the widget tree for the movie details page.
19 : /// - [_buildBackdropImage]: Builds the backdrop image with Hero animation.
20 : /// - [_buildBackdropPlaceholder]: Builds a placeholder for the backdrop image.
21 : /// - [_buildPosterImage]: Builds the poster image.
22 : /// - [_buildPosterPlaceholder]: Builds a placeholder for the poster image.
23 : /// - [_buildInfoRow]: Builds a row for additional info.
24 : /// - [_formatReleaseDate]: Formats the release date string.
25 : /// - [_getGenreName]: Returns the genre name for a given genre ID.
26 : /// - [_toggleFavorite]: Toggles the favorite status and shows a SnackBar.
27 : ///
28 : /// ### Example
29 : /// ```dart
30 : /// MovieDetailsPage(movie: myMovie)
31 : /// ```
32 : class MovieDetailsPage extends StatelessWidget {
33 : /// The movie to display details for.
34 : final TheMovie movie;
35 :
36 : /// Creates a `MovieDetailsPage` instance.
37 : ///
38 : /// **Parameters:**
39 : /// - `movie` (TheMovie): The movie to display details for.
40 3 : const MovieDetailsPage({super.key, required this.movie});
41 :
42 2 : @override
43 : /// Builds the widget tree for the movie details page.
44 : ///
45 : /// **Parameters:**
46 : /// - `context` (BuildContext): The build context.
47 : ///
48 : /// **Returns:**
49 : /// - A `Widget` representing the movie details page.
50 : Widget build(BuildContext context) {
51 2 : return Scaffold(
52 2 : body: CustomScrollView(
53 2 : slivers: [
54 : /// App Bar with backdrop image
55 2 : SliverAppBar(
56 : expandedHeight: 300,
57 : pinned: true,
58 4 : flexibleSpace: FlexibleSpaceBar(background: _buildBackdropImage()),
59 2 : actions: [
60 2 : Consumer<FavoritesProvider>(
61 2 : builder: (context, favoritesProvider, child) {
62 6 : final isFavorite = favoritesProvider.isFavorite(movie.id);
63 2 : return Container(
64 : margin: const EdgeInsets.only(right: 8),
65 2 : decoration: BoxDecoration(
66 2 : color: Colors.black.withValues(alpha: 0.6),
67 : shape: BoxShape.circle,
68 : ),
69 2 : child: IconButton(
70 2 : icon: Icon(
71 : isFavorite ? Icons.favorite : Icons.favorite_border,
72 : color: isFavorite ? Colors.red : Colors.white,
73 : ),
74 2 : onPressed: () => _toggleFavorite(context),
75 : ),
76 : );
77 : },
78 : ),
79 : ],
80 : ),
81 :
82 : /// Movie details content
83 2 : SliverToBoxAdapter(
84 2 : child: Padding(
85 : padding: const EdgeInsets.all(16.0),
86 2 : child: Column(
87 : crossAxisAlignment: CrossAxisAlignment.start,
88 2 : children: [
89 : // Movie poster and basic info
90 2 : Row(
91 : crossAxisAlignment: CrossAxisAlignment.start,
92 2 : children: [
93 : // Poster
94 2 : Container(
95 : width: 120,
96 : height: 180,
97 2 : decoration: BoxDecoration(
98 2 : borderRadius: BorderRadius.circular(12),
99 2 : boxShadow: [
100 2 : BoxShadow(
101 2 : color: Colors.black.withValues(alpha: 0.3),
102 : blurRadius: 8,
103 : offset: const Offset(0, 4),
104 : ),
105 : ],
106 : ),
107 2 : child: ClipRRect(
108 2 : borderRadius: BorderRadius.circular(12),
109 2 : child: _buildPosterImage(),
110 : ),
111 : ),
112 : const SizedBox(width: 16),
113 :
114 : // Basic info
115 2 : Expanded(
116 2 : child: Column(
117 : crossAxisAlignment: CrossAxisAlignment.start,
118 2 : children: [
119 : // Title
120 2 : Text(
121 4 : movie.title,
122 6 : style: Theme.of(context).textTheme.headlineMedium
123 2 : ?.copyWith(fontWeight: FontWeight.bold),
124 : ),
125 : const SizedBox(height: 8),
126 :
127 : // Original title (if different)
128 10 : if (movie.originalTitle != movie.title)
129 2 : Text(
130 4 : movie.originalTitle,
131 6 : style: Theme.of(context).textTheme.titleMedium
132 2 : ?.copyWith(
133 2 : color: Colors.grey[600],
134 : fontStyle: FontStyle.italic,
135 : ),
136 : ),
137 2 : const SizedBox(height: 8),
138 :
139 : // Release date
140 6 : if (movie.releaseDate.isNotEmpty)
141 2 : Row(
142 2 : children: [
143 2 : Icon(
144 : Icons.calendar_month,
145 : size: 16,
146 2 : color: Colors.grey[600],
147 : ),
148 : const SizedBox(width: 4),
149 2 : Text(
150 6 : _formatReleaseDate(movie.releaseDate),
151 2 : style: Theme.of(
152 : context,
153 4 : ).textTheme.bodyMedium,
154 : ),
155 : ],
156 : ),
157 2 : const SizedBox(height: 8),
158 :
159 : // Rating
160 6 : if (movie.voteAverage > 0)
161 2 : Row(
162 2 : children: [
163 : const Icon(
164 : Icons.star,
165 : color: Colors.amber,
166 : size: 20,
167 : ),
168 : const SizedBox(width: 4),
169 2 : Text(
170 8 : '${movie.voteAverage.toStringAsFixed(1)}/10',
171 2 : style: Theme.of(context)
172 2 : .textTheme
173 2 : .titleMedium
174 2 : ?.copyWith(fontWeight: FontWeight.w600),
175 : ),
176 : const SizedBox(width: 8),
177 2 : Text(
178 6 : '(${movie.voteCount} votos)',
179 6 : style: Theme.of(context).textTheme.bodySmall
180 4 : ?.copyWith(color: Colors.grey[600]),
181 : ),
182 : ],
183 : ),
184 2 : const SizedBox(height: 8),
185 :
186 : // Adult content indicator
187 4 : if (movie.adult)
188 1 : Container(
189 : padding: const EdgeInsets.symmetric(
190 : horizontal: 8,
191 : vertical: 4,
192 : ),
193 1 : decoration: BoxDecoration(
194 : color: Colors.red,
195 1 : borderRadius: BorderRadius.circular(4),
196 : ),
197 : child: const Text(
198 : '18+',
199 : style: TextStyle(
200 : color: Colors.white,
201 : fontSize: 12,
202 : fontWeight: FontWeight.bold,
203 : ),
204 : ),
205 : ),
206 : ],
207 : ),
208 : ),
209 : ],
210 : ),
211 : const SizedBox(height: 24),
212 :
213 : // Overview section
214 7 : if (movie.overview.isNotEmpty) ...[
215 1 : Text(
216 : 'Sinopsis',
217 4 : style: Theme.of(context).textTheme.titleLarge?.copyWith(
218 : fontWeight: FontWeight.bold,
219 : ),
220 : ),
221 : const SizedBox(height: 8),
222 1 : Text(
223 2 : movie.overview,
224 1 : style: Theme.of(
225 : context,
226 3 : ).textTheme.bodyLarge?.copyWith(height: 1.5),
227 : textAlign: TextAlign.justify,
228 : ),
229 : const SizedBox(height: 24),
230 : ],
231 :
232 : // Genres section
233 7 : if (movie.genreIds.isNotEmpty) ...[
234 1 : Text(
235 : 'Géneros',
236 4 : style: Theme.of(context).textTheme.titleLarge?.copyWith(
237 : fontWeight: FontWeight.bold,
238 : ),
239 : ),
240 : const SizedBox(height: 8),
241 1 : Wrap(
242 : spacing: 8,
243 : runSpacing: 8,
244 4 : children: movie.genreIds.map((genreId) {
245 1 : return Chip(
246 2 : label: Text(_getGenreName(genreId)),
247 1 : backgroundColor: Theme.of(
248 : context,
249 3 : ).colorScheme.primary.withValues(alpha: 0.1),
250 : );
251 1 : }).toList(),
252 : ),
253 : const SizedBox(height: 24),
254 : ],
255 :
256 : // Additional info section
257 2 : Text(
258 : 'Información adicional',
259 8 : style: Theme.of(context).textTheme.titleLarge?.copyWith(
260 : fontWeight: FontWeight.bold,
261 : ),
262 : ),
263 2 : const SizedBox(height: 8),
264 2 : _buildInfoRow(
265 : 'Idioma original',
266 6 : movie.originalLanguage.toUpperCase(),
267 : ),
268 2 : _buildInfoRow(
269 : 'Popularidad',
270 6 : movie.popularity.toStringAsFixed(1),
271 : ),
272 4 : if (movie.video) _buildInfoRow('Video disponible', 'Sí'),
273 : ],
274 : ),
275 : ),
276 : ),
277 : ],
278 : ),
279 : );
280 : }
281 :
282 2 : Widget _buildBackdropImage() {
283 6 : if (movie.backdropPath.isNotEmpty) {
284 1 : return Hero(
285 3 : tag: 'movie_poster_${movie.id}',
286 : flightShuttleBuilder:
287 1 : (
288 : BuildContext flightContext,
289 : Animation<double> animation,
290 : HeroFlightDirection flightDirection,
291 : BuildContext fromHeroContext,
292 : BuildContext toHeroContext,
293 : ) {
294 : // Animación de escala durante el vuelo
295 : final scaleAnimation =
296 1 : Tween<double>(
297 1 : begin: flightDirection == HeroFlightDirection.push
298 : ? 1.0
299 : : 1.2,
300 1 : end: flightDirection == HeroFlightDirection.push
301 : ? 1.2
302 : : 1.0,
303 1 : ).animate(
304 1 : CurvedAnimation(
305 : parent: animation,
306 1 : curve: flightDirection == HeroFlightDirection.push
307 : ? Curves.elasticOut
308 : : Curves.easeInOut,
309 : ),
310 : );
311 :
312 1 : return AnimatedBuilder(
313 : animation: scaleAnimation,
314 1 : builder: (context, child) {
315 1 : return Transform.scale(
316 1 : scale: scaleAnimation.value,
317 1 : child: Material(
318 : color: Colors.transparent,
319 1 : child: CachedNetworkImage(
320 : imageUrl:
321 3 : 'https://image.tmdb.org/t/p/w1280${movie.backdropPath}',
322 : fit: BoxFit.cover,
323 1 : placeholder: (context, url) =>
324 1 : _buildBackdropPlaceholder(),
325 : ),
326 : ),
327 : );
328 : },
329 : );
330 : },
331 1 : child: CachedNetworkImage(
332 3 : imageUrl: 'https://image.tmdb.org/t/p/w1280${movie.backdropPath}',
333 : fit: BoxFit.cover,
334 2 : placeholder: (context, url) => _buildBackdropPlaceholder(),
335 2 : errorWidget: (context, url, error) => _buildBackdropPlaceholder(),
336 : ),
337 : );
338 : } else {
339 2 : return Hero(
340 6 : tag: 'movie_poster_${movie.id}',
341 2 : child: Material(
342 : color: Colors.transparent,
343 2 : child: _buildBackdropPlaceholder(),
344 : ),
345 : );
346 : }
347 : }
348 :
349 2 : Widget _buildBackdropPlaceholder() {
350 2 : return Container(
351 2 : color: Colors.grey[300],
352 : child: const Center(
353 : child: Icon(Icons.movie, size: 80, color: Colors.grey),
354 : ),
355 : );
356 : }
357 :
358 2 : Widget _buildPosterImage() {
359 6 : if (movie.posterPath.isNotEmpty) {
360 1 : return CachedNetworkImage(
361 3 : imageUrl: 'https://image.tmdb.org/t/p/w500${movie.posterPath}',
362 : fit: BoxFit.cover,
363 2 : placeholder: (context, url) => _buildPosterPlaceholder(),
364 2 : errorWidget: (context, url, error) => _buildPosterPlaceholder(),
365 : );
366 : } else {
367 2 : return _buildPosterPlaceholder();
368 : }
369 : }
370 :
371 2 : Widget _buildPosterPlaceholder() {
372 2 : return Container(
373 2 : color: Colors.grey[300],
374 : child: const Center(
375 : child: Icon(Icons.movie, size: 40, color: Colors.grey),
376 : ),
377 : );
378 : }
379 :
380 2 : Widget _buildInfoRow(String label, String value) {
381 2 : return Padding(
382 : padding: const EdgeInsets.symmetric(vertical: 4),
383 2 : child: Row(
384 : crossAxisAlignment: CrossAxisAlignment.start,
385 2 : children: [
386 2 : SizedBox(
387 : width: 120,
388 2 : child: Text(
389 2 : '$label:',
390 : style: const TextStyle(fontWeight: FontWeight.w500),
391 : ),
392 : ),
393 4 : Expanded(child: Text(value)),
394 : ],
395 : ),
396 : );
397 : }
398 :
399 2 : String _formatReleaseDate(String releaseDate) {
400 : try {
401 2 : final date = DateTime.parse(releaseDate);
402 2 : final months = [
403 : 'enero',
404 : 'febrero',
405 : 'marzo',
406 : 'abril',
407 : 'mayo',
408 : 'junio',
409 : 'julio',
410 : 'agosto',
411 : 'septiembre',
412 : 'octubre',
413 : 'noviembre',
414 : 'diciembre',
415 : ];
416 12 : return '${date.day} de ${months[date.month - 1]} de ${date.year}';
417 : } catch (e) {
418 : return releaseDate;
419 : }
420 : }
421 :
422 1 : String _getGenreName(int genreId) {
423 : // TMDB genre mapping for movies
424 : const genreMap = {
425 : 28: 'Acción',
426 : 12: 'Aventura',
427 : 16: 'Animación',
428 : 35: 'Comedia',
429 : 80: 'Crimen',
430 : 99: 'Documental',
431 : 18: 'Drama',
432 : 10751: 'Familiar',
433 : 14: 'Fantasía',
434 : 36: 'Historia',
435 : 27: 'Terror',
436 : 10402: 'Música',
437 : 9648: 'Misterio',
438 : 10749: 'Romance',
439 : 878: 'Ciencia ficción',
440 : 10770: 'TV Movie',
441 : 53: 'Thriller',
442 : 10752: 'Guerra',
443 : 37: 'Western',
444 : };
445 :
446 1 : return genreMap[genreId] ?? 'Desconocido';
447 : }
448 :
449 1 : void _toggleFavorite(BuildContext context) {
450 : // Check state BEFORE toggling
451 2 : final wasAlreadyFavorite = context.read<FavoritesProvider>().isFavorite(
452 2 : movie.id,
453 : );
454 :
455 3 : context.read<FavoritesProvider>().toggleFavorite(movie);
456 :
457 : // Show snackbar feedback with correct message
458 2 : ScaffoldMessenger.of(context).showSnackBar(
459 1 : SnackBar(
460 1 : content: Text(
461 : wasAlreadyFavorite
462 2 : ? '${movie.title} eliminado de favoritos'
463 4 : : '${movie.title} agregado a favoritos',
464 : ),
465 : duration: const Duration(seconds: 2),
466 : // action: SnackBarAction(
467 : // label: 'Deshacer',
468 : // onPressed: () {
469 : // context.read<FavoritesProvider>().toggleFavorite(movie);
470 : // },
471 : // ),
472 : ),
473 : );
474 : }
475 : }
|