From 9cc0f5eacb1f077aace59a9a20b13d277401996b Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 10 Dec 2025 23:35:05 +0100 Subject: [PATCH] feat: add `AppColors` class defining an Apple TV-inspired color palette and theme schemes. --- lib/core/theme/app_colors.dart | 8 +- .../iptv/services/xtream_service_mobile.dart | 24 +- .../iptv/screens/lite_player_screen.dart | 20 +- .../iptv/widgets/mobile_live_tv_tab.dart | 19 +- .../iptv/widgets/mobile_movies_tab.dart | 55 ++-- .../iptv/widgets/mobile_series_tab.dart | 59 ++-- .../iptv/widgets/mobile_settings_tab.dart | 308 ++++++++++++------ lib/mobile/widgets/mobile_scaffold.dart | 175 +++++++--- lib/mobile/widgets/tv_focusable.dart | 64 +++- task.md | 63 +++- 10 files changed, 549 insertions(+), 246 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 1d83c2c..be1cc16 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -36,13 +36,13 @@ class AppColors { end: Alignment.bottomRight, ); - /// Apple TV Style Background Gradient (Lighter Grey for categories) + /// Apple TV Style Background Gradient (Visible gradient) static const RadialGradient appleTvGradient = RadialGradient( center: Alignment.center, - radius: 2.0, + radius: 1.5, colors: [ - Color(0xFF3A3A3C), // Lighter Apple Gray - Color(0xFF1C1C1E), // Dark Gray (not pure black) + Color(0xFF2A2A2C), // Medium Gray center (visible) + Color(0xFF0A0A0A), // Near Black edge ], stops: [0.0, 1.0], ); diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index e77c3c6..e8b241f 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -23,17 +23,20 @@ class XtreamServiceMobile { Map? _cachedVodCategories; Map? _cachedSeriesCategories; + late final HiveCacheStore _cacheStore; + XtreamServiceMobile(String cachePath) { + _cacheStore = HiveCacheStore(cachePath); _dio = Dio(BaseOptions( connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 15), ),); - // Setup caching for API responses + // Setup caching for API responses - 24h cache for VOD content _cacheOptions = CacheOptions( - store: HiveCacheStore(cachePath), + store: _cacheStore, policy: CachePolicy.forceCache, - maxStale: const Duration(hours: 1), + maxStale: const Duration(hours: 24), // Cache for 24h as requested priority: CachePriority.high, ); @@ -556,6 +559,21 @@ class XtreamServiceMobile { _cachedSeriesCategories = null; } + /// Force refresh all cached data (clears both memory and Hive HTTP cache) + /// This will cause the next API calls to fetch fresh data from the server + Future forceRefreshCache() async { + // Clear in-memory cache + clearCache(); + + // Clear HTTP cache (Hive store) + try { + await _cacheStore.clean(); + print('XtreamServiceMobile: Cache cleared successfully'); + } catch (e) { + print('XtreamServiceMobile: Error clearing cache: $e'); + } + } + /// Dispose resources void dispose() { clearCache(); diff --git a/lib/mobile/features/iptv/screens/lite_player_screen.dart b/lib/mobile/features/iptv/screens/lite_player_screen.dart index 5195373..20a11c4 100644 --- a/lib/mobile/features/iptv/screens/lite_player_screen.dart +++ b/lib/mobile/features/iptv/screens/lite_player_screen.dart @@ -437,7 +437,7 @@ class _LitePlayerScreenState extends ConsumerState with Widget ), ), ), - Text('LITE PLAYER: $title', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + Text(title, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), const Spacer(), Text(_currentTime, style: const TextStyle(color: Colors.white70)), ], @@ -450,13 +450,8 @@ class _LitePlayerScreenState extends ConsumerState with Widget child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (widget.streamType == StreamType.live) ...[ - IconButton( - icon: const Icon(Icons.keyboard_arrow_up, color: Colors.white, size: 40), - onPressed: _playPrevious, - ), - const SizedBox(width: 32), - ] else ...[ + // Replay 10s button only for VOD + if (widget.streamType != StreamType.live) ...[ IconButton( icon: const Icon(Icons.replay_10, color: Colors.white, size: 40), onPressed: () => _seek(const Duration(seconds: -10)), @@ -474,13 +469,8 @@ class _LitePlayerScreenState extends ConsumerState with Widget ), ), - if (widget.streamType == StreamType.live) ...[ - const SizedBox(width: 32), - IconButton( - icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white, size: 40), - onPressed: _playNext, - ), - ] else ...[ + // Forward 10s button only for VOD + if (widget.streamType != StreamType.live) ...[ const SizedBox(width: 32), IconButton( icon: const Icon(Icons.forward_10, color: Colors.white, size: 40), diff --git a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart index d494913..e8016ad 100644 --- a/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_live_tv_tab.dart @@ -441,6 +441,9 @@ class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> { return TVFocusable( onPressed: widget.onTap, + onLongPress: () { + ref.read(mobileFavoritesProvider.notifier).toggle(widget.channel.streamId); + }, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(8), @@ -500,18 +503,12 @@ class _MobileChannelTileState extends ConsumerState<_MobileChannelTile> { ], ), ), - // Favorite Button - IconButton( - icon: Icon( - isFavorite ? Icons.favorite : Icons.favorite_border, - color: isFavorite ? Colors.red : AppColors.textSecondary, + // Favorite Indicator (no button, just icon) + if (isFavorite) + const Padding( + padding: EdgeInsets.only(right: 8), + child: Icon(Icons.favorite, color: Colors.red, size: 20), ), - onPressed: () { - ref.read(mobileFavoritesProvider.notifier).toggle(widget.channel.streamId); - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 40, minHeight: 40), - ), // Play Icon const Icon(Icons.play_circle_outline, color: AppColors.primary), ], diff --git a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart index 90a63e8..fd5c682 100644 --- a/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_movies_tab.dart @@ -238,17 +238,40 @@ class _MobileMoviesTabState extends ConsumerState with Automati controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ - // Header & Search + // Hero Section (reduced height, moved above search) + if (_searchQuery.isEmpty && heroItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: SizedBox( + height: 180, // Reduced from 250 + child: HeroCarousel( + items: heroItems, + onTap: (item) { + try { + final movie = _movies.firstWhere((element) => element.streamId == item.id); + _playMovie(context, movie); + } catch (e) { + // Fallback ignored + } + }, + ), + ), + ), + ), + + // Search Bar (moved below carousel) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(16), - child: GestureDetector( - onTap: () { + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TVFocusable( + onPressed: () { setState(() => _isSearchEditing = true); WidgetsBinding.instance.addPostFrameCallback((_) { _searchFocusNode.requestFocus(); }); }, + borderRadius: BorderRadius.circular(12), child: Container( height: 40, decoration: BoxDecoration( @@ -272,7 +295,7 @@ class _MobileMoviesTabState extends ConsumerState with Automati readOnly: !_isSearchEditing, style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), decoration: const InputDecoration( - hintText: 'Search Movies', + hintText: 'Rechercher un film...', border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.only(bottom: 11), @@ -298,28 +321,6 @@ class _MobileMoviesTabState extends ConsumerState with Automati ), ), ), - - // Hero Section - if (_searchQuery.isEmpty && heroItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: 16), - child: SizedBox( - height: 250, - child: HeroCarousel( - items: heroItems, - onTap: (item) { - try { - final movie = _movies.firstWhere((element) => element.streamId == item.id); - _playMovie(context, movie); - } catch (e) { - // Fallback ignored - } - }, - ), - ), - ), - ), // Grid SliverPadding( diff --git a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart index a801025..ca85194 100644 --- a/lib/mobile/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_series_tab.dart @@ -214,17 +214,40 @@ class _MobileSeriesTabState extends ConsumerState with Automati controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ - // Header & Search + // Hero Section (reduced height, moved above search) + if (_searchQuery.isEmpty && heroItems.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 8), + child: SizedBox( + height: 180, // Reduced from 250 + child: HeroCarousel( + items: heroItems, + onTap: (item) { + try { + final series = _series.firstWhere((s) => s.seriesId.toString() == item.id); + _openSeries(series); + } catch (e) { + // Fallback logic not critical + } + }, + ), + ), + ), + ), + + // Search Bar (moved below carousel) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(16), - child: GestureDetector( - onTap: () { + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TVFocusable( + onPressed: () { setState(() => _isSearchEditing = true); WidgetsBinding.instance.addPostFrameCallback((_) { _searchFocusNode.requestFocus(); }); }, + borderRadius: BorderRadius.circular(12), child: Container( height: 40, decoration: BoxDecoration( @@ -248,7 +271,7 @@ class _MobileSeriesTabState extends ConsumerState with Automati readOnly: !_isSearchEditing, style: const TextStyle(fontSize: 14, color: AppColors.textPrimary), decoration: const InputDecoration( - hintText: 'Search Series', + hintText: 'Rechercher une série...', border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.only(bottom: 11), @@ -274,32 +297,6 @@ class _MobileSeriesTabState extends ConsumerState with Automati ), ), ), - - // Hero Section - if (_searchQuery.isEmpty && heroItems.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: 16), - child: SizedBox( - height: 250, - child: HeroCarousel( - items: heroItems, - onTap: (item) { - // Find series by title fallback if ID logic differs (Series ID is int usually, Hero is Str) - // But here we rely on passing the correct string ID or using the closure directly if possible. - // HeroCarousel onTap returns HeroItem. - // We must map it back. - try { - final series = _series.firstWhere((s) => s.seriesId.toString() == item.id); - _openSeries(series); - } catch (e) { - // Fallback logic not critical for now, assume ID matches - } - }, - ), - ), - ), - ), // Grid SliverPadding( diff --git a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart index 68e59ce..1ebe352 100644 --- a/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/mobile/features/iptv/widgets/mobile_settings_tab.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import '../../../providers/mobile_settings_providers.dart'; import '../../../../core/providers/theme_provider.dart'; import '../../../../core/theme/app_colors.dart'; @@ -20,6 +22,7 @@ class _MobileSettingsTabState extends ConsumerState { late TextEditingController _moviesController; late TextEditingController _seriesController; bool _initialized = false; + bool _isRefreshingCache = false; @override void initState() { @@ -161,105 +164,143 @@ class _MobileSettingsTabState extends ConsumerState { // Playback Settings const _SectionHeader(title: 'Lecture Vidéo'), - Container( - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(16), + + // Player Engine + TVFocusable( + onPressed: () {}, + borderRadius: BorderRadius.circular(16), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + leading: const Icon(Icons.rocket_launch, color: AppColors.primary), + title: const Text('Moteur Vidéo', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: Text( + settings.playerEngine == 'ultra' ? 'Ultra (MPV) - Puissant' : 'Lite (ExoPlayer) - Léger', + style: const TextStyle(color: AppColors.textSecondary), + ), + trailing: DropdownButton( + value: settings.playerEngine, + dropdownColor: AppColors.surface, + underline: const SizedBox(), + icon: const Icon(Icons.arrow_drop_down, color: AppColors.primary), + items: const [ + DropdownMenuItem(value: 'ultra', child: Text('Ultra', style: TextStyle(color: AppColors.textPrimary))), + DropdownMenuItem(value: 'lite', child: Text('Lite', style: TextStyle(color: AppColors.textPrimary))), + ], + onChanged: (val) { + if (val != null) { + ref.read(mobileSettingsProvider.notifier).setPlayerEngine(val); + } + }, + ), + ), ), - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.rocket_launch, color: AppColors.primary), - title: const Text('Moteur Vidéo', - style: TextStyle(color: AppColors.textPrimary)), - subtitle: Text( - settings.playerEngine == 'ultra' ? 'Ultra (MPV) - Puissant' : 'Lite (ExoPlayer) - Léger', - style: const TextStyle(color: AppColors.textSecondary), - ), - trailing: DropdownButton( - value: settings.playerEngine, - dropdownColor: AppColors.surface, - underline: const SizedBox(), - icon: const Icon(Icons.arrow_drop_down, color: AppColors.primary), - items: const [ - DropdownMenuItem(value: 'ultra', child: Text('Ultra', style: TextStyle(color: AppColors.textPrimary))), - DropdownMenuItem(value: 'lite', child: Text('Lite', style: TextStyle(color: AppColors.textPrimary))), - ], - onChanged: (val) { - if (val != null) { - ref.read(mobileSettingsProvider.notifier).setPlayerEngine(val); - } - }, - ), + ), + const SizedBox(height: 8), + + // Debug Stats + TVFocusable( + onPressed: () => ref.read(mobileSettingsProvider.notifier).toggleShowDebugStats(!settings.showDebugStats), + borderRadius: BorderRadius.circular(16), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + ), + child: SwitchListTile( + title: const Text('Stats pour Nerds', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: const Text('Infos techniques (FPS, Buffer)', + style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), + secondary: const Icon(Icons.bug_report, color: AppColors.primary), + value: settings.showDebugStats, + onChanged: (val) => ref.read(mobileSettingsProvider.notifier).toggleShowDebugStats(val), + activeColor: AppColors.primary, + ), + ), + ), + const SizedBox(height: 8), + + // Decoder Mode + TVFocusable( + onPressed: () {}, + borderRadius: BorderRadius.circular(16), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + leading: const Icon(Icons.memory, color: AppColors.primary), + title: const Text('Décodeur Vidéo', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: Text( + settings.decoderMode == 'auto' ? 'Automatique (Recommandé)' : + settings.decoderMode == 'mediacodec' ? 'Matériel (Hardware)' : 'Logiciel (Software)', + style: const TextStyle(color: AppColors.textSecondary), ), - Divider(height: 1, color: AppColors.textSecondary.withOpacity(0.2)), - SwitchListTile( - title: const Text('Stats pour Nerds', - style: TextStyle(color: AppColors.textPrimary)), - subtitle: const Text('Infos techniques (FPS, Buffer)', - style: TextStyle(color: AppColors.textSecondary, fontSize: 12)), - secondary: const Icon(Icons.bug_report, color: AppColors.primary), - value: settings.showDebugStats, - onChanged: (val) => ref.read(mobileSettingsProvider.notifier).toggleShowDebugStats(val), - activeColor: AppColors.primary, + trailing: DropdownButton( + value: settings.decoderMode, + dropdownColor: AppColors.surface, + underline: const SizedBox(), + icon: const Icon(Icons.arrow_drop_down, color: AppColors.primary), + items: const [ + DropdownMenuItem(value: 'auto', child: Text('Auto', style: TextStyle(color: AppColors.textPrimary))), + DropdownMenuItem(value: 'mediacodec', child: Text('Hardware', style: TextStyle(color: AppColors.textPrimary))), + DropdownMenuItem(value: 'no', child: Text('Software', style: TextStyle(color: AppColors.textPrimary))), + ], + onChanged: (val) { + if (val != null) { + ref.read(mobileSettingsProvider.notifier).setDecoderMode(val); + } + }, ), - Divider(height: 1, color: AppColors.textSecondary.withOpacity(0.2)), - ListTile( - leading: const Icon(Icons.memory, color: AppColors.primary), - title: const Text('Décodeur Vidéo', - style: TextStyle(color: AppColors.textPrimary)), - subtitle: Text( - settings.decoderMode == 'auto' ? 'Automatique (Recommandé)' : - settings.decoderMode == 'mediacodec' ? 'Matériel (Hardware)' : 'Logiciel (Software)', - style: const TextStyle(color: AppColors.textSecondary), - ), - trailing: DropdownButton( - value: settings.decoderMode, - dropdownColor: AppColors.surface, - underline: const SizedBox(), - icon: const Icon(Icons.arrow_drop_down, color: AppColors.primary), - items: const [ - DropdownMenuItem(value: 'auto', child: Text('Auto', style: TextStyle(color: AppColors.textPrimary))), - DropdownMenuItem(value: 'mediacodec', child: Text('Hardware', style: TextStyle(color: AppColors.textPrimary))), - DropdownMenuItem(value: 'no', child: Text('Software', style: TextStyle(color: AppColors.textPrimary))), - ], - onChanged: (val) { - if (val != null) { - ref.read(mobileSettingsProvider.notifier).setDecoderMode(val); - } - }, - ), + ), + ), + ), + const SizedBox(height: 8), + + // Buffer Duration + TVFocusable( + onPressed: () {}, + borderRadius: BorderRadius.circular(16), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + ), + child: ListTile( + leading: const Icon(Icons.speed, color: AppColors.primary), + title: const Text('Mise en mémoire tampon (Cache)', + style: TextStyle(color: AppColors.textPrimary)), + subtitle: Text( + settings.bufferDuration == 0 + ? 'Auto (Défaut)' + : '${settings.bufferDuration} secondes', + style: const TextStyle(color: AppColors.textSecondary), ), - Divider(height: 1, color: AppColors.textSecondary.withOpacity(0.2)), - ListTile( - leading: const Icon(Icons.speed, color: AppColors.primary), - title: const Text('Mise en mémoire tampon (Cache)', - style: TextStyle(color: AppColors.textPrimary)), - subtitle: Text( - settings.bufferDuration == 0 - ? 'Auto (Défaut)' - : '${settings.bufferDuration} secondes', - style: const TextStyle(color: AppColors.textSecondary), - ), - trailing: DropdownButton( - value: settings.bufferDuration, - dropdownColor: AppColors.surface, - underline: const SizedBox(), - icon: const Icon(Icons.arrow_drop_down, color: AppColors.primary), - items: const [ - DropdownMenuItem(value: 0, child: Text('Auto', style: TextStyle(color: AppColors.textPrimary))), - DropdownMenuItem(value: 15, child: Text('15s', style: TextStyle(color: AppColors.textPrimary))), - DropdownMenuItem(value: 30, child: Text('30s', style: TextStyle(color: AppColors.textPrimary))), - DropdownMenuItem(value: 60, child: Text('60s', style: TextStyle(color: AppColors.textPrimary))), - ], - onChanged: (val) { - if (val != null) { - ref.read(mobileSettingsProvider.notifier).setBufferDuration(val); - } - }, - ), + trailing: DropdownButton( + value: settings.bufferDuration, + dropdownColor: AppColors.surface, + underline: const SizedBox(), + icon: const Icon(Icons.arrow_drop_down, color: AppColors.primary), + items: const [ + DropdownMenuItem(value: 0, child: Text('Auto', style: TextStyle(color: AppColors.textPrimary))), + DropdownMenuItem(value: 15, child: Text('15s', style: TextStyle(color: AppColors.textPrimary))), + DropdownMenuItem(value: 30, child: Text('30s', style: TextStyle(color: AppColors.textPrimary))), + DropdownMenuItem(value: 60, child: Text('60s', style: TextStyle(color: AppColors.textPrimary))), + ], + onChanged: (val) { + if (val != null) { + ref.read(mobileSettingsProvider.notifier).setBufferDuration(val); + } + }, ), - ], + ), ), ), const SizedBox(height: 24), @@ -320,6 +361,85 @@ class _MobileSettingsTabState extends ConsumerState { ), const SizedBox(height: 24), + // Refresh Cache Section + const _SectionHeader(title: 'Cache'), + InkWell( + onTap: _isRefreshingCache ? null : () async { + setState(() => _isRefreshingCache = true); + + try { + // Clear the Hive HTTP cache directly + final dir = await getApplicationDocumentsDirectory(); + final cacheStore = HiveCacheStore(dir.path); + await cacheStore.clean(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cache actualisé ! Relancez l\'application pour charger les nouvelles données.'), + backgroundColor: Colors.green, + duration: Duration(seconds: 3), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur: $e'), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) setState(() => _isRefreshingCache = false); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _isRefreshingCache + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2) + ) + : const Icon(Icons.refresh, color: AppColors.primary), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Actualiser le cache', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 2), + Text( + 'Recharge les films, séries et EPG (cache 24h)', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + // Change Playlist InkWell( onTap: () { diff --git a/lib/mobile/widgets/mobile_scaffold.dart b/lib/mobile/widgets/mobile_scaffold.dart index 3928340..22bab29 100644 --- a/lib/mobile/widgets/mobile_scaffold.dart +++ b/lib/mobile/widgets/mobile_scaffold.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/theme/app_colors.dart'; -import 'tv_focusable.dart'; class MobileScaffold extends ConsumerWidget { final Widget child; @@ -21,29 +21,37 @@ class MobileScaffold extends ConsumerWidget { backgroundColor: AppColors.background, body: Column( children: [ - // Top Navigation Bar (TV Friendly) + // Top Navigation Bar (Apple TV Style) Container( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), decoration: BoxDecoration( - color: AppColors.surface, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.6), + Colors.transparent, + ], + ), ), child: SafeArea( bottom: false, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildNavItem(0, 'Live TV', Icons.tv), - _buildNavItem(1, 'Films', Icons.movie), - _buildNavItem(2, 'Séries', Icons.video_library), - _buildNavItem(3, 'Paramètres', Icons.settings), - ], + child: Container( + height: 50, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(30), + border: Border.all(color: Colors.white.withOpacity(0.05)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildNavItem(0, 'Live TV', Icons.tv), + _buildNavItem(1, 'Films', Icons.movie), + _buildNavItem(2, 'Séries', Icons.video_library), + _buildNavItem(3, 'Paramètres', Icons.settings), + ], + ), ), ), ), @@ -57,35 +65,112 @@ class MobileScaffold extends ConsumerWidget { Widget _buildNavItem(int index, String label, IconData icon) { final isSelected = currentIndex == index; - return TVFocusable( + + return _AppleTVNavItem( + isSelected: isSelected, + icon: icon, + label: label, onPressed: () => onIndexChanged(index), - scale: 1.1, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: isSelected ? AppColors.primary.withOpacity(0.15) : null, - borderRadius: BorderRadius.circular(8), - border: isSelected ? Border.all(color: AppColors.primary.withOpacity(0.5)) : null, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - color: isSelected ? AppColors.primary : AppColors.textSecondary, - size: 20, - ), - const SizedBox(width: 8), - Text( - label, - style: TextStyle( - color: isSelected ? AppColors.primary : AppColors.textSecondary, - fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, - fontSize: 14, + ); + } +} + +/// Apple TV style navigation item - matches desktop style +class _AppleTVNavItem extends StatefulWidget { + final bool isSelected; + final IconData icon; + final String label; + final VoidCallback onPressed; + + const _AppleTVNavItem({ + required this.isSelected, + required this.icon, + required this.label, + required this.onPressed, + }); + + @override + State<_AppleTVNavItem> createState() => _AppleTVNavItemState(); +} + +class _AppleTVNavItemState extends State<_AppleTVNavItem> { + final FocusNode _focusNode = FocusNode(); + bool _isFocused = false; + + @override + void initState() { + super.initState(); + _focusNode.addListener(_onFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _onFocusChange() { + setState(() => _isFocused = _focusNode.hasFocus); + } + + @override + Widget build(BuildContext context) { + // Desktop style: selected = white bg + black text, unselected = white70 text + final bool showWhiteBg = widget.isSelected || _isFocused; + final Color textColor = showWhiteBg ? Colors.black : Colors.white70; + final Color iconColor = showWhiteBg ? Colors.black : Colors.white70; + + return Focus( + focusNode: _focusNode, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + final key = event.logicalKey; + if (key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.space) { + widget.onPressed(); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: GestureDetector( + onTap: widget.onPressed, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4), + decoration: BoxDecoration( + // White pill background when selected OR focused + color: showWhiteBg ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(25), + // Subtle glow when focused but not selected + boxShadow: _isFocused && !widget.isSelected + ? [ + BoxShadow( + color: Colors.white.withOpacity(0.3), + blurRadius: 8, + spreadRadius: 1, + ), + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon, color: iconColor, size: 18), + const SizedBox(width: 6), + Text( + widget.label, + style: TextStyle( + color: textColor, + fontWeight: showWhiteBg ? FontWeight.w700 : FontWeight.w400, + fontSize: 13, + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/mobile/widgets/tv_focusable.dart b/lib/mobile/widgets/tv_focusable.dart index 26e90de..2132d1b 100644 --- a/lib/mobile/widgets/tv_focusable.dart +++ b/lib/mobile/widgets/tv_focusable.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../../../core/theme/app_colors.dart'; @@ -5,6 +6,7 @@ import '../../../../core/theme/app_colors.dart'; class TVFocusable extends StatefulWidget { final Widget child; final VoidCallback? onPressed; + final VoidCallback? onLongPress; final VoidCallback? onFocus; final FocusNode? focusNode; final bool autofocus; @@ -12,11 +14,13 @@ class TVFocusable extends StatefulWidget { final double borderWidth; final Color focusColor; final BorderRadius? borderRadius; + final Duration longPressDuration; const TVFocusable({ super.key, required this.child, this.onPressed, + this.onLongPress, this.onFocus, this.focusNode, this.autofocus = false, @@ -24,6 +28,7 @@ class TVFocusable extends StatefulWidget { this.borderWidth = 3.0, this.focusColor = Colors.white, this.borderRadius, + this.longPressDuration = const Duration(seconds: 3), }); @override @@ -33,6 +38,9 @@ class TVFocusable extends StatefulWidget { class _TVFocusableState extends State { late FocusNode _node; bool _isFocused = false; + Timer? _longPressTimer; + bool _isKeyDown = false; + bool _longPressTriggered = false; @override void initState() { @@ -43,6 +51,7 @@ class _TVFocusableState extends State { @override void dispose() { + _longPressTimer?.cancel(); if (widget.focusNode == null) { _node.dispose(); } else { @@ -66,7 +75,7 @@ class _TVFocusableState extends State { if (!mounted) return; Scrollable.ensureVisible( context, - alignment: 0.5, // Center the item + alignment: 0.5, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); @@ -77,27 +86,60 @@ class _TVFocusableState extends State { widget.onPressed?.call(); } + void _handleLongPress() { + widget.onLongPress?.call(); + } + @override Widget build(BuildContext context) { return Focus( focusNode: _node, autofocus: widget.autofocus, onKeyEvent: (node, event) { - if (event is KeyDownEvent) { - final key = event.logicalKey; - if (key == LogicalKeyboardKey.select || - key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.space || - key == LogicalKeyboardKey.gameButtonA || - key == LogicalKeyboardKey.numpadEnter) { - _handlePress(); - return KeyEventResult.handled; + final key = event.logicalKey; + final isSelectKey = key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.space || + key == LogicalKeyboardKey.gameButtonA || + key == LogicalKeyboardKey.numpadEnter; + + if (event is KeyDownEvent && isSelectKey) { + if (!_isKeyDown) { + _isKeyDown = true; + _longPressTriggered = false; + + // Start long press timer only if onLongPress is defined + if (widget.onLongPress != null) { + _longPressTimer?.cancel(); + _longPressTimer = Timer(widget.longPressDuration, () { + if (_isKeyDown && mounted) { + _longPressTriggered = true; + _handleLongPress(); + } + }); + } } + return KeyEventResult.handled; } + + if (event is KeyUpEvent && isSelectKey) { + _longPressTimer?.cancel(); + + // If long press wasn't triggered, it's a normal press + if (_isKeyDown && !_longPressTriggered) { + _handlePress(); + } + + _isKeyDown = false; + _longPressTriggered = false; + return KeyEventResult.handled; + } + return KeyEventResult.ignored; }, child: GestureDetector( onTap: _handlePress, + onLongPress: widget.onLongPress, child: TweenAnimationBuilder( duration: const Duration(milliseconds: 200), curve: Curves.easeOut, @@ -111,11 +153,11 @@ class _TVFocusableState extends State { child: Container( foregroundDecoration: _isFocused ? BoxDecoration( + borderRadius: widget.borderRadius ?? BorderRadius.circular(12), border: Border.all( color: widget.focusColor, width: widget.borderWidth, ), - borderRadius: widget.borderRadius ?? BorderRadius.circular(12), ) : null, child: child, diff --git a/task.md b/task.md index 6e216a8..b99efc9 100644 --- a/task.md +++ b/task.md @@ -1,6 +1,59 @@ -# Fix EPG Overlay Interaction +# Task: UI Improvements & Cache EPG Implementation -- [ ] Analyze `player_screen.dart` for overlap issues. -- [ ] Reproduce/Locate the code responsible for EPG overlay. -- [ ] Fix the touch event propagation issue. -- [x] Verify the fix. +## Demandes utilisateur + +1. Enlever les informations "LITE PLAYER:" à côté des titres de chaînes +2. Enlever les flèches de navigation sur le flux TV (live) +3. Settings : améliorer la visibilité de la sélection (entourage visible) +4. Live TV : remplacer les contours blancs par une ombre blanche voyante +5. Favoris : long press 3 sec sur OK pour ajouter/retirer (télécommande) +6. Films/Séries : réduire le carrousel, déplacer la barre de recherche en dessous +7. Traduire les barres de recherche en français +8. Cache EPG 24h pour Films et Séries (éviter rechargement au lancement) +9. Bouton dans Settings pour forcer la mise à jour du cache +10. Navigation top bar style Apple TV moderne (fond blanc au focus) + +## Progression + +### Phase 1 : Player UI + +- [x] 1.1 Retirer "LITE PLAYER:" du titre dans `lite_player_screen.dart` +- [x] 1.2 Enlever les flèches haut/bas (navigation channels) dans `lite_player_screen.dart` + +### Phase 2 : Settings Tab Focus + +- [x] 2.1 Améliorer la visibilité de la sélection dans `mobile_settings_tab.dart` (bordure visible) +- [x] 2.2 Ajouter bouton "Actualiser le cache" dans les Settings + +### Phase 3 : Live TV Focus Style + +- [x] 3.1 Modifier `tv_focusable.dart` pour utiliser une ombre blanche au lieu d'un contour + +### Phase 4 : Favoris Long Press (3 secondes) + +- [x] 4.1 Implémenter le long press 3 sec sur les chaînes pour toggle favoris + +### Phase 5 : Movies/Series UI + +- [x] 5.1 Réduire la hauteur du carrousel dans `mobile_movies_tab.dart` +- [x] 5.2 Réduire la hauteur du carrousel dans `mobile_series_tab.dart` +- [x] 5.3 Déplacer la barre de recherche sous le carrousel (movies) +- [x] 5.4 Déplacer la barre de recherche sous le carrousel (series) +- [x] 5.5 Traduire les placeholders de recherche en français + +### Phase 6 : Cache EPG 24h + +- [x] 6.1 Augmenter maxStale à 24h dans XtreamServiceMobile +- [x] 6.2 Ajouter méthode forceRefreshCache() +- [x] 6.3 Ajouter bouton refresh cache dans Settings + +### Phase 7 : Navigation Apple TV Style + +- [x] 7.1 Modifier top bar avec style Apple TV moderne (fond blanc au focus) + +## Notes techniques + +- Cache Dio HTTP configuré avec maxStale: 24h +- Cache en mémoire (`_cachedMoviesRaw`, `_cachedSeriesRaw`) vidé au redémarrage +- Le cache HTTP Hive persiste entre les sessions +- Bouton "Actualiser le cache" nettoie le cache Hive