From e1a0802a0cb2df9a9ad5808f575bb11bf9b299e4 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Fri, 27 Mar 2026 13:34:06 +0100 Subject: [PATCH] feat: Add mobile-specific Riverpod providers for Xtream service, live TV, movies, series, and EPG data, along with UI state management. --- .claude/settings.local.json | 17 +- lib/core/models/iptv_models.dart | 15 +- lib/core/theme/app_colors.dart | 154 +++-- lib/core/theme/app_decorations.dart | 194 ++++++ lib/core/theme/app_theme.dart | 191 +++++- .../iptv/screens/mobile_dashboard_screen.dart | 2 +- .../iptv/screens/mobile_playlist_screen.dart | 5 +- .../mobile_playlist_selection_screen.dart | 2 +- .../iptv/services/xtream_service_mobile.dart | 95 +-- .../iptv/widgets/mobile_live_tv_tab.dart | 601 ++++++++++-------- .../iptv/widgets/mobile_movies_tab.dart | 27 +- .../iptv/widgets/mobile_series_tab.dart | 74 ++- .../iptv/widgets/mobile_settings_tab.dart | 165 ++++- .../providers/mobile_xtream_providers.dart | 16 +- lib/mobile/theme/mobile_theme.dart | 374 +++++++++-- lib/mobile/widgets/mobile_poster_card.dart | 192 +++--- lib/mobile/widgets/mobile_scaffold.dart | 52 +- lib/mobile/widgets/tv_focusable.dart | 17 +- 18 files changed, 1619 insertions(+), 574 deletions(-) create mode 100644 lib/core/theme/app_decorations.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2b36092..90be5a6 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,22 @@ "Bash(flutter build:*)", "Bash(tee build_apk_output.txt)", "Bash(rm -f *.txt)", - "Bash(ls -la *.txt)" + "Bash(ls -la *.txt)", + "WebSearch", + "WebFetch(domain:designcode.io)", + "WebFetch(domain:www.brightec.co.uk)", + "WebFetch(domain:medium.com)", + "WebFetch(domain:devsign.co)", + "WebFetch(domain:marvelapp.com)", + "WebFetch(domain:www.colorsandfonts.com)", + "WebFetch(domain:mar.codes)", + "WebFetch(domain:www.smashingmagazine.com)", + "WebFetch(domain:sarunw.com)", + "WebFetch(domain:noahgilmore.com)", + "WebFetch(domain:www.color-hex.com)", + "WebFetch(domain:iosexample.com)", + "WebFetch(domain:blog.karenying.com)", + "WebFetch(domain:gist.github.com)" ] } } diff --git a/lib/core/models/iptv_models.dart b/lib/core/models/iptv_models.dart index 2ecd9ed..db0d396 100644 --- a/lib/core/models/iptv_models.dart +++ b/lib/core/models/iptv_models.dart @@ -321,12 +321,23 @@ class ShortEPG { }); factory ShortEPG.fromJson(Map json) { + // Xtream API encodes title and description in Base64 + String decodeBase64(String? value) { + if (value == null || value.isEmpty) return ''; + try { + final bytes = base64.decode(value); + return utf8.decode(bytes); + } catch (_) { + return value; // Return as-is if not valid base64 + } + } + return ShortEPG( id: json['id']?.toString() ?? '', - title: json['title']?.toString() ?? '', + title: decodeBase64(json['title']?.toString()), start: json['start']?.toString() ?? '', end: json['end']?.toString() ?? '', - description: json['description']?.toString() ?? '', + description: decodeBase64(json['description']?.toString()), ); } diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 6353ae7..2c1b0d2 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -1,81 +1,155 @@ import 'package:flutter/material.dart'; -/// XtremFlow Apple TV Style Palette +/// XtremFlow Apple TV Grey Glossy Palette /// -/// Ultra minimalist, focus-driven palette. -/// - Background: Pure Black (#000000) -/// - Focus/Active: Pure White (#FFFFFF) -/// - Inactive: Grey (#9E9E9E) -/// - Glass: Heavy blur with low opacity +/// Premium Apple TV design language: +/// - Background: Deep dark grey (#1C1C1E) to black (#000000) gradient +/// - Surface cards: Glossy dark grey (#2C2C2E to #3A3A3C) with glass reflections +/// - Primary accent: Bright white (#FFFFFF) and light grey (#EBEBF5) +/// - Secondary accent: Apple Blue (#0A84FF) +/// - Borders: Subtle white 8% opacity for glass effect +/// - Glossy highlight: White 10-15% at top fading to transparent class AppColors { AppColors._(); // ============ BACKGROUNDS ============ - /// Pure black for that infinite depth look (OLED friendly) + /// Pure black — infinite depth, OLED-friendly static const Color background = Color(0xFF000000); - /// Very deep grey for surfaces that need to be distinct but subtle (Apple Dark Grey) + /// Apple deep dark grey — primary app background (alias) + static const Color backgroundSecondary = Color(0xFF1C1C1E); + + /// Apple deep dark grey — primary surface static const Color surface = Color(0xFF1C1C1E); - /// Slightly lighter grey for secondary surfaces or hover states - static const Color surfaceVariant = Color(0xFF3A3A3C); + /// Mid grey — elevated cards, sheets + static const Color surfaceVariant = Color(0xFF2C2C2E); - /// Focused element background (often white in tvOS for text, or bright accent) + /// Light grey surface — tertiary elevation + static const Color surfaceElevated = Color(0xFF3A3A3C); + + /// Focus/selected element background static const Color focusColor = Color(0xFFFFFFFF); // ============ ACCENTS ============ - /// Minimal white accent. In Apple TV, color is used sparingly. - /// We keep a subtle blue only for specific indicators if needed. + /// Primary: Pure white — focus rings, active labels static const Color primary = Color(0xFFFFFFFF); - static const Color accent = Color(0xFFE5E5E5); - /// Primary Gradient (Subtle silver/white glow) + /// Light grey — secondary text / inactive elements + static const Color accent = Color(0xFFEBEBF5); + + /// Apple Blue — CTAs, links, active indicators + static const Color appleBlue = Color(0xFF0A84FF); + + // ============ GRADIENTS ============ + + /// Apple TV background: deep dark top → pure black bottom + static const LinearGradient backgroundGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF1C1C1E), // Deep dark grey + Color(0xFF000000), // Pure black + ], + stops: [0.0, 1.0], + ); + + /// Legacy alias for backward compatibility + static const RadialGradient appleTvGradient = RadialGradient( + center: Alignment.center, + radius: 1.5, + colors: [ + Color(0xFF1A1A1C), // Slightly lighter than pure black + Color(0xFF000000), // Pure black edge + ], + stops: [0.0, 1.0], + ); + + /// Glossy card gradient: bright highlight at top fading to dark base + static const LinearGradient glossyCardGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF484848), // Lighter top for gloss highlight + Color(0xFF2C2C2E), // Dark mid + Color(0xFF1C1C1E), // Deeper bottom + ], + stops: [0.0, 0.45, 1.0], + ); + + /// Glossy inner highlight (white shimmer layer, overlaid on top of card) + static const LinearGradient glossyHighlight = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x26FFFFFF), // White ~15% at top + Color(0x00FFFFFF), // Transparent at bottom + ], + stops: [0.0, 0.5], + ); + + /// Card glossy gradient (Apple TV spec alias — dark base) + static const LinearGradient cardGlossyGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF48484A), Color(0xFF2C2C2E)], + ); + + /// Card glossy highlight (Apple TV spec alias — top-left shimmer) + static const LinearGradient cardGlossyHighlight = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0x26FFFFFF), Color(0x00FFFFFF)], + stops: [0.0, 0.5], + ); + + /// Primary silver gradient (used for buttons/badges) static const LinearGradient primaryGradient = LinearGradient( colors: [Color(0xFFFFFFFF), Color(0xFFCCCCCC)], begin: Alignment.topLeft, end: Alignment.bottomRight, ); - /// Apple TV Style Background Gradient (Visible gradient) - static const RadialGradient appleTvGradient = RadialGradient( - center: Alignment.center, - radius: 1.5, - colors: [ - Color(0xFF151515), // Deep Dark Grey center (barely visible) - Color(0xFF000000), // Pure Black edge - ], - stops: [0.0, 1.0], - ); - // ============ TEXT ============ + /// Pure white — headings, active labels static const Color textPrimary = Color(0xFFFFFFFF); - static const Color textSecondary = Color(0xFF9E9E9E); // Medium Grey - static const Color textTertiary = Color(0xFF616161); // Darker Grey + + /// Light grey 60% — secondary info, captions + static const Color textSecondary = Color(0x99EBEBF5); // #EBEBF5 @ 60% + + /// Darker grey — hints, placeholders + static const Color textTertiary = Color(0xFF636366); // ============ FUNCTIONAL COLORS ============ - static const Color success = Color(0xFF4DB6AC); // Teal-ish - static const Color warning = Color(0xFFFFB74D); - static const Color error = Color(0xFFEF5350); - static const Color live = Color(0xFFFF3B30); // Apple Red + static const Color success = Color(0xFF30D158); // Apple Green + static const Color warning = Color(0xFFFF9F0A); // Apple Orange + static const Color error = Color(0xFFFF453A); // Apple Red + static const Color live = Color(0xFFFF453A); // Apple Red (live badge) // ============ BORDERS ============ - // Borders are usually hidden until focused - static const Color border = Color(0xFF1F1F1F); + /// Invisible border — unfocused state + static const Color border = Color(0xFF2C2C2E); + + /// Glass border: white at 8% opacity for subtle edge definition + static const Color glassBorderColor = Color(0x14FFFFFF); // 8% white + + /// Focus border: pure white ring static const Color focusBorder = Color(0xFFFFFFFF); + /// Alias: focused border — pure white (Apple TV spec) + static const Color borderFocused = Color(0xFFFFFFFF); + // ============ GLASSMORPHISM ============ - // Apple TV uses this heavily for headers/overlays - static final Color glassBackground = const Color(0xFF1E1E1E).withOpacity(0.6); - static final Color glassBorder = const Color(0xFFFFFFFF).withOpacity(0.15); + static final Color glassBackground = const Color(0xFF1E1E1E).withValues(alpha: 0.75); + static final Color glassBorder = const Color(0xFFFFFFFF).withValues(alpha: 0.08); // ============ THEME SCHEMES ============ static ColorScheme get darkColorScheme => const ColorScheme.dark( - primary: primary, + primary: appleBlue, secondary: accent, surface: surface, error: error, - onPrimary: Colors - .black, // White text on buttons -> Black text on White buttons + onPrimary: Colors.white, onSecondary: Colors.black, onSurface: textPrimary, onError: Colors.white, diff --git a/lib/core/theme/app_decorations.dart b/lib/core/theme/app_decorations.dart new file mode 100644 index 0000000..bb89328 --- /dev/null +++ b/lib/core/theme/app_decorations.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; + +/// Theme-aware decoration helpers. +/// Returns dark (Apple TV glossy) or light (iOS clean white) styles +/// based on current [BuildContext] brightness. +class AppDecorations { + AppDecorations._(); + + static bool _isDark(BuildContext context) => + Theme.of(context).brightness == Brightness.dark; + + // ─── Backgrounds ──────────────────────────────────────────────────────────── + + /// Full-screen gradient background for tab content areas. + static BoxDecoration background(BuildContext context) { + if (_isDark(context)) { + return const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF1C1C1E), Color(0xFF000000)], + stops: [0.0, 1.0], + ), + ); + } + return const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFEAEAF0), Color(0xFFD8D8E0)], + stops: [0.0, 1.0], + ), + ); + } + + // ─── Nav Pill ──────────────────────────────────────────────────────────────── + + /// Floating pill navigation bar background. + static BoxDecoration navPill(BuildContext context) { + if (_isDark(context)) { + return BoxDecoration( + color: const Color(0xFF1C1C1E), + borderRadius: BorderRadius.circular(40), + border: Border.all(color: const Color(0x20FFFFFF), width: 1), + boxShadow: const [ + BoxShadow( + color: Color(0x66000000), + blurRadius: 20, + spreadRadius: 0, + offset: Offset(0, 6), + ), + ], + ); + } + return BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(40), + border: Border.all(color: const Color(0x1A000000), width: 1), + boxShadow: const [ + BoxShadow( + color: Color(0x1A000000), + blurRadius: 16, + spreadRadius: 0, + offset: Offset(0, 4), + ), + ], + ); + } + + // ─── Cards ─────────────────────────────────────────────────────────────────── + + /// Glossy category / channel card — primary card style. + static BoxDecoration glossyCard( + BuildContext context, { + double radius = 16, + }) { + if (_isDark(context)) { + return BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF4A4A4E), Color(0xFF1C1C1E)], + stops: [0.0, 1.0], + ), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: const Color(0x28FFFFFF), width: 1), + boxShadow: const [ + BoxShadow( + color: Color(0xAA000000), + blurRadius: 20, + spreadRadius: 0, + offset: Offset(0, 8), + ), + ], + ); + } + return BoxDecoration( + color: const Color(0xFFF2F2F7), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: const Color(0x1A000000), width: 1), + boxShadow: const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 8, + spreadRadius: 0, + offset: Offset(0, 2), + ), + ], + ); + } + + /// Gloss shimmer overlay — placed on top-left of a card Stack. + static BoxDecoration glossShimmer( + BuildContext context, { + double radius = 16, + }) { + if (_isDark(context)) { + return BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x40FFFFFF), Color(0x00FFFFFF)], + stops: [0.0, 0.55], + ), + borderRadius: + BorderRadius.vertical(top: Radius.circular(radius)), + ); + } + return BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x0A000000), Color(0x00000000)], + stops: [0.0, 0.55], + ), + borderRadius: + BorderRadius.vertical(top: Radius.circular(radius)), + ); + } + + /// Smaller channel card base (top area behind the logo). + static BoxDecoration channelCardBase(BuildContext context) { + if (_isDark(context)) { + return const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF3A3A3C), Color(0xFF1C1C1E)], + ), + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + ); + } + return const BoxDecoration( + color: Color(0xFFE4E4EC), + borderRadius: BorderRadius.vertical(top: Radius.circular(12)), + ); + } + + // ─── Search Bar ────────────────────────────────────────────────────────────── + + /// Search / text field container. + static BoxDecoration searchBar(BuildContext context) { + if (_isDark(context)) { + return BoxDecoration( + color: const Color(0xFF2C2C2E), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0x18FFFFFF), width: 1), + ); + } + return BoxDecoration( + color: const Color(0xFFEEEEF4), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0x18000000), width: 1), + ); + } + + // ─── Colours ───────────────────────────────────────────────────────────────── + + static Color textPrimary(BuildContext context) => + _isDark(context) ? Colors.white : const Color(0xFF000000); + + static Color textSecondary(BuildContext context) => + _isDark(context) + ? const Color(0x99EBEBF5) + : const Color(0x993C3C43); + + static Color iconMuted(BuildContext context) => + _isDark(context) ? Colors.white38 : Colors.black26; + + static Color divider(BuildContext context) => + _isDark(context) + ? const Color(0x1FFFFFFF) + : const Color(0x1A000000); +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index bbacda0..5b8f119 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -179,10 +179,189 @@ class AppTheme { static const double radiusFull = 999.0; - // Light theme stub - static ThemeData get lightTheme => - ThemeData.light(useMaterial3: true).copyWith( - colorScheme: AppColors.lightColorScheme, - textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme), - ); + // ============ LIGHT THEME (Apple Clean White) ============ + static ThemeData get lightTheme { + final baseTheme = ThemeData.light(useMaterial3: true); + + // Apple light palette + const bgColor = Color(0xFFF2F2F7); // Apple system grey6 + const surfaceColor = Color(0xFFFFFFFF); // Pure white surfaces + const primaryColor = Color(0xFF007AFF); // Apple Blue (light) + const textPrimaryColor = Color(0xFF000000); + const textSecondaryColor = Color(0x993C3C43); // #3C3C43 @ 60% + const textTertiaryColor = Color(0xFFC7C7CC); + const borderColor = Color(0x1A000000); // #000000 @ 10% + + return baseTheme.copyWith( + colorScheme: AppColors.lightColorScheme.copyWith( + primary: primaryColor, + secondary: const Color(0xFF34C759), // Apple green + surface: surfaceColor, + onPrimary: Colors.white, + onSurface: textPrimaryColor, + brightness: Brightness.light, + ), + scaffoldBackgroundColor: bgColor, + + // Text selection — blue cursor on light + textSelectionTheme: const TextSelectionThemeData( + cursorColor: primaryColor, + selectionColor: Color(0x4D007AFF), + selectionHandleColor: primaryColor, + ), + + // Typography + textTheme: TextTheme( + displayLarge: GoogleFonts.inter( + fontSize: 56, + fontWeight: FontWeight.w700, + letterSpacing: -1.0, + color: textPrimaryColor, + ), + displayMedium: GoogleFonts.inter( + fontSize: 48, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: textPrimaryColor, + ), + displaySmall: GoogleFonts.inter( + fontSize: 32, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + headlineMedium: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + titleLarge: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + titleMedium: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + bodyLarge: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w400, + color: textSecondaryColor, + ), + bodyMedium: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: textSecondaryColor, + ), + labelLarge: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + ), + + // AppBar: White with subtle bottom shadow + appBarTheme: AppBarTheme( + backgroundColor: surfaceColor, + foregroundColor: textPrimaryColor, + elevation: 0, + scrolledUnderElevation: 1, + shadowColor: borderColor, + centerTitle: true, + titleTextStyle: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + ), + + // Bottom Navigation Bar: white, Apple Blue selected + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: surfaceColor, + selectedItemColor: primaryColor, + unselectedItemColor: textSecondaryColor, + selectedLabelStyle: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w500, + ), + type: BottomNavigationBarType.fixed, + elevation: 8, + ), + + // Inputs: white fill, subtle border + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surfaceColor, + contentPadding: const EdgeInsets.symmetric( + horizontal: spacing24, + vertical: spacing16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusMd), + borderSide: const BorderSide(color: borderColor, width: 1), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusMd), + borderSide: const BorderSide(color: borderColor, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusMd), + borderSide: const BorderSide(color: primaryColor, width: 2), + ), + hintStyle: GoogleFonts.inter(color: textTertiaryColor), + ), + + // Buttons: Apple Blue pill + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), + ), + textStyle: + GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 16), + ), + ), + + // Cards: white with subtle shadow + cardTheme: const CardThemeData( + color: surfaceColor, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(radiusMd)), + side: BorderSide(color: borderColor, width: 1), + ), + shadowColor: borderColor, + ), + + // Dialogs: white, clean + dialogTheme: DialogThemeData( + backgroundColor: surfaceColor, + elevation: 8, + shadowColor: borderColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusLg), + side: const BorderSide(color: borderColor, width: 1), + ), + titleTextStyle: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.bold, + color: textPrimaryColor, + ), + contentTextStyle: GoogleFonts.inter( + fontSize: 14, + color: textSecondaryColor, + ), + ), + ); + } } diff --git a/lib/features/iptv/screens/mobile_dashboard_screen.dart b/lib/features/iptv/screens/mobile_dashboard_screen.dart index 5beaf57..867d2d7 100644 --- a/lib/features/iptv/screens/mobile_dashboard_screen.dart +++ b/lib/features/iptv/screens/mobile_dashboard_screen.dart @@ -33,7 +33,7 @@ class _MobileDashboardScreenState extends ConsumerState { // This enables instant tab switching and preserves user scroll position, search state, etc. // The AutomaticKeepAliveClientMixin in each tab will keep them in the widget tree return Theme( - data: MobileTheme.darkTheme, + data: MobileTheme.themeOf(context), child: MobileScaffold( currentIndex: currentIndex, onIndexChanged: (index) { diff --git a/lib/features/iptv/screens/mobile_playlist_screen.dart b/lib/features/iptv/screens/mobile_playlist_screen.dart index 4f4ecd6..faaa4a1 100644 --- a/lib/features/iptv/screens/mobile_playlist_screen.dart +++ b/lib/features/iptv/screens/mobile_playlist_screen.dart @@ -388,14 +388,11 @@ class _MobilePlaylistScreenState extends ConsumerState { final playlistsAsync = ref.watch(localPlaylistsProvider); return Theme( - data: MobileTheme.darkTheme, + data: MobileTheme.themeOf(context), child: Scaffold( - backgroundColor: AppColors.background, appBar: AppBar( title: const Text('XtremFlow'), centerTitle: true, - backgroundColor: AppColors.background, - elevation: 0, ), body: playlistsAsync.when( loading: () => const Center( diff --git a/lib/features/iptv/screens/mobile_playlist_selection_screen.dart b/lib/features/iptv/screens/mobile_playlist_selection_screen.dart index c8957c1..a07031c 100644 --- a/lib/features/iptv/screens/mobile_playlist_selection_screen.dart +++ b/lib/features/iptv/screens/mobile_playlist_selection_screen.dart @@ -203,7 +203,7 @@ class _MobilePlaylistSelectionScreenState final playlistsAsync = ref.watch(playlistsProvider); return Theme( - data: MobileTheme.darkTheme, + data: MobileTheme.themeOf(context), child: Scaffold( extendBodyBehindAppBar: true, // For gradient appBar: AppBar( diff --git a/lib/features/iptv/services/xtream_service_mobile.dart b/lib/features/iptv/services/xtream_service_mobile.dart index 8c79777..98d9dca 100644 --- a/lib/features/iptv/services/xtream_service_mobile.dart +++ b/lib/features/iptv/services/xtream_service_mobile.dart @@ -132,7 +132,7 @@ class XtreamServiceMobile { return batch[streamId] ?? model.ShortEPG( id: streamId, - title: 'No Program', + title: '', start: DateTime.now().toIso8601String(), end: DateTime.now().add(const Duration(hours: 1)).toIso8601String(), ); @@ -178,60 +178,71 @@ class XtreamServiceMobile { } } - /// Internal: Load EPG batch from API + /// Internal: Load EPG for a single stream + /// NOTE: Xtream API get_short_epg only returns results for ONE stream_id at a time. + /// Response format: {"epg_listings": [...]} where title/description are Base64 encoded. Future> _loadEpgBatch( List streamIds, String cacheKey, ) async { - try { - final result = {}; + final result = {}; - // Join stream IDs for API call - final streamIdParam = streamIds.join(','); + // Load EPG for each stream_id individually (API doesn't support true batch) + for (final streamId in streamIds) { + try { + final response = await _dio.get( + '$_baseUrl/player_api.php', + queryParameters: { + 'username': _username, + 'password': _password, + 'action': 'get_short_epg', + 'stream_id': streamId, + }, + options: Options( + receiveTimeout: const Duration(seconds: 10), + sendTimeout: const Duration(seconds: 10), + ), + ).timeout(const Duration(seconds: 10)); - final response = await _dio.get( - '$_baseUrl/player_api.php', - queryParameters: { - 'username': _username, - 'password': _password, - 'action': 'get_short_epg', - 'stream_id': streamIdParam, - }, - options: Options( - receiveTimeout: const Duration(seconds: 20), - sendTimeout: const Duration(seconds: 20), - ), - ); + if (response.statusCode == 200 && response.data is Map) { + final data = response.data as Map; + final listings = data['epg_listings']; - if (response.statusCode == 200 && response.data is List) { - final items = (response.data as List); + if (listings is List && listings.isNotEmpty) { + // Find the currently airing program + final now = DateTime.now(); + Map? current; - for (var item in items) { - if (item is Map && item.containsKey('stream_id')) { - final streamId = item['stream_id'].toString(); - // Assuming the API returns a list of EPG entries for this stream - if (item['epg_listings'] is List && - (item['epg_listings'] as List).isNotEmpty) { - final epgJson = (item['epg_listings'] as List).first; - result[streamId] = model.ShortEPG.fromJson({ - ...epgJson, - 'id': streamId, - }); + for (final item in listings) { + if (item is! Map) continue; + try { + final start = DateTime.parse(item['start'].toString()); + final end = DateTime.parse(item['end'].toString()); + if (now.isAfter(start) && now.isBefore(end)) { + current = Map.from(item); + break; + } + } catch (_) {} } + + // Fallback to first entry if no current program found + current ??= Map.from(listings.first as Map); + + result[streamId] = model.ShortEPG.fromJson({ + ...current, + 'id': streamId, + }); } } - - if (kDebugMode) { - print( - '✅ Loaded EPG for ${result.length}/${streamIds.length} channels'); - } + } catch (e) { + if (kDebugMode) print('⚠️ EPG failed for $streamId: $e'); } - - return result; - } catch (e) { - if (kDebugMode) print('❌ Error loading EPG batch: $e'); - return {}; } + + if (kDebugMode) { + print('✅ EPG loaded: ${result.length}/${streamIds.length} channels'); + } + return result; } /// Get live TV categories diff --git a/lib/features/iptv/widgets/mobile_live_tv_tab.dart b/lib/features/iptv/widgets/mobile_live_tv_tab.dart index 7b10ae2..c3d4ba5 100644 --- a/lib/features/iptv/widgets/mobile_live_tv_tab.dart +++ b/lib/features/iptv/widgets/mobile_live_tv_tab.dart @@ -9,11 +9,12 @@ import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremobile/features/iptv/screens/native_player_screen.dart'; import 'package:xtremobile/core/models/iptv_models.dart'; import 'package:xtremobile/core/models/playlist_config.dart'; -import 'package:xtremobile/core/theme/app_colors.dart'; +import 'package:xtremobile/core/theme/app_decorations.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/features/iptv/screens/lite_player_screen.dart'; import 'package:flutter/services.dart'; import 'package:xtremobile/core/services/ip_service.dart'; +import 'package:xtremobile/core/models/iptv_models.dart' as model; class MobileLiveTVTab extends ConsumerStatefulWidget { final PlaylistConfig playlist; @@ -33,6 +34,8 @@ class _MobileLiveTVTabState extends ConsumerState bool _justReturnedFromPlayer = false; // Flag to prevent PopScope interception after player + Timer? _searchTimer; + @override void initState() { super.initState(); @@ -54,8 +57,6 @@ class _MobileLiveTVTabState extends ConsumerState }); } - Timer? _searchTimer; - @override void dispose() { _searchController.dispose(); @@ -70,15 +71,21 @@ class _MobileLiveTVTabState extends ConsumerState @override Widget build(BuildContext context) { super.build(context); - final channelsAsync = - ref.watch(mobileLiveChannelsProvider(widget.playlist)); - final favorites = ref.watch(mobileFavoritesProvider); + + // Phase 1: always watch categories (fast, ~1 second) + final categoriesAsync = + ref.watch(mobileLiveCategoriesProvider(widget.playlist)); final settings = ref.watch(mobileSettingsProvider); final uiState = ref.watch(mobileLiveTvUiStateProvider); final uiNotifier = ref.read(mobileLiveTvUiStateProvider.notifier); + // Determine if we should show the category grid + final bool showGrid = uiState.isCategoryView && + _searchQuery.isEmpty && + !_showFavoritesOnly; + return Container( - decoration: const BoxDecoration(gradient: AppColors.appleTvGradient), + decoration: AppDecorations.background(context), child: Scaffold( backgroundColor: Colors.transparent, body: PopScope( @@ -141,7 +148,7 @@ class _MobileLiveTVTabState extends ConsumerState } } }, - child: channelsAsync.when( + child: categoriesAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, s) => Center( child: Padding( @@ -153,55 +160,36 @@ class _MobileLiveTVTabState extends ConsumerState ), ), ), - data: (groupedChannels) { - // Prepare categories - var categories = - groupedChannels.map((c) => c.categoryName).toSet().toList(); + data: (allCategories) { + // Apply keyword filter and smart geo-sort on categories + List categories; if (settings.liveTvKeywords.isNotEmpty) { - categories = categories - .where((cat) => settings.matchesLiveTvFilter(cat)) + categories = allCategories + .where( + (cat) => settings.matchesLiveTvFilter(cat.categoryName),) .toList(); - categories.sort(); + categories.sort( + (a, b) => a.categoryName.compareTo(b.categoryName), + ); } else { // Smart Sort (GeoIP) final userCountry = IpService().country; + categories = List.of(allCategories); categories.sort((a, b) { if (userCountry != null) { - final aMatches = - a.toLowerCase().contains(userCountry.toLowerCase()); - final bMatches = - b.toLowerCase().contains(userCountry.toLowerCase()); + final aMatches = a.categoryName + .toLowerCase() + .contains(userCountry.toLowerCase()); + final bMatches = b.categoryName + .toLowerCase() + .contains(userCountry.toLowerCase()); if (aMatches && !bMatches) return -1; if (!aMatches && bMatches) return 1; } - return a.compareTo(b); + return a.categoryName.compareTo(b.categoryName); }); } - // Prepare channels - List displayedChannels = []; - - // Determine mode based on search/favorites/selection - bool showGrid = uiState.isCategoryView && - _searchQuery.isEmpty && - !_showFavoritesOnly; - - if (!showGrid) { - if (_searchQuery.isNotEmpty) { - displayedChannels = groupedChannels - .where((c) => c.name.toLowerCase().contains(_searchQuery)) - .toList(); - } else if (_showFavoritesOnly) { - displayedChannels = groupedChannels - .where((c) => favorites.contains(c.streamId)) - .toList(); - } else if (uiState.selectedCategory != null) { - displayedChannels = groupedChannels - .where((c) => c.categoryName == uiState.selectedCategory) - .toList(); - } - } - return SafeArea( bottom: false, child: Column( @@ -230,37 +218,31 @@ class _MobileLiveTVTabState extends ConsumerState borderRadius: BorderRadius.circular(12), child: Container( height: 48, - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - // Removed internal editing border here to prevent conflict and "inside frame" look - ), + decoration: AppDecorations.searchBar(context), padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - const Icon( + Icon( Icons.search, - color: AppColors.textSecondary, + color: AppDecorations.textSecondary(context), ), const SizedBox(width: 8), Expanded( child: ExcludeFocus( excluding: !_isSearchEditing, child: TextField( - cursorColor: - Colors.white, // Extra safety + cursorColor: Colors.white, controller: _searchController, focusNode: _searchFocusNode, readOnly: !_isSearchEditing, - style: const TextStyle( - color: AppColors.textPrimary, + style: TextStyle( + color: AppDecorations.textPrimary(context), ), decoration: const InputDecoration( hintText: 'Rechercher une chaîne...', border: InputBorder.none, - focusedBorder: InputBorder - .none, // Explicitly remove focus border + focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, isDense: true, ), @@ -273,9 +255,9 @@ class _MobileLiveTVTabState extends ConsumerState if (_searchQuery.isNotEmpty) GestureDetector( onTap: () => _searchController.clear(), - child: const Icon( + child: Icon( Icons.close, - color: AppColors.textSecondary, + color: AppDecorations.textSecondary(context), ), ), ], @@ -292,9 +274,9 @@ class _MobileLiveTVTabState extends ConsumerState _showFavoritesOnly || _searchQuery.isNotEmpty) ...[ IconButton( - icon: const Icon( + icon: Icon( Icons.arrow_back, - color: AppColors.textPrimary, + color: AppDecorations.textPrimary(context), ), onPressed: () { if (_searchQuery.isNotEmpty) { @@ -324,8 +306,8 @@ class _MobileLiveTVTabState extends ConsumerState ? 'Catégories' : uiState.selectedCategory ?? 'Chaînes', - style: const TextStyle( - color: AppColors.textPrimary, + style: TextStyle( + color: AppDecorations.textPrimary(context), fontWeight: FontWeight.bold, fontSize: 18, ), @@ -338,8 +320,8 @@ class _MobileLiveTVTabState extends ConsumerState ? Icons.favorite : Icons.favorite_border, color: _showFavoritesOnly - ? AppColors.error - : AppColors.textSecondary, + ? const Color(0xFFFF453A) + : AppDecorations.textSecondary(context), ), onPressed: () => setState(() { _showFavoritesOnly = !_showFavoritesOnly; @@ -348,7 +330,7 @@ class _MobileLiveTVTabState extends ConsumerState uiNotifier.state = uiState.copyWith(isCategoryView: false); } - // If exiting favorites, default depends on logic (here back to category grid if was previously) + // If exiting favorites, default back to category grid if (!_showFavoritesOnly) { uiNotifier.state = uiState.copyWith(isCategoryView: true); @@ -364,8 +346,21 @@ class _MobileLiveTVTabState extends ConsumerState // Main Content Expanded( child: showGrid - ? _buildCategoryGrid(categories) - : _buildChannelList(displayedChannels), + ? _buildCategoryGrid(categories, uiNotifier, uiState) + : _ChannelListView( + playlist: widget.playlist, + uiState: uiState, + searchQuery: _searchQuery, + showFavoritesOnly: _showFavoritesOnly, + onPlay: (channel, channels, index) => + _playChannel( + context, + channel, + channels, + widget.playlist, + index, + ), + ), ), ], ), @@ -377,12 +372,16 @@ class _MobileLiveTVTabState extends ConsumerState ); } - Widget _buildCategoryGrid(List categories) { + Widget _buildCategoryGrid( + List categories, + StateController uiNotifier, + LiveTvUiState uiState, + ) { if (categories.isEmpty) { return Center( child: Text( 'Aucune catégorie trouvée', - style: GoogleFonts.inter(color: AppColors.textSecondary), + style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)), ), ); } @@ -400,93 +399,64 @@ class _MobileLiveTVTabState extends ConsumerState final category = categories[index]; return TVFocusable( onPressed: () { - ref.read(mobileLiveTvUiStateProvider.notifier).state = - LiveTvUiState( - selectedCategory: category, + uiNotifier.state = LiveTvUiState( + selectedCategory: category.categoryName, + selectedCategoryId: category.categoryId, isCategoryView: false, ); }, borderRadius: BorderRadius.circular(16), - child: Container( - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xFF4A4A4C), // Lighter grey top - Color(0xFF2C2C2E), // Medium grey bottom - ], + child: Stack( + children: [ + // Base gradient (theme-aware) + Container(decoration: AppDecorations.glossyCard(context)), + // Glossy highlight overlay (top shimmer) + Positioned( + top: 0, + left: 0, + right: 0, + height: 60, + child: Container( + decoration: AppDecorations.glossShimmer(context), + ), ), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white.withOpacity(0.05)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 8, - offset: const Offset(0, 4), + // Content (icon + text) — Positioned.fill ensures Column fills the card + Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.live_tv_rounded, + color: Color(0xFF0A84FF), + size: 36, + ), + const SizedBox(height: 10), + Text( + category.categoryName, + style: TextStyle( + color: AppDecorations.textPrimary(context), + fontWeight: FontWeight.w600, + fontSize: 13, + letterSpacing: 0.2, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ), - ], - ), - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.tv, - color: AppColors.primary, - size: 32, - ), - const SizedBox(height: 12), - Text( - category, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 14, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), + ), + ), // closes Positioned.fill + ], ), ); }, ); } - Widget _buildChannelList(List channels) { - if (channels.isEmpty) { - return Center( - child: Text( - 'Aucune chaîne trouvée', - style: GoogleFonts.inter(color: AppColors.textSecondary), - ), - ); - } - - return GridView.builder( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, - childAspectRatio: 0.85, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - ), - itemCount: channels.length, - itemBuilder: (context, index) { - final channel = channels[index]; - return _MobileChannelCard( - channel: channel, - playlist: widget.playlist, - onTap: () => - _playChannel(context, channel, channels, widget.playlist, index), - ); - }, - ); - } - void _playChannel( BuildContext context, Channel channel, @@ -538,6 +508,122 @@ class _MobileLiveTVTabState extends ConsumerState } } +/// Phase 2 widget: loads channels for the selected category (or handles search/favorites) +class _ChannelListView extends ConsumerWidget { + final PlaylistConfig playlist; + final LiveTvUiState uiState; + final String searchQuery; + final bool showFavoritesOnly; + final void Function(Channel, List, int) onPlay; + + const _ChannelListView({ + required this.playlist, + required this.uiState, + required this.searchQuery, + required this.showFavoritesOnly, + required this.onPlay, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final favorites = ref.watch(mobileFavoritesProvider); + + // Determine which category to fetch channels for + final categoryId = uiState.selectedCategoryId; + + if (categoryId == null && !showFavoritesOnly && searchQuery.isEmpty) { + // No category selected and no filter → go back to categories + return Center( + child: Text( + 'Aucune catégorie sélectionnée', + style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)), + ), + ); + } + + // Phase 2: load channels for selected category + final channelsAsync = categoryId != null + ? ref.watch( + mobileLiveChannelsByCategoryProvider((playlist, categoryId)),) + : null; + + if (channelsAsync == null) { + // Favorites or search without category: need channels — show loading + // For favorites/search without a category, we can't load without a categoryId. + // Show a message directing user to select a category first. + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + showFavoritesOnly + ? 'Sélectionnez une catégorie pour voir vos favoris' + : 'Sélectionnez une catégorie pour rechercher', + style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)), + textAlign: TextAlign.center, + ), + ), + ); + } + + return channelsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, s) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Erreur: $e', + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + ), + data: (channels) { + // Apply search or favorites filter + List displayedChannels; + if (searchQuery.isNotEmpty) { + displayedChannels = channels + .where((c) => c.name.toLowerCase().contains(searchQuery)) + .toList(); + } else if (showFavoritesOnly) { + displayedChannels = channels + .where((c) => favorites.contains(c.streamId)) + .toList(); + } else { + displayedChannels = channels; + } + + if (displayedChannels.isEmpty) { + return Center( + child: Text( + 'Aucune chaîne trouvée', + style: GoogleFonts.inter(color: AppDecorations.textSecondary(context)), + ), + ); + } + + return GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 80), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6, + childAspectRatio: 0.85, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: displayedChannels.length, + itemBuilder: (context, index) { + final channel = displayedChannels[index]; + return _MobileChannelCard( + channel: channel, + playlist: playlist, + onTap: () => onPlay(channel, displayedChannels, index), + ); + }, + ); + }, + ); + } +} + class _MobileChannelCard extends ConsumerStatefulWidget { final Channel channel; final VoidCallback onTap; @@ -594,122 +680,131 @@ class _MobileChannelCardState extends ConsumerState<_MobileChannelCard> { .read(mobileFavoritesProvider.notifier) .toggle(widget.channel.streamId); }, - child: Container( - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xFF4A4A4C), // Lighter grey top - Color(0xFF2C2C2E), // Medium grey bottom - ], - ), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( children: [ - // Channel Logo - Expanded( - flex: 3, + // Base card (theme-aware) + Container( + decoration: AppDecorations.glossyCard(context, radius: 12), + ), + // Glossy highlight overlay (top shimmer) + Positioned( + top: 0, + left: 0, + right: 0, + height: 50, child: Container( - width: double.infinity, - decoration: const BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.vertical(top: Radius.circular(12)), - ), - child: Stack( - children: [ - // Logo - // [P1-2 FIX] Optimize channel icon cache (40x40 display) - Center( - child: iconUrl != null - ? CachedNetworkImage( - imageUrl: iconUrl, - fit: BoxFit.contain, - memCacheWidth: 50, - memCacheHeight: 50, - maxWidthDiskCache: 50, - maxHeightDiskCache: 50, - cacheManager: AppCacheManager.instance, - errorWidget: (_, __, ___) => const Icon( - Icons.tv, - color: Colors.white38, - size: 40, + decoration: AppDecorations.glossShimmer(context, radius: 12), + ), + ), + // Content + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Channel Logo + Expanded( + flex: 3, + child: Container( + width: double.infinity, + decoration: AppDecorations.channelCardBase(context), + child: Stack( + children: [ + // Logo + // [P1-2 FIX] Optimize channel icon cache (40x40 display) + Center( + child: iconUrl != null + ? CachedNetworkImage( + imageUrl: iconUrl, + fit: BoxFit.contain, + memCacheWidth: 50, + memCacheHeight: 50, + maxWidthDiskCache: 50, + maxHeightDiskCache: 50, + cacheManager: AppCacheManager.instance, + errorWidget: (_, __, ___) => Icon( + Icons.tv, + color: AppDecorations.iconMuted(context), + size: 40, + ), + placeholder: (_, __) => Icon( + Icons.tv, + color: AppDecorations.iconMuted(context), + size: 40, + ), + ) + : Icon( + Icons.tv, + color: AppDecorations.iconMuted(context), + size: 40, + ), + ), + // Favorite badge + if (isFavorite) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), ), - placeholder: (_, __) => const Icon( - Icons.tv, - color: Colors.white24, - size: 40, + child: const Icon( + Icons.favorite, + color: Colors.white, + size: 12, ), - ) - : const Icon( - Icons.tv, - color: Colors.white38, - size: 40, ), - ), - // Favorite badge - if (isFavorite) - Positioned( - top: 4, - right: 4, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.circular(12), ), - child: const Icon( - Icons.favorite, - color: Colors.white, - size: 12, - ), - ), - ), - ], - ), - ), - ), - // Channel Name - Expanded( - flex: 2, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.channel.name, - style: const TextStyle( - color: AppColors.textPrimary, - fontWeight: FontWeight.w500, - fontSize: 11, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, + ], ), - if (_epgTitle != null) ...[ - const SizedBox(height: 2), - Text( - _epgTitle!, - style: const TextStyle( - color: Colors.yellow, - fontSize: 9, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], + ), ), - ), - ), + // Channel Name + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 4, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.channel.name, + style: TextStyle( + color: AppDecorations.textPrimary(context), + fontWeight: FontWeight.w500, + fontSize: 11, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (_epgTitle != null) ...[ + const SizedBox(height: 2), + Text( + _epgTitle!, + style: const TextStyle( + color: Colors.yellow, + fontSize: 9, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ), + ], + ), // end channel card content ], - ), - ), - ); + ), // end Stack + ), // end ClipRRect + ); // end TVFocusable } } diff --git a/lib/features/iptv/widgets/mobile_movies_tab.dart b/lib/features/iptv/widgets/mobile_movies_tab.dart index 0267c9c..3181a9e 100644 --- a/lib/features/iptv/widgets/mobile_movies_tab.dart +++ b/lib/features/iptv/widgets/mobile_movies_tab.dart @@ -5,7 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/mobile/widgets/mobile_poster_card.dart'; import 'package:xtremobile/core/models/playlist_config.dart'; -import 'package:xtremobile/core/theme/app_colors.dart'; +import 'package:xtremobile/core/theme/app_decorations.dart'; import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart'; import 'package:xtremobile/core/models/iptv_models.dart' as model; @@ -257,7 +257,7 @@ class _MobileMoviesTabState extends ConsumerState } return Container( - decoration: const BoxDecoration(gradient: AppColors.appleTvGradient), + decoration: AppDecorations.background(context), child: PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) async { @@ -318,8 +318,8 @@ class _MobileMoviesTabState extends ConsumerState bottom: false, child: RefreshIndicator( onRefresh: _refresh, - color: AppColors.primary, - backgroundColor: AppColors.surface, + color: Theme.of(context).colorScheme.primary, + backgroundColor: Theme.of(context).colorScheme.surface, child: CustomScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), @@ -371,30 +371,27 @@ class _MobileMoviesTabState extends ConsumerState borderRadius: BorderRadius.circular(12), child: Container( height: 40, - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - ), + decoration: AppDecorations.searchBar(context), padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - const Icon( + Icon( Icons.search, size: 20, - color: AppColors.textSecondary, + color: AppDecorations.textSecondary(context), ), const SizedBox(width: 8), Expanded( child: ExcludeFocus( excluding: !_isSearchEditing, child: TextField( - cursorColor: Colors.white, + cursorColor: AppDecorations.textPrimary(context), controller: _searchController, focusNode: _searchFocusNode, readOnly: !_isSearchEditing, - style: const TextStyle( + style: TextStyle( fontSize: 14, - color: AppColors.textPrimary, + color: AppDecorations.textPrimary(context), ), decoration: InputDecoration( hintText: _selectedCategoryId != null @@ -427,10 +424,10 @@ class _MobileMoviesTabState extends ConsumerState _searchController.clear(); _onSearchChanged(''); }, - child: const Icon( + child: Icon( Icons.close, size: 16, - color: AppColors.textSecondary, + color: AppDecorations.textSecondary(context), ), ), ], diff --git a/lib/features/iptv/widgets/mobile_series_tab.dart b/lib/features/iptv/widgets/mobile_series_tab.dart index cdaf245..63cb55d 100644 --- a/lib/features/iptv/widgets/mobile_series_tab.dart +++ b/lib/features/iptv/widgets/mobile_series_tab.dart @@ -5,7 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/mobile/widgets/mobile_poster_card.dart'; import 'package:xtremobile/core/models/playlist_config.dart'; -import 'package:xtremobile/core/theme/app_colors.dart'; +import 'package:xtremobile/core/theme/app_decorations.dart'; import 'package:xtremobile/core/widgets/components/hero_carousel.dart'; import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremobile/mobile/providers/mobile_xtream_providers.dart'; @@ -111,7 +111,7 @@ class _MobileSeriesTabState extends ConsumerState // Add timeout to prevent infinite loading state final newSeries = await service .getSeriesPaginated() - .timeout(const Duration(seconds: 15)); + .timeout(const Duration(seconds: 45)); if (mounted) { setState(() { @@ -214,7 +214,7 @@ class _MobileSeriesTabState extends ConsumerState .toList(); return Container( - decoration: const BoxDecoration(gradient: AppColors.appleTvGradient), + decoration: AppDecorations.background(context), child: PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) async { @@ -268,8 +268,8 @@ class _MobileSeriesTabState extends ConsumerState bottom: false, child: RefreshIndicator( onRefresh: _refresh, - color: AppColors.primary, - backgroundColor: AppColors.surface, + color: Theme.of(context).colorScheme.primary, + backgroundColor: Theme.of(context).colorScheme.surface, child: CustomScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), @@ -315,31 +315,27 @@ class _MobileSeriesTabState extends ConsumerState borderRadius: BorderRadius.circular(12), child: Container( height: 40, - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(12), - // Removed internal editing border here to prevent conflict and "inside frame" look - ), + decoration: AppDecorations.searchBar(context), padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - const Icon( + Icon( Icons.search, size: 20, - color: AppColors.textSecondary, + color: AppDecorations.textSecondary(context), ), const SizedBox(width: 8), Expanded( child: ExcludeFocus( excluding: !_isSearchEditing, child: TextField( - cursorColor: Colors.white, // Extra safety + cursorColor: AppDecorations.textPrimary(context), controller: _searchController, focusNode: _searchFocusNode, readOnly: !_isSearchEditing, - style: const TextStyle( + style: TextStyle( fontSize: 14, - color: AppColors.textPrimary, + color: AppDecorations.textPrimary(context), ), decoration: const InputDecoration( hintText: 'Rechercher une série...', @@ -370,10 +366,10 @@ class _MobileSeriesTabState extends ConsumerState _searchController.clear(); _onSearchChanged(''); }, - child: const Icon( + child: Icon( Icons.close, size: 16, - color: AppColors.textSecondary, + color: AppDecorations.textSecondary(context), ), ), ], @@ -419,6 +415,50 @@ class _MobileSeriesTabState extends ConsumerState child: Center(child: CircularProgressIndicator()), ), ), + + if (!_isLoading && displaySeries.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.video_library_outlined, + size: 64, + color: AppDecorations.textSecondary(context), + ), + const SizedBox(height: 16), + Text( + 'Aucune série chargée', + style: TextStyle( + color: AppDecorations.textPrimary(context), + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + 'Le chargement a pris trop de temps ou une erreur est survenue.', + textAlign: TextAlign.center, + style: TextStyle( + color: AppDecorations.textSecondary(context), + fontSize: 14, + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: () { + setState(() => _hasMore = true); + _loadMoreSeries(); + }, + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + ), + ], + ), + ), + ), ], ), ), diff --git a/lib/features/iptv/widgets/mobile_settings_tab.dart b/lib/features/iptv/widgets/mobile_settings_tab.dart index ea23d30..12662c6 100644 --- a/lib/features/iptv/widgets/mobile_settings_tab.dart +++ b/lib/features/iptv/widgets/mobile_settings_tab.dart @@ -4,7 +4,8 @@ import 'package:google_fonts/google_fonts.dart'; // import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import 'package:xtremobile/mobile/providers/mobile_settings_providers.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; -import 'package:xtremobile/core/theme/app_colors.dart'; +import 'package:xtremobile/core/theme/app_decorations.dart'; +import 'package:xtremobile/core/providers/theme_provider.dart'; import 'package:xtremobile/features/iptv/screens/mobile_playlist_selection_screen.dart'; /// Simplified mobile settings tab - optimized for TV remote control @@ -79,9 +80,9 @@ class _MobileSettingsTabState extends ConsumerState { child: Image.asset( 'assets/images/logo_xtremobile.png', fit: BoxFit.contain, - errorBuilder: (_, __, ___) => const Icon( + errorBuilder: (_, __, ___) => Icon( Icons.play_circle_filled, - color: AppColors.primary, + color: Theme.of(context).colorScheme.primary, size: 28, ), ), @@ -95,14 +96,14 @@ class _MobileSettingsTabState extends ConsumerState { style: GoogleFonts.inter( fontSize: 18, fontWeight: FontWeight.bold, - color: AppColors.textPrimary, + color: AppDecorations.textPrimary(context), ), ), Text( 'Version 1.5.1', style: GoogleFonts.inter( fontSize: 13, - color: AppColors.textSecondary, + color: AppDecorations.textSecondary(context), ), ), ], @@ -113,6 +114,13 @@ class _MobileSettingsTabState extends ConsumerState { const SizedBox(height: 16), + // === APPARENCE === + _buildSectionHeader('Apparence'), + + _buildThemeSwitcher(), + + const SizedBox(height: 8), + _buildSettingItem( icon: Icons.access_time, title: 'Afficher l\'heure', @@ -304,6 +312,109 @@ class _MobileSettingsTabState extends ConsumerState { // === UI BUILDERS === + Widget _buildThemeSwitcher() { + final themeState = ref.watch(themeProvider); + final notifier = ref.read(themeProvider.notifier); + + final segments = [ + (AppThemeMode.dark, Icons.dark_mode_rounded, 'Sombre'), + (AppThemeMode.light, Icons.light_mode_rounded, 'Clair'), + (AppThemeMode.system, Icons.brightness_auto_rounded, 'Système'), + ]; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.brightness_6_rounded, + color: Theme.of(context).colorScheme.primary, + size: 24, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Thème', + style: TextStyle( + color: AppDecorations.textPrimary(context), + fontWeight: FontWeight.w500, + fontSize: 15, + ), + ), + const SizedBox(height: 10), + // Segmented control + LayoutBuilder( + builder: (ctx, constraints) { + return Row( + children: segments.map((seg) { + final (mode, icon, label) = seg; + final selected = themeState.appThemeMode == mode; + final accentColor = Theme.of(context).colorScheme.primary; + return Expanded( + child: GestureDetector( + onTap: () => notifier.setThemeMode(mode), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.symmetric( + vertical: 7, + ), + decoration: BoxDecoration( + color: selected + ? accentColor + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16, + color: selected + ? Colors.white + : AppDecorations.textSecondary(context), + ), + const SizedBox(height: 3), + Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + color: selected + ? Colors.white + : AppDecorations.textSecondary(context), + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + Widget _buildSectionHeader(String title) { return Padding( padding: const EdgeInsets.only(left: 4, bottom: 8, top: 8), @@ -312,7 +423,7 @@ class _MobileSettingsTabState extends ConsumerState { style: GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w600, - color: AppColors.primary, + color: Theme.of(context).colorScheme.primary, letterSpacing: 1.2, ), ), @@ -334,12 +445,12 @@ class _MobileSettingsTabState extends ConsumerState { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: AppColors.surface, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ - Icon(icon, color: AppColors.primary, size: 24), + Icon(icon, color: Theme.of(context).colorScheme.primary, size: 24), const SizedBox(width: 16), Expanded( child: Column( @@ -347,8 +458,8 @@ class _MobileSettingsTabState extends ConsumerState { children: [ Text( title, - style: const TextStyle( - color: AppColors.textPrimary, + style: TextStyle( + color: AppDecorations.textPrimary(context), fontWeight: FontWeight.w500, fontSize: 15, ), @@ -356,8 +467,8 @@ class _MobileSettingsTabState extends ConsumerState { if (subtitle != null) Text( subtitle, - style: const TextStyle( - color: AppColors.textSecondary, + style: TextStyle( + color: AppDecorations.textSecondary(context), fontSize: 12, ), ), @@ -370,13 +481,13 @@ class _MobileSettingsTabState extends ConsumerState { vertical: 6, ), decoration: BoxDecoration( - color: AppColors.primary.withOpacity(0.15), + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(8), ), child: Text( value, - style: const TextStyle( - color: AppColors.primary, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold, fontSize: 13, ), @@ -413,12 +524,12 @@ class _MobileSettingsTabState extends ConsumerState { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: AppColors.surface, + color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), ), child: Row( children: [ - Icon(icon, color: AppColors.primary, size: 24), + Icon(icon, color: Theme.of(context).colorScheme.primary, size: 24), const SizedBox(width: 16), Expanded( child: Column( @@ -426,8 +537,8 @@ class _MobileSettingsTabState extends ConsumerState { children: [ Text( label, - style: const TextStyle( - color: AppColors.textPrimary, + style: TextStyle( + color: AppDecorations.textPrimary(context), fontWeight: FontWeight.w500, fontSize: 15, ), @@ -436,8 +547,8 @@ class _MobileSettingsTabState extends ConsumerState { subtitle, style: TextStyle( color: hasFilters - ? AppColors.primary - : AppColors.textSecondary, + ? Theme.of(context).colorScheme.primary + : AppDecorations.textSecondary(context), fontSize: 12, fontWeight: hasFilters ? FontWeight.w600 : FontWeight.normal, @@ -448,7 +559,7 @@ class _MobileSettingsTabState extends ConsumerState { ], ), ), - const Icon(Icons.edit, color: AppColors.textSecondary, size: 20), + Icon(Icons.edit, color: AppDecorations.textSecondary(context), size: 20), ], ), ), @@ -466,20 +577,20 @@ class _MobileSettingsTabState extends ConsumerState { showDialog( context: context, builder: (ctx) => AlertDialog( - backgroundColor: AppColors.surface, + backgroundColor: Theme.of(ctx).colorScheme.surface, title: Text( 'Filtres $label', - style: const TextStyle(color: AppColors.textPrimary), + style: TextStyle(color: AppDecorations.textPrimary(ctx)), ), content: TextField( controller: tempController, autofocus: true, - style: const TextStyle(color: AppColors.textPrimary), + style: TextStyle(color: AppDecorations.textPrimary(ctx)), decoration: InputDecoration( hintText: 'Séparez par des virgules', - hintStyle: const TextStyle(color: AppColors.textSecondary), + hintStyle: TextStyle(color: AppDecorations.textSecondary(ctx)), filled: true, - fillColor: AppColors.background, + fillColor: Theme.of(ctx).scaffoldBackgroundColor, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none, diff --git a/lib/mobile/providers/mobile_xtream_providers.dart b/lib/mobile/providers/mobile_xtream_providers.dart index 3bd410d..ae47f98 100644 --- a/lib/mobile/providers/mobile_xtream_providers.dart +++ b/lib/mobile/providers/mobile_xtream_providers.dart @@ -145,14 +145,24 @@ final mobileLiveTvUiStateProvider = StateProvider((ref) => LiveTvUiState()); class LiveTvUiState { - final String? selectedCategory; + final String? selectedCategory; // category NAME (for display) + final String? selectedCategoryId; // category ID (for API calls) final bool isCategoryView; - LiveTvUiState({this.selectedCategory, this.isCategoryView = true}); + LiveTvUiState({ + this.selectedCategory, + this.selectedCategoryId, + this.isCategoryView = true, + }); - LiveTvUiState copyWith({String? selectedCategory, bool? isCategoryView}) { + LiveTvUiState copyWith({ + String? selectedCategory, + String? selectedCategoryId, + bool? isCategoryView, + }) { return LiveTvUiState( selectedCategory: selectedCategory ?? this.selectedCategory, + selectedCategoryId: selectedCategoryId ?? this.selectedCategoryId, isCategoryView: isCategoryView ?? this.isCategoryView, ); } diff --git a/lib/mobile/theme/mobile_theme.dart b/lib/mobile/theme/mobile_theme.dart index 6dffffc..508b6e6 100644 --- a/lib/mobile/theme/mobile_theme.dart +++ b/lib/mobile/theme/mobile_theme.dart @@ -2,9 +2,8 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import '../../core/theme/app_colors.dart'; -/// XtremFlow Mobile Theme -/// -/// Adapted from AppTheme for touch-first screens. +/// XtremFlow Mobile Theme — Apple TV Grey Glossy Style (dark) +/// + Apple Clean White (light) class MobileTheme { MobileTheme._(); @@ -14,7 +13,7 @@ class MobileTheme { static const double spacing12 = 12.0; static const double spacing16 = 16.0; static const double spacing24 = 24.0; - + // ============ RADIUS ============ static const double radiusSm = 8.0; static const double radiusMd = 12.0; @@ -23,80 +22,369 @@ class MobileTheme { // ============ DARK THEME (Mobile Main) ============ static ThemeData get darkTheme { final baseTheme = ThemeData.dark(useMaterial3: true); - + return baseTheme.copyWith( colorScheme: AppColors.darkColorScheme, - scaffoldBackgroundColor: Colors.black, // Enforce Pure Black - - // Typography: Scaled down for mobile - textTheme: TextTheme( - displayLarge: GoogleFonts.inter(fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -1.0, color: Colors.white), - displayMedium: GoogleFonts.inter(fontSize: 28, fontWeight: FontWeight.w700, letterSpacing: -0.5, color: Colors.white), - displaySmall: GoogleFonts.inter(fontSize: 24, fontWeight: FontWeight.w600, color: Colors.white), - headlineMedium: GoogleFonts.inter(fontSize: 20, fontWeight: FontWeight.w600, color: Colors.white), - titleLarge: GoogleFonts.inter(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white), - titleMedium: GoogleFonts.inter(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), - bodyLarge: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w400, color: const Color(0xFF9E9E9E)), - bodyMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w400, color: const Color(0xFF9E9E9E)), - labelLarge: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.white), + scaffoldBackgroundColor: AppColors.background, + + // Global text selection — white cursor on dark + textSelectionTheme: const TextSelectionThemeData( + cursorColor: Colors.white, + selectionColor: Color(0x4DFFFFFF), + selectionHandleColor: Colors.white, ), - // AppBar: Transparent / Glass + // Typography: San Francisco proxy via Inter — clean and modern + textTheme: TextTheme( + displayLarge: GoogleFonts.inter( + fontSize: 32, + fontWeight: FontWeight.w700, + letterSpacing: -1.0, + color: AppColors.textPrimary, + ), + displayMedium: GoogleFonts.inter( + fontSize: 28, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: AppColors.textPrimary, + ), + displaySmall: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + headlineMedium: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + titleLarge: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + titleMedium: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + bodyLarge: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), + bodyMedium: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), + labelLarge: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + + // AppBar: Transparent — background gradient shows through appBarTheme: AppBarTheme( - backgroundColor: Colors.transparent, // Fully transparent for gradient overlay - foregroundColor: Colors.white, + backgroundColor: Colors.transparent, + foregroundColor: AppColors.textPrimary, elevation: 0, centerTitle: true, scrolledUnderElevation: 0, titleTextStyle: GoogleFonts.inter( fontSize: 16, fontWeight: FontWeight.w600, - color: Colors.white, + color: AppColors.textPrimary, ), ), - - // Bottom Navigation Bar + + // Bottom Navigation Bar: glossy dark surface, Apple Blue selected bottomNavigationBarTheme: BottomNavigationBarThemeData( - backgroundColor: const Color(0xFF1C1C1E).withOpacity(0.95), // Darker surface - selectedItemColor: Colors.white, - unselectedItemColor: const Color(0xFF9E9E9E), - selectedLabelStyle: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.w600), - unselectedLabelStyle: GoogleFonts.inter(fontSize: 10, fontWeight: FontWeight.w500), + backgroundColor: AppColors.surface, + selectedItemColor: AppColors.appleBlue, + unselectedItemColor: AppColors.textSecondary, + selectedLabelStyle: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w500, + ), type: BottomNavigationBarType.fixed, elevation: 0, ), - - // Inputs + + // Inputs: dark surface fill, white focus border inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: const Color(0xFF1C1C1E), // Dark Grey Surface - contentPadding: const EdgeInsets.symmetric(horizontal: spacing16, vertical: spacing12), + fillColor: AppColors.surfaceVariant, + contentPadding: const EdgeInsets.symmetric( + horizontal: spacing16, + vertical: spacing12, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), - borderSide: BorderSide.none, + borderSide: const BorderSide( + color: AppColors.glassBorderColor, + width: 1, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), - borderSide: BorderSide.none, + borderSide: const BorderSide( + color: AppColors.glassBorderColor, + width: 1, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), - borderSide: const BorderSide(color: Colors.white, width: 1), // White border focus + borderSide: const BorderSide(color: Colors.white, width: 1.5), + ), + hintStyle: GoogleFonts.inter( + color: AppColors.textTertiary, + fontSize: 14, ), - hintStyle: GoogleFonts.inter(color: const Color(0xFF616161), fontSize: 14), ), - - // Buttons + + // Buttons: Apple Blue fill — modern CTA style filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( - backgroundColor: Colors.white, // White buttons - foregroundColor: Colors.black, + backgroundColor: AppColors.appleBlue, + foregroundColor: Colors.white, elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusMd)), - textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600, fontSize: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), + ), + textStyle: GoogleFonts.inter( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + + // Cards: glossy dark surface with subtle glass border + cardTheme: const CardThemeData( + color: AppColors.surfaceVariant, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(radiusMd)), + side: BorderSide(color: AppColors.glassBorderColor, width: 1), + ), + ), + + // Dialogs: glass/dark + dialogTheme: DialogThemeData( + backgroundColor: AppColors.surface, + elevation: 24, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusLg), + side: const BorderSide(color: AppColors.glassBorderColor, width: 1), + ), + titleTextStyle: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + contentTextStyle: GoogleFonts.inter( + fontSize: 14, + color: AppColors.textSecondary, ), ), ); } + + // ============ LIGHT THEME (Apple Clean White) ============ + static ThemeData get lightTheme { + final baseTheme = ThemeData.light(useMaterial3: true); + + const bgColor = Color(0xFFEAEAF0); + const surfaceColor = Color(0xFFF2F2F7); + const primaryColor = Color(0xFF007AFF); + const textPrimaryColor = Color(0xFF000000); + const textSecondaryColor = Color(0x993C3C43); + const textTertiaryColor = Color(0xFFC7C7CC); + const borderColor = Color(0x1A000000); + + return baseTheme.copyWith( + colorScheme: const ColorScheme.light( + primary: primaryColor, + secondary: Color(0xFF34C759), + surface: surfaceColor, + error: AppColors.error, + onPrimary: Colors.white, + onSecondary: Colors.white, + onSurface: textPrimaryColor, + onError: Colors.white, + brightness: Brightness.light, + ), + scaffoldBackgroundColor: bgColor, + + textSelectionTheme: const TextSelectionThemeData( + cursorColor: primaryColor, + selectionColor: Color(0x4D007AFF), + selectionHandleColor: primaryColor, + ), + + textTheme: TextTheme( + displayLarge: GoogleFonts.inter( + fontSize: 32, + fontWeight: FontWeight.w700, + letterSpacing: -1.0, + color: textPrimaryColor, + ), + displayMedium: GoogleFonts.inter( + fontSize: 28, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: textPrimaryColor, + ), + displaySmall: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + headlineMedium: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + titleLarge: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + titleMedium: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + bodyLarge: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w400, + color: textSecondaryColor, + ), + bodyMedium: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w400, + color: textSecondaryColor, + ), + labelLarge: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + ), + + appBarTheme: AppBarTheme( + backgroundColor: surfaceColor, + foregroundColor: textPrimaryColor, + elevation: 0, + scrolledUnderElevation: 1, + shadowColor: borderColor, + centerTitle: true, + titleTextStyle: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w600, + color: textPrimaryColor, + ), + ), + + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: surfaceColor, + selectedItemColor: primaryColor, + unselectedItemColor: textSecondaryColor, + selectedLabelStyle: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + ), + unselectedLabelStyle: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w500, + ), + type: BottomNavigationBarType.fixed, + elevation: 8, + ), + + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surfaceColor, + contentPadding: const EdgeInsets.symmetric( + horizontal: spacing16, + vertical: spacing12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusMd), + borderSide: const BorderSide(color: borderColor, width: 1), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusMd), + borderSide: const BorderSide(color: borderColor, width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusMd), + borderSide: const BorderSide(color: primaryColor, width: 2), + ), + hintStyle: GoogleFonts.inter( + color: textTertiaryColor, + fontSize: 14, + ), + ), + + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), + ), + textStyle: GoogleFonts.inter( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + + cardTheme: const CardThemeData( + color: surfaceColor, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(radiusMd)), + side: BorderSide(color: borderColor, width: 1), + ), + shadowColor: borderColor, + ), + + dialogTheme: DialogThemeData( + backgroundColor: surfaceColor, + elevation: 8, + shadowColor: borderColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusLg), + side: const BorderSide(color: borderColor, width: 1), + ), + titleTextStyle: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.bold, + color: textPrimaryColor, + ), + contentTextStyle: GoogleFonts.inter( + fontSize: 14, + color: textSecondaryColor, + ), + ), + ); + } + + /// Returns the appropriate MobileTheme based on the current [BuildContext] brightness. + static ThemeData themeOf(BuildContext context) { + final brightness = Theme.of(context).brightness; + return brightness == Brightness.dark ? darkTheme : lightTheme; + } } diff --git a/lib/mobile/widgets/mobile_poster_card.dart b/lib/mobile/widgets/mobile_poster_card.dart index 6fc806d..2ec2c8f 100644 --- a/lib/mobile/widgets/mobile_poster_card.dart +++ b/lib/mobile/widgets/mobile_poster_card.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:xtremobile/core/theme/app_colors.dart'; +import 'package:xtremobile/core/theme/app_decorations.dart'; import 'package:xtremobile/mobile/widgets/tv_focusable.dart'; import 'package:xtremobile/core/utils/image_cache_config.dart'; @@ -26,94 +26,132 @@ class MobilePosterCard extends StatelessWidget { @override Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - // Focusable Poster + // Shadow lives on this outer Container so ClipRRect doesn't swallow it Expanded( - child: TVFocusable( - onPressed: onTap, - onLongPress: onLongPress, - borderRadius: BorderRadius.circular(12), - child: Stack( - fit: StackFit.expand, - children: [ - // Image - // [P1-2 FIX] Optimize image cache: resize to display size (100x150) - // Before: Downloads full resolution (1000x1500px), caches at full size - // After: Cache only 100x150px needed for display - // Impact: -60% disk cache, -40% memory usage - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: imageUrl != null && imageUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: imageUrl!, - fit: BoxFit.cover, - // Memory cache dimensions (match display size) - memCacheWidth: 120, - memCacheHeight: 180, - // Disk cache dimensions (slightly larger for quality) - maxWidthDiskCache: 120, - maxHeightDiskCache: 180, - cacheManager: AppCacheManager.instance, - 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), - ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: isDark + ? const Color(0x99000000) + : const Color(0x28000000), + blurRadius: 12, + offset: const Offset(0, 6), ), - - // 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), + ], + ), + child: TVFocusable( + onPressed: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, + children: [ + // Image / placeholder (clipped) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: imageUrl != null && imageUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: imageUrl!, + fit: BoxFit.cover, + memCacheWidth: 120, + memCacheHeight: 180, + maxWidthDiskCache: 120, + maxHeightDiskCache: 180, + cacheManager: AppCacheManager.instance, + errorWidget: (_, __, ___) => Container( + decoration: + AppDecorations.channelCardBase(context), + child: Icon( + placeholderIcon, + color: AppDecorations.iconMuted(context), + ), + ), + placeholder: (_, __) => Container( + decoration: + AppDecorations.channelCardBase(context), + child: Icon( + placeholderIcon, + color: AppDecorations.iconMuted(context), + ), + ), + ) + : Container( + decoration: AppDecorations.channelCardBase(context), + child: Icon( + placeholderIcon, + color: AppDecorations.iconMuted(context), + ), ), - ], - ), - ), ), - if (isWatched) - Positioned( - top: 6, - right: 6, - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: AppColors.success, - shape: BoxShape.circle, + // Rating badge + 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, + ), + ), + ], + ), ), - child: const Icon(Icons.check, size: 10, color: Colors.white), ), - ), - ], + + // Watched badge + if (isWatched) + Positioned( + top: 6, + right: 6, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Color(0xFF30D158), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check, + size: 10, + color: Colors.white, + ), + ), + ), + ], + ), ), ), ), - const SizedBox(height: 8), + + const SizedBox(height: 6), + // Title Padding( padding: const EdgeInsets.symmetric(horizontal: 4), @@ -122,8 +160,8 @@ class MobilePosterCard extends StatelessWidget { textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.textPrimary, + style: TextStyle( + color: AppDecorations.textPrimary(context), fontSize: 12, fontWeight: FontWeight.w500, ), diff --git a/lib/mobile/widgets/mobile_scaffold.dart b/lib/mobile/widgets/mobile_scaffold.dart index d9cfb7c..d905510 100644 --- a/lib/mobile/widgets/mobile_scaffold.dart +++ b/lib/mobile/widgets/mobile_scaffold.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; -import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; class MobileScaffold extends ConsumerStatefulWidget { final Widget child; @@ -34,7 +34,7 @@ class _MobileScaffoldState extends ConsumerState { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: AppColors.background, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: Column( children: [ // Top Navigation Bar (Redesigned) @@ -51,19 +51,7 @@ class _MobileScaffoldState extends ConsumerState { child: Container( height: 64, // Taller pill to accommodate larger logo padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A) - .withOpacity(0.9), // Slightly more opaque pill - borderRadius: BorderRadius.circular(40), - border: Border.all(color: Colors.white.withOpacity(0.1)), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.3), - blurRadius: 10, - spreadRadius: 2, - ), - ], - ), + decoration: AppDecorations.navPill(context), child: Row( children: [ // --- LEFT: LOGO & BRAND --- @@ -89,11 +77,11 @@ class _MobileScaffoldState extends ConsumerState { ), ), const SizedBox(width: 12), - const Text( + Text( 'XtremFlow', style: TextStyle( - color: Colors.white, - fontSize: 20, // Slightly larger text + color: AppDecorations.textPrimary(context), + fontSize: 20, fontWeight: FontWeight.bold, fontFamily: 'Inter', ), @@ -127,8 +115,7 @@ class _MobileScaffoldState extends ConsumerState { vertical: 8, ), decoration: BoxDecoration( - color: Colors.white - .withOpacity(0.08), // Subtle background + color: AppDecorations.divider(context), borderRadius: BorderRadius.circular(20), ), child: StreamBuilder( @@ -139,8 +126,8 @@ class _MobileScaffoldState extends ConsumerState { DateFormat('HH:mm').format(snapshot.data!); return Text( time, - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: AppDecorations.textPrimary(context), fontWeight: FontWeight.bold, fontSize: 16, ), @@ -214,11 +201,12 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> { @override Widget build(BuildContext context) { - // Selected: Black text on White bg - // Focused: White text on Translucent bg - // Normal: White70 text on Transparent bg - final Color textColor = widget.isSelected ? Colors.black : Colors.white; - final Color iconColor = widget.isSelected ? Colors.black : Colors.white; + const Color selectedColor = Color(0xFF0A84FF); + final Color unselectedColor = AppDecorations.textSecondary(context); + final Color textColor = + widget.isSelected ? Colors.white : unselectedColor; + final Color iconColor = + widget.isSelected ? Colors.white : unselectedColor; final FontWeight fontWeight = widget.isSelected ? FontWeight.w700 : FontWeight.w500; @@ -243,15 +231,13 @@ class _AppleTVNavItemState extends State<_AppleTVNavItem> { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: widget.isSelected - ? Colors.white + ? selectedColor : (_isFocused - ? Colors.white.withOpacity(0.2) // Explicit focus highlight + ? AppDecorations.divider(context).withValues(alpha: 0.2) : Colors.transparent), - borderRadius: BorderRadius.circular(20), // Matches pill shape + borderRadius: BorderRadius.circular(20), border: _isFocused && !widget.isSelected - ? Border.all( - color: Colors.white.withOpacity(0.3), - ) // Subtle border for focus + ? Border.all(color: AppDecorations.divider(context)) : null, ), child: Row( diff --git a/lib/mobile/widgets/tv_focusable.dart b/lib/mobile/widgets/tv_focusable.dart index 27cfba2..a745f93 100644 --- a/lib/mobile/widgets/tv_focusable.dart +++ b/lib/mobile/widgets/tv_focusable.dart @@ -23,8 +23,8 @@ class TVFocusable extends StatefulWidget { this.onFocus, this.focusNode, this.autofocus = false, - this.scale = 1.08, // More visible scale - this.borderWidth = 3.0, + this.scale = 1.05, // Apple TV subtle lift + this.borderWidth = 2.0, this.focusColor = Colors.white, this.borderRadius, this.longPressDuration = const Duration(seconds: 3), @@ -157,15 +157,14 @@ class _TVFocusableState extends State { boxShadow: [ BoxShadow( color: Colors.black - .withOpacity(0.5), // Shadow for depth - blurRadius: 10, - offset: const Offset(0, 4), + .withOpacity(0.5), // Deep shadow for lift + blurRadius: 16, + offset: const Offset(0, 6), ), - BoxShadow( - color: widget.focusColor - .withOpacity(0.3), // Glow effect + const BoxShadow( + color: Color(0x40FFFFFF), // White glow blurRadius: 12, - spreadRadius: 2, + spreadRadius: 0, ), ], )