From 426e76374235ff2674107593998d112d01c8bb8b Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Thu, 11 Dec 2025 23:16:18 +0100 Subject: [PATCH] feat: add `MobileMoviesTab` for paginated movie display, search, and playback, introducing `TvFocusable` for enhanced navigation. --- .../iptv/widgets/mobile_movies_tab.dart | 25 ++-- .../iptv/widgets/mobile_series_tab.dart | 18 +-- .../iptv/widgets/mobile_settings_tab.dart | 2 +- lib/mobile/widgets/mobile_poster_card.dart | 123 ++++++++++++++++++ lib/mobile/widgets/tv_focusable.dart | 19 ++- 5 files changed, 161 insertions(+), 26 deletions(-) create mode 100644 lib/mobile/widgets/mobile_poster_card.dart diff --git a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart index fd5c682..110a006 100644 --- a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xtremflow/mobile/widgets/tv_focusable.dart'; +import 'package:xtremflow/mobile/widgets/mobile_poster_card.dart'; import '../../../../core/models/playlist_config.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/widgets/components/hero_carousel.dart'; @@ -338,19 +339,15 @@ class _MobileMoviesTabState extends ConsumerState with Automati final movie = displayMovies[index]; final isWatched = watchHistory.isMovieWatched(movie.streamId); - return TVFocusable( - onPressed: () => _playMovie(context, movie), - child: MediaCard( - title: movie.name, - imageUrl: _getImageUrl(movie.streamIcon), - subtitle: movie.rating != null ? '${_formatRating(movie.rating)} ★' : null, - rating: _formatRating(movie.rating), - isWatched: isWatched, - onTap: () => _playMovie(context, movie), - onLongPress: () { - ref.read(mobileWatchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); - }, - ), + return MobilePosterCard( + title: movie.name, + imageUrl: _getImageUrl(movie.streamIcon), + rating: _formatRating(movie.rating), + isWatched: isWatched, + onTap: () => _playMovie(context, movie), + onLongPress: () { + ref.read(mobileWatchHistoryProvider.notifier).toggleMovieWatched(movie.streamId); + }, ); }, childCount: displayMovies.length, @@ -373,3 +370,5 @@ class _MobileMoviesTabState extends ConsumerState with Automati } } + + diff --git a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart index ca85194..66d2bd9 100644 --- a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xtremflow/mobile/widgets/tv_focusable.dart'; +import 'package:xtremflow/mobile/widgets/mobile_poster_card.dart'; import '../../../../core/models/playlist_config.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/widgets/components/hero_carousel.dart'; @@ -313,17 +314,12 @@ class _MobileSeriesTabState extends ConsumerState with Automati if (index >= displaySeries.length) return null; final series = displaySeries[index]; - return TVFocusable( - onPressed: () => _openSeries(series), - child: MediaCard( - title: series.name, - imageUrl: _getImageUrl(series.cover), - - subtitle: series.rating != null ? '${_formatRating(series.rating)} ★' : null, - rating: _formatRating(series.rating), - placeholderIcon: Icons.tv, - onTap: () => _openSeries(series), - ), + return MobilePosterCard( + title: series.name, + imageUrl: _getImageUrl(series.cover), + rating: _formatRating(series.rating), + placeholderIcon: Icons.tv, + onTap: () => _openSeries(series), ); }, childCount: displaySeries.length, diff --git a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart index 0822a18..9ada6fb 100644 --- a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart @@ -96,7 +96,7 @@ class _MobileSettingsTabState extends ConsumerState { ), ), Text( - 'Version 1.2.3', + 'Version 1.2.4', style: GoogleFonts.inter( fontSize: 13, color: AppColors.textSecondary, diff --git a/lib/mobile/widgets/mobile_poster_card.dart b/lib/mobile/widgets/mobile_poster_card.dart new file mode 100644 index 0000000..ac91c74 --- /dev/null +++ b/lib/mobile/widgets/mobile_poster_card.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:xtremflow/core/theme/app_colors.dart'; +import 'package:xtremflow/mobile/widgets/tv_focusable.dart'; + +class MobilePosterCard extends StatelessWidget { + final String title; + final String? imageUrl; + final String? rating; + final VoidCallback onTap; + final VoidCallback? onLongPress; + final bool isWatched; + final IconData placeholderIcon; + + const MobilePosterCard({ + super.key, + required this.title, + required this.imageUrl, + this.rating, + required this.onTap, + this.onLongPress, + this.isWatched = false, + this.placeholderIcon = Icons.movie, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Focusable Poster + Expanded( + child: TVFocusable( + onPressed: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, + children: [ + // Image + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: imageUrl != null && imageUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: imageUrl!, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => Container( + color: AppColors.surface, + child: Icon(placeholderIcon, color: Colors.white38), + ), + placeholder: (_, __) => Container( + color: AppColors.surface, + child: Icon(placeholderIcon, color: Colors.white24), + ), + ) + : Container( + color: AppColors.surface, + child: Icon(placeholderIcon, color: Colors.white38), + ), + ), + + // Overlays + if (rating != null) + Positioned( + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + children: [ + const Icon(Icons.star, color: Colors.amber, size: 10), + const SizedBox(width: 2), + Text( + rating!, + style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + + if (isWatched) + Positioned( + top: 6, + right: 6, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + ), + child: const Icon(Icons.check, size: 10, color: Colors.white), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + // Title + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + title, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } +} diff --git a/lib/mobile/widgets/tv_focusable.dart b/lib/mobile/widgets/tv_focusable.dart index 2132d1b..beb4932 100644 --- a/lib/mobile/widgets/tv_focusable.dart +++ b/lib/mobile/widgets/tv_focusable.dart @@ -147,10 +147,27 @@ class _TVFocusableState extends State { begin: 1.0, end: _isFocused ? widget.scale : 1.0, ), - builder: (context, scale, child) { + builder: (context, double scale, child) { return Transform.scale( scale: scale, child: Container( + decoration: _isFocused + ? BoxDecoration( + borderRadius: widget.borderRadius ?? BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.5), // Shadow for depth + blurRadius: 10, + offset: const Offset(0, 4), + ), + BoxShadow( + color: widget.focusColor.withOpacity(0.3), // Glow effect + blurRadius: 12, + spreadRadius: 2, + ), + ], + ) + : null, foregroundDecoration: _isFocused ? BoxDecoration( borderRadius: widget.borderRadius ?? BorderRadius.circular(12),