fix: restore EPG display in live TV player and channel cards

- Fix EPG not showing in NativePlayerScreen by reusing the shared
  mobileXtreamServiceProvider (cache already populated by channel cards)
  instead of creating a fresh service instance per player session
- Add periodic EPG refresh timer (every 5 min) matching LitePlayerScreen
- Fix empty-title fallback in _buildEPGBox
- Force dark theme, invert default live TV player (NativePlayer default),
  remove next-program display from OSD
- Fix back button exit dialog firing from wrong tab (add tab index guards)
- Halve card grid count for Movies/Series (6 categories, 8 items)
- Revamp Movies/Series search bars to match Live TV aesthetic
- Fix first channel row clipping when entering a category (scroll-to-top)
- Fix playlist edit dialog TV remote navigation
- Fix empty playlist screen button readability
- Remove theme switcher from settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-03-29 10:04:47 +02:00
1 parent f07a6e91f9
commit 6bb0d73f38
8 files changed
+564 -350

No files matched your search

+24 -1
View File
@@ -19,7 +19,30 @@
"WebFetch(domain:www.color-hex.com)",
"WebFetch(domain:iosexample.com)",
"WebFetch(domain:blog.karenying.com)",
"WebFetch(domain:gist.github.com)"
"WebFetch(domain:gist.github.com)",
"Bash(~/.flutter/bin/flutter analyze:*)",
"Bash(where flutter:*)",
"Read(//c/Users/Michael/**)",
"Bash(/c/Users/Michael/Git/flutter/bin/flutter analyze:*)",
"Bash(/c/Users/Michael/Git/flutter/bin/flutter build:*)",
"Bash(echo \"ANDROID_HOME=$ANDROID_HOME\")",
"Bash(echo \"ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT\")",
"Bash(java -version)",
"Bash(where java:*)",
"Bash(echo \"JAVA_HOME=$JAVA_HOME\")",
"Bash(winget --version)",
"Bash(winget install:*)",
"Bash(export JAVA_HOME=\"/c/Program Files/Microsoft/jdk-21.0.10.7-hotspot\")",
"Bash(export PATH=\"$JAVA_HOME/bin:$PATH\")",
"Bash(export JAVA_HOME=\"/c/Program Files/Microsoft/jdk-21.0.10.7-hotspot\" export ANDROID_HOME=\"/c/Users/Michael/Android/Sdk\" export PATH=\"$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH\" __NEW_LINE_a84857a0b65daa44__ SDKMANAGER=\"/c/Users/Michael/Android/Sdk/cmdline-tools/latest/bin/sdkmanager.bat\" __NEW_LINE_a84857a0b65daa44__ yes)",
"Bash(\"$SDKMANAGER\" --licenses)",
"Bash(export ANDROID_HOME=\"/c/Users/Michael/Android/Sdk\")",
"Bash(SDKMANAGER=\"/c/Users/Michael/Android/Sdk/cmdline-tools/latest/bin/sdkmanager.bat\")",
"Bash(__NEW_LINE_633e55f3dba9c695__ \"$SDKMANAGER\" \"platform-tools\" \"platforms;android-35\" \"build-tools;35.0.0\")",
"Bash(export PATH=\"$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH\")",
"Bash(__NEW_LINE_fa059bc1c566f6ee__ cd:*)",
"Bash(__NEW_LINE_70fecd5c4080fe72__ cd:*)",
"Bash(__NEW_LINE_2a0d1cacdabfd662__ cd:*)"
]
}
}
+2 -20
View File
@@ -41,26 +41,8 @@ class ThemeNotifier extends StateNotifier<ThemeState> {
_loadTheme();
}
/// Load saved theme preference from SharedPreferences
Future<void> _loadTheme() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedMode = prefs.getString(_ThemeKeys.themeMode);
if (savedMode != null) {
final appMode = AppThemeMode.values.firstWhere(
(e) => e.name == savedMode,
orElse: () => AppThemeMode.dark,
);
state = state.copyWith(
appThemeMode: appMode,
themeMode: _mapToThemeMode(appMode),
);
}
} catch (e) {
debugPrint('Error loading theme: $e');
}
}
/// Theme is always forced to dark — no loading from preferences
Future<void> _loadTheme() async {}
/// Set theme mode and persist to SharedPreferences
Future<void> setThemeMode(AppThemeMode mode) async {
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../../core/services/playlist_api_service.dart';
import '../../../../core/models/playlist_config.dart';
import '../../../../mobile/widgets/tv_focusable.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../mobile/theme/mobile_theme.dart';
@@ -45,155 +46,37 @@ class _MobilePlaylistSelectionScreenState
Future<void> _showPlaylistDialog({PlaylistConfig? playlist}) async {
final isEditing = playlist != null;
final nameController = TextEditingController(text: playlist?.name ?? '');
final dnsController = TextEditingController(text: playlist?.dns ?? '');
final userController =
TextEditingController(text: playlist?.username ?? '');
final passController =
TextEditingController(text: playlist?.password ?? '');
await showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text(
isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
style: GoogleFonts.inter(color: Colors.white),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Nom',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
),
),
const SizedBox(height: 8),
TextField(
controller: dnsController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'URL (DNS)',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
),
),
const SizedBox(height: 8),
TextField(
controller: userController,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Utilisateur',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
),
),
const SizedBox(height: 8),
TextField(
controller: passController,
obscureText: true,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Mot de passe',
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
),
),
],
),
),
actions: [
if (isEditing)
TextButton(
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text(
'Confirmer',
style: TextStyle(color: Colors.white),
),
content: const Text(
'Supprimer cette playlist ?',
style: TextStyle(color: Colors.white70),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style:
TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
await PlaylistApiService().deletePlaylist(playlist.id);
if (context.mounted) {
Navigator.pop(context);
ref.invalidate(playlistsProvider);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
if (nameController.text.isEmpty || dnsController.text.isEmpty) {
return;
}
final service = PlaylistApiService();
if (isEditing) {
await service.updatePlaylist(
id: playlist.id,
name: nameController.text,
dns: dnsController.text,
username: userController.text,
password: passController.text,
);
} else {
await service.createPlaylist(
name: nameController.text,
dns: dnsController.text,
username: userController.text,
password: passController.text,
);
}
if (context.mounted) {
Navigator.pop(context);
builder: (context) => _PlaylistEditDialog(
isEditing: isEditing,
playlist: playlist,
onSave: (name, dns, username, password) async {
final service = PlaylistApiService();
if (isEditing) {
await service.updatePlaylist(
id: playlist.id,
name: name,
dns: dns,
username: username,
password: password,
);
} else {
await service.createPlaylist(
name: name,
dns: dns,
username: username,
password: password,
);
}
ref.invalidate(playlistsProvider);
},
onDelete: isEditing
? () async {
await PlaylistApiService().deletePlaylist(playlist.id);
ref.invalidate(playlistsProvider);
}
},
child: const Text('Enregistrer'),
),
],
: null,
),
);
}
@@ -271,16 +154,32 @@ class _MobilePlaylistSelectionScreenState
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
TVFocusable(
onPressed: () => _showPlaylistDialog(),
icon: const Icon(Icons.add),
label: const Text('Ajouter une Playlist'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.black,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
horizontal: 28,
vertical: 14,
),
decoration: BoxDecoration(
color: const Color(0xFF0A84FF),
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, color: Colors.white, size: 20),
SizedBox(width: 10),
Text(
'Ajouter une Playlist',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
],
),
),
),
@@ -322,6 +221,285 @@ class _MobilePlaylistSelectionScreenState
}
}
/// TV-friendly playlist edit dialog.
/// Each field is a focusable row; pressing select opens a single-field input dialog.
class _PlaylistEditDialog extends StatefulWidget {
final bool isEditing;
final PlaylistConfig? playlist;
final Future<void> Function(String name, String dns, String username, String password) onSave;
final Future<void> Function()? onDelete;
const _PlaylistEditDialog({
required this.isEditing,
required this.playlist,
required this.onSave,
this.onDelete,
});
@override
State<_PlaylistEditDialog> createState() => _PlaylistEditDialogState();
}
class _PlaylistEditDialogState extends State<_PlaylistEditDialog> {
late String _name;
late String _dns;
late String _username;
late String _password;
bool _isSaving = false;
@override
void initState() {
super.initState();
_name = widget.playlist?.name ?? '';
_dns = widget.playlist?.dns ?? '';
_username = widget.playlist?.username ?? '';
_password = widget.playlist?.password ?? '';
}
Future<void> _editField({
required String label,
required String currentValue,
required void Function(String) onSaved,
bool obscure = false,
TextInputType keyboardType = TextInputType.text,
}) async {
final controller = TextEditingController(text: currentValue);
final result = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text(label, style: const TextStyle(color: Colors.white)),
content: TextField(
controller: controller,
autofocus: true,
obscureText: obscure,
keyboardType: keyboardType,
textInputAction: TextInputAction.done,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelStyle: TextStyle(color: Colors.white70),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white24),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.white),
),
),
onSubmitted: (val) => Navigator.pop(ctx, val),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler'),
),
ElevatedButton(
autofocus: false,
onPressed: () => Navigator.pop(ctx, controller.text),
child: const Text('OK'),
),
],
),
);
controller.dispose();
if (result != null) {
onSaved(result);
}
}
Widget _buildFieldRow({
required IconData icon,
required String label,
required String value,
required VoidCallback onTap,
bool obscure = false,
}) {
final displayValue = obscure && value.isNotEmpty
? '•' * value.length.clamp(1, 12)
: value.isEmpty
? '—'
: value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TVFocusable(
onPressed: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.07),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(icon, color: AppColors.primary, size: 22),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
color: Colors.white70,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
displayValue,
style: TextStyle(
color: value.isEmpty ? Colors.white38 : Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const Icon(Icons.edit, color: Colors.white38, size: 18),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: AppColors.surface,
title: Text(
widget.isEditing ? 'Modifier la Playlist' : 'Ajouter une Playlist',
style: GoogleFonts.inter(color: Colors.white),
),
content: SizedBox(
width: 480,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildFieldRow(
icon: Icons.label_outline,
label: 'Nom',
value: _name,
onTap: () => _editField(
label: 'Nom',
currentValue: _name,
onSaved: (v) => setState(() => _name = v),
),
),
_buildFieldRow(
icon: Icons.link,
label: 'URL (DNS)',
value: _dns,
onTap: () => _editField(
label: 'URL (DNS)',
currentValue: _dns,
keyboardType: TextInputType.url,
onSaved: (v) => setState(() => _dns = v),
),
),
_buildFieldRow(
icon: Icons.person_outline,
label: 'Utilisateur',
value: _username,
onTap: () => _editField(
label: 'Utilisateur',
currentValue: _username,
onSaved: (v) => setState(() => _username = v),
),
),
_buildFieldRow(
icon: Icons.lock_outline,
label: 'Mot de passe',
value: _password,
obscure: true,
onTap: () => _editField(
label: 'Mot de passe',
currentValue: _password,
obscure: true,
onSaved: (v) => setState(() => _password = v),
),
),
],
),
),
actions: [
if (widget.onDelete != null)
TVFocusable(
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('Confirmer', style: TextStyle(color: Colors.white)),
content: const Text('Supprimer cette playlist ?', style: TextStyle(color: Colors.white70)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
if (confirm == true) {
await widget.onDelete!();
if (context.mounted) Navigator.pop(context);
}
},
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('Supprimer', style: TextStyle(color: Colors.red)),
),
),
TVFocusable(
onPressed: () => Navigator.pop(context),
borderRadius: BorderRadius.circular(8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text('Annuler', style: TextStyle(color: Colors.white70)),
),
),
TVFocusable(
onPressed: _isSaving || _name.isEmpty || _dns.isEmpty
? null
: () async {
setState(() => _isSaving = true);
await widget.onSave(_name, _dns, _username, _password);
if (context.mounted) Navigator.pop(context);
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: _name.isEmpty || _dns.isEmpty
? Colors.white12
: AppColors.primary,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_isSaving ? '...' : 'Enregistrer',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
}
}
class _MobilePlaylistCard extends StatelessWidget {
final PlaylistConfig playlist;
final VoidCallback onTap;
@@ -11,6 +11,7 @@ import 'package:xtremobile/core/models/iptv_models.dart' as model;
import 'package:xtremobile/core/models/playlist_config.dart';
import 'package:xtremobile/features/iptv/services/xtream_service_mobile.dart';
import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart';
import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart';
import 'package:xtremobile/core/theme/app_colors.dart';
import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -86,6 +87,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Timer? _clockTimer;
Timer? _controlsTimer; // Auto-hide timer
Timer? _epgTimer; // Periodic EPG refresh for live TV
Timer? _liveWatchdog; // Watchdog for live stream reconnection
int _reconnectAttempts = 0;
static const int _maxReconnectAttempts = 5;
@@ -464,6 +466,7 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
_clockTimer?.cancel();
_controlsTimer?.cancel();
_epgTimer?.cancel();
_liveWatchdog?.cancel();
// Stop playback first to prevent audio continuing in background
@@ -702,9 +705,13 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
? widget.channels![_currentIndex].streamId
: widget.streamId;
// Load EPG if Live TV
// Load EPG if Live TV (with periodic refresh every 5 min)
if (widget.streamType == model.StreamType.live) {
_loadEpg(currentStreamId);
_epgTimer?.cancel();
_epgTimer = Timer.periodic(const Duration(minutes: 5), (_) {
_loadEpg(currentStreamId);
});
}
// ... (rest of _loadStream logic: get url, open player)
@@ -806,7 +813,9 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
Future<void> _loadEpg(String streamId) async {
try {
final epg = await _xtreamService!.getShortEPG(streamId);
// Use shared provider service so EPG cached by channel cards is reused instantly
final service = await ref.read(mobileXtreamServiceProvider(widget.playlist).future);
final epg = await service.getShortEPG(streamId);
if (mounted) {
setState(() => _epg = epg);
}
@@ -1323,7 +1332,8 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
}
Widget _buildEPGBox(String title) {
String nowPlaying = _epg?.nowPlaying ?? "Pas d'infos EPG";
final rawNow = _epg?.nowPlaying ?? '';
String nowPlaying = rawNow.isNotEmpty ? rawNow : "Pas d'infos EPG";
String nextPlaying =
_epg?.nextPlaying != null ? "Suivant: ${_epg!.nextPlaying}" : "";
@@ -1375,19 +1385,6 @@ class _NativePlayerScreenState extends ConsumerState<NativePlayerScreen>
),
],
),
if (nextPlaying.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
nextPlaying,
style: const TextStyle(
color: Colors.white70,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
@@ -27,6 +27,7 @@ class MobileLiveTVTab extends ConsumerStatefulWidget {
class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
with AutomaticKeepAliveClientMixin {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
String _searchQuery = '';
@@ -60,6 +61,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
_searchTimer?.cancel();
@@ -80,13 +82,24 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
final uiState = ref.watch(mobileLiveTvUiStateProvider);
final uiNotifier = ref.read(mobileLiveTvUiStateProvider.notifier);
// Scroll to top when entering a category so first row is fully visible
ref.listen(mobileLiveTvUiStateProvider, (previous, next) {
if (previous?.isCategoryView == true && !next.isCategoryView) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
});
}
});
// Determine if we should show the category grid
final bool showGrid = uiState.isCategoryView &&
_searchQuery.isEmpty &&
!_showFavoritesOnly;
return Container(
color: const Color(0xFF000000), // Pure black background
color: const Color(0xFF000000),
child: Scaffold(
backgroundColor: Colors.transparent,
body: PopScope(
@@ -95,6 +108,9 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
// Only handle back if this tab is currently active (tab index 0)
if (ref.read(mobileDashboardIndexProvider) != 0) return;
// If just returned from player, reset the flag and do nothing (stay on channel list)
if (_justReturnedFromPlayer) {
_justReturnedFromPlayer = false;
@@ -194,6 +210,7 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
return SafeArea(
bottom: false,
child: CustomScrollView(
controller: _scrollController,
slivers: [
// Floating Header (Search + Navigation)
SliverAppBar(
@@ -447,14 +464,13 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NativePlayerScreen(
builder: (context) => LitePlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: playlist,
streamType: StreamType.live,
channels: channels, // Pass full channel list for zapping
channels: channels,
initialIndex: index,
forceDeinterlace: true,
),
),
);
@@ -462,13 +478,14 @@ class _MobileLiveTVTabState extends ConsumerState<MobileLiveTVTab>
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LitePlayerScreen(
builder: (context) => NativePlayerScreen(
streamId: channel.streamId,
title: channel.name,
playlist: playlist,
streamType: StreamType.live,
channels: channels, // Pass full channel list for zapping
channels: channels,
initialIndex: index,
forceDeinterlace: false,
),
),
);
@@ -264,6 +264,9 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
// Only handle back if this tab is currently active (tab index 1)
if (ref.read(mobileDashboardIndexProvider) != 1) return;
// Handle Search Exit
if (_searchQuery.isNotEmpty || _isSearching) {
_onSearchChanged(''); // clear search
@@ -327,94 +330,101 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
// Pinned Header
SliverAppBar(
pinned: true,
floating: true,
backgroundColor: AppDecorations.background(context).gradient is LinearGradient
? (AppDecorations.background(context).gradient as LinearGradient).colors.first
: Theme.of(context).scaffoldBackgroundColor,
elevation: 4,
floating: false,
snap: false,
backgroundColor: const Color(0xFF000000),
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
expandedHeight: showingMovies ? 110 : 110,
collapsedHeight: showingMovies ? 110 : 110,
toolbarHeight: showingMovies ? 120.0 : 110.0,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: const EdgeInsets.only(top: 8.0),
background: Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showingMovies)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
SystemChannels.textInput.invokeMethod('TextInput.show');
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _onBack,
),
Icon(Icons.search, color: AppDecorations.textSecondary(context)),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedCategoryName ?? 'Films',
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(color: AppDecorations.textPrimary(context)),
decoration: InputDecoration(
hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher un film...',
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () { _searchController.clear(); _onSearchChanged(''); },
child: Icon(Icons.close, color: AppDecorations.textSecondary(context)),
),
],
),
)
else
const SizedBox(height: 10),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 40,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(Icons.search, size: 20, color: AppDecorations.textSecondary(context)),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: AppDecorations.textPrimary(context),
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(fontSize: 14, color: AppDecorations.textPrimary(context)),
decoration: InputDecoration(
hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher un film...',
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching) const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: Icon(Icons.close, size: 16, color: AppDecorations.textSecondary(context)),
),
],
const SizedBox(height: 12),
// Navigation Row
Row(
children: [
if (showingMovies) ...[
IconButton(
icon: Icon(Icons.arrow_back, color: AppDecorations.textPrimary(context)),
onPressed: _onBack,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
showingMovies
? (_selectedCategoryName ?? 'Films')
: showingSearch
? 'Résultats de recherche'
: 'Catégories',
style: TextStyle(
color: AppDecorations.textPrimary(context),
fontWeight: FontWeight.bold,
fontSize: 18,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
@@ -427,7 +437,7 @@ class _MobileMoviesTabState extends ConsumerState<MobileMoviesTab>
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: showingCategories ? 3 : 4,
crossAxisCount: showingCategories ? 6 : 8,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: showingCategories ? 2.0 : 0.67,
@@ -251,6 +251,9 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
// Only handle back if this tab is currently active (tab index 2)
if (ref.read(mobileDashboardIndexProvider) != 2) return;
if (_searchQuery.isNotEmpty || _isSearching) {
_onSearchChanged('');
_searchController.clear();
@@ -297,94 +300,101 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
// Pinned Header
SliverAppBar(
pinned: true,
floating: true,
backgroundColor: AppDecorations.background(context).gradient is LinearGradient
? (AppDecorations.background(context).gradient as LinearGradient).colors.first
: Theme.of(context).scaffoldBackgroundColor,
elevation: 4,
floating: false,
snap: false,
backgroundColor: const Color(0xFF000000),
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
expandedHeight: showingSeries ? 110 : 110,
collapsedHeight: showingSeries ? 110 : 110,
toolbarHeight: showingSeries ? 120.0 : 110.0,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: const EdgeInsets.only(top: 8.0),
background: Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showingSeries)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
// Search Bar
TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
SystemChannels.textInput.invokeMethod('TextInput.show');
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 48,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _onBack,
),
Icon(Icons.search, color: AppDecorations.textSecondary(context)),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedCategoryName ?? 'Séries',
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: Colors.white,
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(color: AppDecorations.textPrimary(context)),
decoration: InputDecoration(
hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher une série...',
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
isDense: true,
),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () { _searchController.clear(); _onSearchChanged(''); },
child: Icon(Icons.close, color: AppDecorations.textSecondary(context)),
),
],
),
)
else
const SizedBox(height: 10),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: TVFocusable(
scale: 1.0,
focusColor: Colors.white,
onPressed: () {
setState(() => _isSearchEditing = true);
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
},
borderRadius: BorderRadius.circular(12),
child: Container(
height: 40,
decoration: AppDecorations.searchBar(context),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Icon(Icons.search, size: 20, color: AppDecorations.textSecondary(context)),
const SizedBox(width: 8),
Expanded(
child: ExcludeFocus(
excluding: !_isSearchEditing,
child: TextField(
cursorColor: AppDecorations.textPrimary(context),
controller: _searchController,
focusNode: _searchFocusNode,
readOnly: !_isSearchEditing,
style: TextStyle(fontSize: 14, color: AppDecorations.textPrimary(context)),
decoration: InputDecoration(
hintText: _selectedCategoryId != null ? 'Rechercher...' : 'Rechercher une série...',
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.only(bottom: 11),
),
onChanged: _onSearchChanged,
onSubmitted: (_) => setState(() => _isSearchEditing = false),
),
),
),
if (_isSearching) const SizedBox(width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 2)),
if (_searchQuery.isNotEmpty)
GestureDetector(
onTap: () {
_searchController.clear();
_onSearchChanged('');
},
child: Icon(Icons.close, size: 16, color: AppDecorations.textSecondary(context)),
),
],
const SizedBox(height: 12),
// Navigation Row
Row(
children: [
if (showingSeries) ...[
IconButton(
icon: Icon(Icons.arrow_back, color: AppDecorations.textPrimary(context)),
onPressed: _onBack,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
showingSeries
? (_selectedCategoryName ?? 'Séries')
: showingSearch
? 'Résultats de recherche'
: 'Catégories',
style: TextStyle(
color: AppDecorations.textPrimary(context),
fontWeight: FontWeight.bold,
fontSize: 18,
),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
@@ -396,7 +406,7 @@ class _MobileSeriesTabState extends ConsumerState<MobileSeriesTab>
padding: const EdgeInsets.fromLTRB(16, 0, 16, 80),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: showingCategories ? 3 : 4,
crossAxisCount: showingCategories ? 6 : 8,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: showingCategories ? 2.0 : 0.67,
@@ -117,10 +117,6 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
// === APPARENCE ===
_buildSectionHeader('Apparence'),
_buildThemeSwitcher(),
const SizedBox(height: 8),
_buildSettingItem(
icon: Icons.access_time,
title: 'Afficher l\'heure',
@@ -360,8 +356,9 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
final selected = themeState.appThemeMode == mode;
final accentColor = Theme.of(context).colorScheme.primary;
return Expanded(
child: GestureDetector(
onTap: () => notifier.setThemeMode(mode),
child: TVFocusable(
onPressed: () => notifier.setThemeMode(mode),
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 2),