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 : import '../pages/details/movie_details_page.dart';
7 :
8 : import '../../domain/entities/models/mdl_the_movie.dart';
9 :
10 : /// Displays a card for a single movie, including poster, title, release date, rating, and favorite button.
11 : ///
12 : /// The `MovieCard` widget presents movie information in a visually rich card format.
13 : /// It supports navigation to the details page, toggling favorite status, and provides feedback via SnackBar.
14 : ///
15 : /// ### Parameters
16 : /// - [movie]: The movie to display (required).
17 : /// - [onTap]: Optional callback for custom tap behavior. Defaults to navigation to details page.
18 : ///
19 : /// ### Visual States
20 : /// - Shows poster image or a placeholder if not available.
21 : /// - Displays a favorite button, reflecting current favorite status.
22 : /// - Shows movie rating as a badge if available.
23 : /// - Presents title and release year.
24 : ///
25 : /// ### Interactions
26 : /// - Tapping the card navigates to the movie details page (unless [onTap] is provided).
27 : /// - Tapping the favorite button toggles favorite status and shows a SnackBar.
28 : ///
29 : /// ### Usage
30 : /// Use this widget in grids or lists to display movie items with interactive features.
31 : class MovieCard extends StatelessWidget {
32 : final TheMovie movie;
33 : final VoidCallback? onTap;
34 :
35 3 : const MovieCard({super.key, required this.movie, this.onTap});
36 :
37 3 : @override
38 : Widget build(BuildContext context) {
39 3 : return Card(
40 : elevation: 4,
41 6 : shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
42 3 : child: InkWell(
43 3 : borderRadius: BorderRadius.circular(12),
44 5 : onTap: onTap ?? () => _navigateToDetails(context),
45 3 : child: Column(
46 : crossAxisAlignment: CrossAxisAlignment.start,
47 3 : children: [
48 : // Movie Poster
49 3 : Expanded(
50 : flex: 3,
51 3 : child: Stack(
52 3 : children: [
53 : // Poster Image
54 3 : Container(
55 : width: double.infinity,
56 : decoration: const BoxDecoration(
57 : borderRadius: BorderRadius.vertical(
58 : top: Radius.circular(12),
59 : ),
60 : ),
61 3 : child: ClipRRect(
62 : borderRadius: const BorderRadius.vertical(
63 : top: Radius.circular(12),
64 : ),
65 3 : child: Hero(
66 9 : tag: 'movie_poster_${movie.id}',
67 3 : child: _buildPosterImage(),
68 : ),
69 : ),
70 : ),
71 :
72 : // Favorite Button
73 3 : Positioned(
74 : top: 8,
75 : right: 8,
76 3 : child: Consumer<FavoritesProvider>(
77 3 : builder: (context, favoritesProvider, child) {
78 3 : final isFavorite = favoritesProvider.isFavorite(
79 6 : movie.id,
80 : );
81 3 : return Container(
82 3 : decoration: BoxDecoration(
83 3 : color: Colors.black.withValues(alpha: 0.6),
84 : shape: BoxShape.circle,
85 : ),
86 3 : child: IconButton(
87 3 : icon: Icon(
88 : isFavorite
89 : ? Icons.favorite
90 : : Icons.favorite_border,
91 : color: isFavorite ? Colors.red : Colors.white,
92 : size: 20,
93 : ),
94 2 : onPressed: () => _toggleFavorite(context),
95 : padding: const EdgeInsets.all(4),
96 : constraints: const BoxConstraints(
97 : minWidth: 32,
98 : minHeight: 32,
99 : ),
100 : ),
101 : );
102 : },
103 : ),
104 : ),
105 :
106 : // Rating Badge
107 9 : if (movie.voteAverage > 0)
108 1 : Positioned(
109 : top: 8,
110 : left: 8,
111 1 : child: Container(
112 : padding: const EdgeInsets.symmetric(
113 : horizontal: 6,
114 : vertical: 2,
115 : ),
116 1 : decoration: BoxDecoration(
117 1 : color: Colors.black.withValues(alpha: 0.7),
118 1 : borderRadius: BorderRadius.circular(8),
119 : ),
120 1 : child: Row(
121 : mainAxisSize: MainAxisSize.min,
122 1 : children: [
123 : const Icon(
124 : Icons.star,
125 : color: Colors.amber,
126 : size: 12,
127 : ),
128 : const SizedBox(width: 2),
129 1 : Text(
130 3 : movie.voteAverage.toStringAsFixed(1),
131 : style: const TextStyle(
132 : color: Colors.white,
133 : fontSize: 10,
134 : fontWeight: FontWeight.bold,
135 : ),
136 : ),
137 : ],
138 : ),
139 : ),
140 : ),
141 : ],
142 : ),
143 : ),
144 :
145 : // Movie Info
146 3 : Expanded(
147 : flex: 1,
148 3 : child: Padding(
149 : padding: const EdgeInsets.all(8.0),
150 3 : child: Column(
151 : crossAxisAlignment: CrossAxisAlignment.start,
152 3 : children: [
153 : // Title - Flexible para adaptarse al espacio
154 3 : Flexible(
155 3 : child: Text(
156 6 : movie.title,
157 12 : style: Theme.of(context).textTheme.titleSmall?.copyWith(
158 : fontWeight: FontWeight.w600,
159 : ),
160 : maxLines: 2,
161 : overflow: TextOverflow.ellipsis,
162 : ),
163 : ),
164 :
165 : // Spacing between title and date
166 : const SizedBox(height: 4),
167 :
168 : // Release Date - Siempre visible en la parte inferior
169 9 : if (movie.releaseDate.isNotEmpty)
170 1 : Text(
171 3 : _formatReleaseDate(movie.releaseDate),
172 4 : style: Theme.of(context).textTheme.bodySmall?.copyWith(
173 1 : color: Colors.grey[600],
174 : ),
175 : maxLines: 1,
176 : overflow: TextOverflow.ellipsis,
177 : ),
178 : ],
179 : ),
180 : ),
181 : ),
182 : ],
183 : ),
184 : ),
185 : );
186 : }
187 :
188 3 : Widget _buildPosterImage() {
189 9 : if (movie.posterPath.isNotEmpty) {
190 1 : return CachedNetworkImage(
191 3 : imageUrl: 'https://image.tmdb.org/t/p/w500${movie.posterPath}',
192 : fit: BoxFit.cover,
193 2 : placeholder: (context, url) => _buildPlaceholder(),
194 2 : errorWidget: (context, url, error) => _buildPlaceholder(),
195 : );
196 : } else {
197 3 : return _buildPlaceholder();
198 : }
199 : }
200 :
201 3 : Widget _buildPlaceholder() {
202 3 : return Container(
203 : width: double.infinity,
204 3 : color: Colors.grey[300],
205 : child: const Column(
206 : mainAxisAlignment: MainAxisAlignment.center,
207 : children: [
208 : Icon(Icons.movie, size: 40, color: Colors.grey),
209 : SizedBox(height: 4),
210 : Text(
211 : 'Sin imagen',
212 : style: TextStyle(color: Colors.grey, fontSize: 12),
213 : ),
214 : ],
215 : ),
216 : );
217 : }
218 :
219 1 : String _formatReleaseDate(String releaseDate) {
220 : try {
221 1 : final date = DateTime.parse(releaseDate);
222 2 : return '${date.year}';
223 : } catch (e) {
224 : return releaseDate;
225 : }
226 : }
227 :
228 1 : void _navigateToDetails(BuildContext context) {
229 1 : Navigator.push(
230 : context,
231 1 : PageRouteBuilder(
232 1 : pageBuilder: (context, animation, secondaryAnimation) =>
233 2 : MovieDetailsPage(movie: movie),
234 : transitionDuration: const Duration(milliseconds: 800),
235 : reverseTransitionDuration: const Duration(milliseconds: 600),
236 1 : transitionsBuilder: (context, animation, secondaryAnimation, child) {
237 : // Curva elástica para efecto de rebote
238 1 : final curvedAnimation = CurvedAnimation(
239 : parent: animation,
240 : curve: Curves.elasticOut,
241 : reverseCurve: Curves.easeInOut,
242 : );
243 :
244 1 : return FadeTransition(opacity: curvedAnimation, child: child);
245 : },
246 : ),
247 : );
248 : }
249 :
250 1 : void _toggleFavorite(BuildContext context) {
251 : // Check state BEFORE toggling
252 2 : final wasAlreadyFavorite = context.read<FavoritesProvider>().isFavorite(
253 2 : movie.id,
254 : );
255 :
256 3 : context.read<FavoritesProvider>().toggleFavorite(movie);
257 :
258 : // Show snackbar feedback with correct message
259 2 : ScaffoldMessenger.of(context).showSnackBar(
260 1 : SnackBar(
261 1 : content: Text(
262 : wasAlreadyFavorite
263 2 : ? '${movie.title} eliminado de favoritos'
264 4 : : '${movie.title} agregado a favoritos',
265 : ),
266 : duration: const Duration(seconds: 2),
267 : ),
268 : );
269 : }
270 : }
|