feat: Introduce core IPTV features, authentication screen, web player, and application theming.

This commit is contained in:
Michael committed 2025-12-06 18:19:21 +01:00
1 parent c90ea530ea
commit e699d3ec31
8 files changed
+931 -1379

No files matched your search

+69 -95
View File
@@ -1,125 +1,99 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// XtremFlow Premium Color Palette /// XtremFlow Cinematic Minimalist Palette
/// ///
/// Modern theme with cyan/teal gradients - supports light and dark modes /// A deep, dark, and neutral palette designed for content-first experiences.
/// Focuses on #0A0A0A backgrounds and subtle surfaces.
class AppColors { class AppColors {
AppColors._(); AppColors._();
// ============ PRIMARY GRADIENT ============ // ============ BACKGROUNDS ============
/// Cyan primary - main accent color /// Pitch black for OLED/Main backgrounds
static const Color primary = Color(0xFF00BCD4); static const Color background = Color(0xFF0A0A0A);
/// Teal secondary - gradient end /// Dark grey for cards/sheets
static const Color secondary = Color(0xFF00ACC1); static const Color surface = Color(0xFF141414);
/// Primary gradient for buttons and highlights /// Slightly lighter for hover states or active items
static const Color surfaceLight = Color(0xFF1F1F1F);
/// For inputs or secondary surfaces
static const Color surfaceVariant = Color(0xFF2A2A2A);
// ============ ACCENTS (MINIMALIST) ============
/// Primary Brand Color (Electric Blue/Violet for modern feel)
/// Replacing the old Gradient Cyan with a solid, punchy color.
static const Color primary = Color(0xFF3F51B5); // Indigo
/// Secondary accent
static const Color accent = Color(0xFF7986CB);
/// Primary Gradient (Subtle, for buttons only)
static const LinearGradient primaryGradient = LinearGradient( static const LinearGradient primaryGradient = LinearGradient(
colors: [primary, secondary], colors: [primary, accent],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
); );
// ============ DARK MODE SURFACE COLORS ============ // ============ TEXT ============
/// Deep dark background /// Pure white for high contrast headers
static const Color backgroundDark = Color(0xFF0A0E1A); static const Color textPrimary = Color(0xFFFFFFFF);
/// Card/elevated surface (dark) /// Light grey for body text
static const Color surfaceDark = Color(0xFF121829); static const Color textSecondary = Color(0xFFB3B3B3);
/// Lighter surface variant (dark) /// Darker grey for hints/disabled
static const Color surfaceVariantDark = Color(0xFF1E2638); static const Color textTertiary = Color(0xFF808080);
/// Border/divider color (dark) // ============ FUNCTIONAL COLORS ============
static const Color borderDark = Color(0xFF2A3441); static const Color success = Color(0xFF4CAF50);
static const Color warning = Color(0xFFFFC107);
static const Color error = Color(0xFFCF6679);
static const Color info = Color(0xFF2196F3);
static const Color live = Color(0xFFFF0000); // Standard Red for LIVE
// ============ LIGHT MODE SURFACE COLORS ============ // ============ BORDERS ============
/// Light background static const Color border = Color(0xFF333333);
static const Color backgroundLight = Color(0xFFF5F7FA);
/// Card/elevated surface (light)
static const Color surfaceLight = Color(0xFFFFFFFF);
/// Lighter surface variant (light)
static const Color surfaceVariantLight = Color(0xFFF0F2F5);
/// Border/divider color (light)
static const Color borderLight = Color(0xFFE2E8F0);
// ============ DARK MODE TEXT COLORS ============
static const Color textPrimaryDark = Color(0xFFF5F5F5);
static const Color textSecondaryDark = Color(0xFF94A3B8);
static const Color textDisabledDark = Color(0xFF64748B);
// ============ LIGHT MODE TEXT COLORS ============
static const Color textPrimaryLight = Color(0xFF1A1A2E);
static const Color textSecondaryLight = Color(0xFF64748B);
static const Color textDisabledLight = Color(0xFF94A3B8);
// ============ LEGACY ALIASES (for backwards compatibility) ============
static const Color background = backgroundDark;
static const Color surface = surfaceDark;
static const Color surfaceVariant = surfaceVariantDark;
static const Color border = borderDark;
static const Color textPrimary = textPrimaryDark;
static const Color textSecondary = textSecondaryDark;
static const Color textDisabled = textDisabledDark;
static const Color overlay = Color(0xCC0A0E14);
// ============ ACCENT COLORS ============
/// Live indicator red
static const Color live = Color(0xFFFF6B6B);
/// Success green
static const Color success = Color(0xFF4ADE80);
/// Warning amber
static const Color warning = Color(0xFFFBBF24);
/// Error red
static const Color error = Color(0xFFEF4444);
/// Info blue
static const Color info = Color(0xFF3B82F6);
// ============ CATEGORY COLORS ============
static const List<Color> categoryColors = [
Color(0xFF6366F1), // Indigo
Color(0xFFEC4899), // Pink
Color(0xFF8B5CF6), // Purple
Color(0xFF14B8A6), // Teal
Color(0xFFF59E0B), // Amber
Color(0xFF10B981), // Emerald
];
// ============ GLASSMORPHISM ============ // ============ GLASSMORPHISM ============
static const Color glassBackground = Color(0x1AFFFFFF); static final Color glassBackground = const Color(0xFF1E1E1E).withOpacity(0.7);
static const Color glassBorder = Color(0x33FFFFFF); static final Color glassBorder = const Color(0xFFFFFFFF).withOpacity(0.1);
static const Color glassBackgroundLight = Color(0x40FFFFFF);
static const Color glassBorderLight = Color(0x20000000);
// ============ DARK THEME COLOR SCHEME ============ // ============ CATEGORY COLORS (MUTED) ============
static const List<Color> categoryColors = [
Color(0xFF5C6BC0), // Indigo
Color(0xFFAB47BC), // Purple
Color(0xFFEF5350), // Red
Color(0xFF26A69A), // Teal
Color(0xFFFFA726), // Orange
Color(0xFF78909C), // Blue Grey
];
// ============ THEME SCHEMES ============
static ColorScheme get darkColorScheme => const ColorScheme.dark( static ColorScheme get darkColorScheme => const ColorScheme.dark(
primary: primary, primary: primary,
secondary: secondary, secondary: accent,
surface: surfaceDark, surface: surface,
background: background,
error: error, error: error,
onPrimary: Color(0xFF000000), onPrimary: Colors.white,
onSecondary: Color(0xFF000000), onSecondary: Colors.white,
onSurface: textPrimaryDark, onSurface: textPrimary,
onError: Color(0xFFFFFFFF), onError: Colors.white,
brightness: Brightness.dark,
); );
// ============ LIGHT THEME COLOR SCHEME ============ // Minimalist Light Scheme (if ever toggled)
static ColorScheme get lightColorScheme => const ColorScheme.light( static ColorScheme get lightColorScheme => const ColorScheme.light(
primary: primary, primary: primary,
secondary: secondary, secondary: accent,
surface: surfaceLight, surface: Color(0xFFF5F5F5),
background: Colors.white,
error: error, error: error,
onPrimary: Color(0xFFFFFFFF), onPrimary: Colors.white,
onSecondary: Color(0xFFFFFFFF), onSecondary: Colors.black,
onSurface: textPrimaryLight, onSurface: Color(0xFF121212),
onError: Color(0xFFFFFFFF), onError: Colors.white,
brightness: Brightness.light,
); );
} }
+95 -525
View File
@@ -2,38 +2,28 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart'; import 'app_colors.dart';
/// XtremFlow Modern Theme /// XtremFlow Cinematic Minimalist Theme
/// ///
/// Complete theme with typography, spacing, and component theming /// Refined, content-first theme. Less noise, more focus.
class AppTheme { class AppTheme {
AppTheme._(); AppTheme._();
// ============ SPACING SCALE ============ // ============ SPACING ============
static const double spacing4 = 4.0; static const double spacing4 = 4.0;
static const double spacing8 = 8.0; static const double spacing8 = 8.0;
static const double spacing12 = 12.0; static const double spacing12 = 12.0;
static const double spacing16 = 16.0; static const double spacing16 = 16.0;
static const double spacing24 = 24.0; static const double spacing24 = 24.0;
static const double spacing32 = 32.0; static const double spacing32 = 32.0;
static const double spacing48 = 48.0;
// ============ BORDER RADIUS ============ // ============ RADIUS (Tighter for professional look) ============
static const double radiusSm = 8.0; static const double radiusSm = 6.0;
static const double radiusMd = 12.0; static const double radiusMd = 8.0;
static const double radiusLg = 16.0; static const double radiusLg = 12.0;
static const double radiusXl = 24.0; static const double radiusXl = 16.0;
static const double radiusFull = 999.0; static const double radiusFull = 999.0;
// ============ ANIMATION DURATIONS ============ // ============ DARK THEME (Main) ============
static const Duration durationFast = Duration(milliseconds: 150);
static const Duration durationNormal = Duration(milliseconds: 250);
static const Duration durationSlow = Duration(milliseconds: 400);
// ============ ANIMATION CURVES ============
static const Curve curveDefault = Curves.easeOutCubic;
static const Curve curveEmphasized = Curves.easeInOutCubic;
// ============ DARK THEME ============
static ThemeData get darkTheme { static ThemeData get darkTheme {
final baseTheme = ThemeData.dark(useMaterial3: true); final baseTheme = ThemeData.dark(useMaterial3: true);
@@ -41,66 +31,60 @@ class AppTheme {
colorScheme: AppColors.darkColorScheme, colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.background, scaffoldBackgroundColor: AppColors.background,
// AppBar // AppBar: Minimalist, blends with background
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: AppColors.surface, backgroundColor: AppColors.background, // Seamless
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
iconTheme: const IconThemeData(color: AppColors.textPrimary),
titleTextStyle: GoogleFonts.inter( titleTextStyle: GoogleFonts.inter(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
iconTheme: const IconThemeData(color: AppColors.textPrimary),
), ),
// Navigation Bar (bottom) // Navigation: Simple, functional
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.surface, backgroundColor: AppColors.background,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
indicatorColor: AppColors.primary.withOpacity(0.2), indicatorColor: AppColors.primary.withOpacity(0.15),
labelTextStyle: WidgetStateProperty.resolveWith((states) { labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) { final isSelected = states.contains(WidgetState.selected);
return GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primary,
);
}
return GoogleFonts.inter( return GoogleFonts.inter(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: AppColors.textSecondary, color: isSelected ? AppColors.textPrimary : AppColors.textSecondary,
); );
}), }),
iconTheme: WidgetStateProperty.resolveWith((states) { iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) { final isSelected = states.contains(WidgetState.selected);
return const IconThemeData(color: AppColors.primary); return IconThemeData(
} color: isSelected ? AppColors.textPrimary : AppColors.textSecondary,
return const IconThemeData(color: AppColors.textSecondary); );
}), }),
), ),
// Navigation Rail (desktop sidebar) // NavigationRail (Desktop)
navigationRailTheme: NavigationRailThemeData( navigationRailTheme: NavigationRailThemeData(
backgroundColor: AppColors.surface, backgroundColor: AppColors.background,
indicatorColor: AppColors.primary.withOpacity(0.2), indicatorColor: AppColors.primary.withOpacity(0.15),
selectedIconTheme: const IconThemeData(color: AppColors.primary), selectedIconTheme: const IconThemeData(color: AppColors.textPrimary),
unselectedIconTheme: const IconThemeData(color: AppColors.textSecondary), unselectedIconTheme: const IconThemeData(color: AppColors.textSecondary),
selectedLabelTextStyle: GoogleFonts.inter( selectedLabelTextStyle: GoogleFonts.inter(
fontSize: 12, color: AppColors.textPrimary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.primary, fontSize: 12,
), ),
unselectedLabelTextStyle: GoogleFonts.inter( unselectedLabelTextStyle: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary, color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 12,
), ),
), ),
// Cards // Cards: Flat, subtle border
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: AppColors.surface, color: AppColors.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
@@ -112,10 +96,11 @@ class AppTheme {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
), ),
// Inputs // Inputs: Clean, filled
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: AppColors.surfaceVariant, fillColor: AppColors.surfaceVariant,
contentPadding: const EdgeInsets.all(spacing16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm), borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: AppColors.border), borderSide: const BorderSide(color: AppColors.border),
@@ -126,498 +111,83 @@ class AppTheme {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm), borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: AppColors.primary, width: 2), borderSide: const BorderSide(color: AppColors.primary),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: AppColors.error),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing16,
vertical: spacing12,
),
hintStyle: GoogleFonts.inter(
color: AppColors.textDisabled,
fontSize: 14,
),
labelStyle: GoogleFonts.inter(
color: AppColors.textSecondary,
fontSize: 14,
), ),
hintStyle: GoogleFonts.inter(color: AppColors.textTertiary),
labelStyle: GoogleFonts.inter(color: AppColors.textSecondary),
), ),
// Buttons // Buttons: Sharp, reliable
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
horizontal: spacing24,
vertical: spacing12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusSm),
),
textStyle: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primary),
padding: const EdgeInsets.symmetric(
horizontal: spacing24,
vertical: spacing12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusSm),
),
textStyle: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.primary,
textStyle: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
// Chips
chipTheme: ChipThemeData(
backgroundColor: AppColors.surfaceVariant,
selectedColor: AppColors.primary.withOpacity(0.2),
labelStyle: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusFull),
),
padding: const EdgeInsets.symmetric(horizontal: spacing8),
),
// Dialogs
dialogTheme: DialogThemeData(
backgroundColor: AppColors.surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
),
titleTextStyle: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
contentTextStyle: GoogleFonts.inter(
fontSize: 14,
color: AppColors.textSecondary,
),
),
// Snackbars
snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.surfaceVariant,
contentTextStyle: GoogleFonts.inter(
fontSize: 14,
color: AppColors.textPrimary,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusSm),
),
behavior: SnackBarBehavior.floating,
),
// Tabs
tabBarTheme: TabBarThemeData(
labelColor: AppColors.primary,
unselectedLabelColor: AppColors.textSecondary,
labelStyle: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
),
indicatorColor: AppColors.primary,
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: AppColors.border,
),
// Dividers
dividerTheme: const DividerThemeData(
color: AppColors.border,
thickness: 1,
space: 1,
),
// List tiles
listTileTheme: ListTileThemeData(
tileColor: Colors.transparent,
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing16,
vertical: spacing8,
),
titleTextStyle: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
subtitleTextStyle: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textSecondary,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusSm),
),
),
// Progress indicators
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.primary,
linearTrackColor: AppColors.surfaceVariant,
circularTrackColor: AppColors.surfaceVariant,
),
// Switch
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.primary;
}
return AppColors.textSecondary;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.primary.withOpacity(0.3);
}
return AppColors.surfaceVariant;
}),
),
// Text theme
textTheme: TextTheme(
displayLarge: GoogleFonts.inter(
fontSize: 57,
fontWeight: FontWeight.w400,
color: AppColors.textPrimary,
),
displayMedium: GoogleFonts.inter(
fontSize: 45,
fontWeight: FontWeight.w400,
color: AppColors.textPrimary,
),
displaySmall: GoogleFonts.inter(
fontSize: 36,
fontWeight: FontWeight.w400,
color: AppColors.textPrimary,
),
headlineLarge: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
headlineMedium: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
headlineSmall: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleLarge: GoogleFonts.inter(
fontSize: 22,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
titleSmall: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
bodyLarge: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w400,
color: AppColors.textPrimary,
),
bodyMedium: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textPrimary,
),
bodySmall: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.textSecondary,
),
labelLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
labelMedium: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
labelSmall: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
);
}
// ============ LIGHT THEME ============
static ThemeData get lightTheme {
final baseTheme = ThemeData.light(useMaterial3: true);
return baseTheme.copyWith(
colorScheme: AppColors.lightColorScheme,
scaffoldBackgroundColor: AppColors.backgroundLight,
// AppBar
appBarTheme: AppBarTheme(
backgroundColor: AppColors.surfaceLight,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: false,
titleTextStyle: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimaryLight,
),
iconTheme: const IconThemeData(color: AppColors.textPrimaryLight),
),
// Navigation Bar (bottom)
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.surfaceLight,
surfaceTintColor: Colors.transparent,
indicatorColor: AppColors.primary.withOpacity(0.15),
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primary,
);
}
return GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textSecondaryLight,
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return const IconThemeData(color: AppColors.primary);
}
return const IconThemeData(color: AppColors.textSecondaryLight);
}),
),
// Navigation Rail (desktop sidebar)
navigationRailTheme: NavigationRailThemeData(
backgroundColor: AppColors.surfaceLight,
indicatorColor: AppColors.primary.withOpacity(0.15),
selectedIconTheme: const IconThemeData(color: AppColors.primary),
unselectedIconTheme: const IconThemeData(color: AppColors.textSecondaryLight),
selectedLabelTextStyle: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
unselectedLabelTextStyle: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.textSecondaryLight,
),
),
// Cards
cardTheme: CardThemeData(
color: AppColors.surfaceLight,
surfaceTintColor: Colors.transparent,
elevation: 2,
shadowColor: Colors.black.withOpacity(0.08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
margin: EdgeInsets.zero,
),
// Inputs
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surfaceVariantLight,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: AppColors.borderLight),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: AppColors.borderLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: AppColors.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusSm),
borderSide: const BorderSide(color: AppColors.error),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing16,
vertical: spacing12,
),
hintStyle: GoogleFonts.inter(
color: AppColors.textDisabledLight,
fontSize: 14,
),
labelStyle: GoogleFonts.inter(
color: AppColors.textSecondaryLight,
fontSize: 14,
),
),
// Buttons
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.primary, backgroundColor: AppColors.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: spacing24,
vertical: spacing12,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusSm), borderRadius: BorderRadius.circular(radiusSm),
), ),
textStyle: GoogleFonts.inter( textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600),
fontSize: 14, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
fontWeight: FontWeight.w600, ),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.textPrimary,
side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusSm),
), ),
textStyle: GoogleFonts.inter(fontWeight: FontWeight.w600),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
), ),
), ),
// Dialogs // Text
dialogTheme: DialogThemeData(
backgroundColor: AppColors.surfaceLight,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
),
titleTextStyle: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimaryLight,
),
contentTextStyle: GoogleFonts.inter(
fontSize: 14,
color: AppColors.textSecondaryLight,
),
),
// Dividers
dividerTheme: const DividerThemeData(
color: AppColors.borderLight,
thickness: 1,
space: 1,
),
// List tiles
listTileTheme: ListTileThemeData(
tileColor: Colors.transparent,
contentPadding: const EdgeInsets.symmetric(
horizontal: spacing16,
vertical: spacing8,
),
titleTextStyle: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimaryLight,
),
subtitleTextStyle: GoogleFonts.inter(
fontSize: 12,
color: AppColors.textSecondaryLight,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusSm),
),
),
// Progress indicators
progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.primary,
linearTrackColor: AppColors.surfaceVariantLight,
circularTrackColor: AppColors.surfaceVariantLight,
),
// Text theme
textTheme: TextTheme( textTheme: TextTheme(
displayLarge: GoogleFonts.inter( displayLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.bold),
fontSize: 57, displayMedium: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.bold),
fontWeight: FontWeight.w400, headlineMedium: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w600),
color: AppColors.textPrimaryLight, titleLarge: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w600),
), titleMedium: GoogleFonts.inter(color: AppColors.textPrimary, fontWeight: FontWeight.w500),
headlineLarge: GoogleFonts.inter( bodyLarge: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 16),
fontSize: 32, bodyMedium: GoogleFonts.inter(color: AppColors.textSecondary, fontSize: 14),
fontWeight: FontWeight.w600,
color: AppColors.textPrimaryLight,
),
titleLarge: GoogleFonts.inter(
fontSize: 22,
fontWeight: FontWeight.w600,
color: AppColors.textPrimaryLight,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimaryLight,
),
bodyLarge: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w400,
color: AppColors.textPrimaryLight,
),
bodyMedium: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.textPrimaryLight,
),
bodySmall: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.textSecondaryLight,
),
labelLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textPrimaryLight,
),
), ),
); );
} }
}
// ============ LIGHT THEME (Minimalist) ============
static ThemeData get lightTheme {
final baseTheme = ThemeData.light(useMaterial3: true);
final colors = AppColors.lightColorScheme;
return baseTheme.copyWith(
colorScheme: colors,
scaffoldBackgroundColor: colors.background,
appBarTheme: AppBarTheme(
backgroundColor: colors.background,
foregroundColor: colors.onSurface,
elevation: 0,
centerTitle: false,
iconTheme: IconThemeData(color: colors.onSurface),
titleTextStyle: GoogleFonts.inter(
fontSize: 20,
fontWeight: FontWeight.w600,
color: colors.onSurface,
),
),
cardTheme: CardThemeData(
color: colors.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
side: BorderSide(color: Colors.grey.shade300),
),
),
textTheme: TextTheme(
bodyLarge: GoogleFonts.inter(color: colors.onSurface),
bodyMedium: GoogleFonts.inter(color: colors.onSurfaceVariant),
),
// Can expand light theme details later if needed
);
}
}
+193 -224
View File
@@ -45,241 +45,210 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
final authState = ref.watch(authProvider); final authState = ref.watch(authProvider);
return Scaffold( return Scaffold(
body: Container( backgroundColor: AppColors.background,
decoration: const BoxDecoration( body: Stack(
gradient: LinearGradient( children: [
begin: Alignment.topLeft, // Ambient background glow (Subtle)
end: Alignment.bottomRight, Positioned(
colors: [ top: -100,
Color(0xFF0F1923), // Deep dark blue left: -100,
AppColors.background, child: Container(
Color(0xFF0A1628), // Navy accent width: 500,
], height: 500,
stops: [0.0, 0.5, 1.0], decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
AppColors.primary.withOpacity(0.15),
Colors.transparent,
],
radius: 0.6,
),
),
),
), ),
),
child: Stack( // Main content
children: [ Center(
// Glow effect top child: SingleChildScrollView(
Positioned( padding: const EdgeInsets.all(24.0),
top: -100,
left: -100,
child: Container( child: Container(
width: 300, constraints: const BoxConstraints(maxWidth: 400),
height: 300, child: Column(
decoration: BoxDecoration( mainAxisSize: MainAxisSize.min,
shape: BoxShape.circle, children: [
gradient: RadialGradient( // Logo and title
colors: [ Container(
AppColors.primary.withOpacity(0.1), width: 80,
Colors.transparent, height: 80,
], decoration: BoxDecoration(
), color: AppColors.surface,
), borderRadius: BorderRadius.circular(20),
), border: Border.all(color: AppColors.border),
), boxShadow: [
// Glow effect bottom BoxShadow(
Positioned( color: AppColors.primary.withOpacity(0.2),
bottom: -150, blurRadius: 30,
right: -150, offset: const Offset(0, 10),
child: Container( ),
width: 400, ],
height: 400, ),
decoration: BoxDecoration( child: const Icon(
shape: BoxShape.circle, Icons.live_tv,
gradient: RadialGradient( color: AppColors.primary,
colors: [ size: 40,
AppColors.secondary.withOpacity(0.08), ),
Colors.transparent, ),
], const SizedBox(height: 24),
), const Text(
), 'XtremFlow',
), style: TextStyle(
), fontSize: 32,
// Main content fontWeight: FontWeight.w800,
Center( color: AppColors.textPrimary,
child: SingleChildScrollView( letterSpacing: -0.5,
padding: const EdgeInsets.all(24.0), ),
child: Container( ),
constraints: const BoxConstraints(maxWidth: 400), const SizedBox(height: 8),
child: Column( const Text(
mainAxisSize: MainAxisSize.min, 'Cinematic Experience',
children: [ style: TextStyle(
// Logo and title fontSize: 14,
Container( color: AppColors.textSecondary,
width: 80, letterSpacing: 2,
height: 80, fontWeight: FontWeight.w500,
decoration: BoxDecoration( ),
gradient: AppColors.primaryGradient, ),
borderRadius: BorderRadius.circular(20), const SizedBox(height: 48),
boxShadow: [ // Login form
BoxShadow( GlassCard(
color: AppColors.primary.withOpacity(0.4), padding: const EdgeInsets.all(32),
blurRadius: 30, borderRadius: 16,
offset: const Offset(0, 10), showBorder: true,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Welcome Back',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextFormField(
controller: _usernameController,
style: const TextStyle(color: AppColors.textPrimary),
decoration: InputDecoration(
labelText: 'Username',
prefixIcon: const Icon(Icons.person_outline),
fillColor: AppColors.surface,
hoverColor: AppColors.surfaceLight,
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Username required';
}
return null;
},
autofocus: true,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
style: const TextStyle(color: AppColors.textPrimary),
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outline),
fillColor: AppColors.surface,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password required';
}
return null;
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleLogin(),
),
if (authState.errorMessage != null) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.error.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: AppColors.error,
size: 20,
),
const SizedBox(height: 8),
Expanded(
child: Text(
authState.errorMessage!,
style: const TextStyle(
color: AppColors.error,
fontSize: 13,
),
),
),
],
),
),
],
const SizedBox(height: 32),
GradientButton(
label: 'Sign In',
icon: Icons.arrow_forward,
isLoading: authState.isLoading,
isExpanded: true,
onPressed: _handleLogin,
), ),
], ],
), ),
child: const Icon(
Icons.live_tv,
color: Colors.black,
size: 40,
),
), ),
const SizedBox(height: 24), ),
ShaderMask( const SizedBox(height: 24),
shaderCallback: (bounds) => const Text(
AppColors.primaryGradient.createShader(bounds), 'Default: admin / admin',
child: const Text( style: TextStyle(
'XtremFlow', fontSize: 12,
style: TextStyle( color: AppColors.textTertiary,
fontSize: 36, fontStyle: FontStyle.italic,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
), ),
const SizedBox(height: 8), textAlign: TextAlign.center,
Text( ),
'Streaming IPTV Premium', ],
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
letterSpacing: 1,
),
),
const SizedBox(height: 48),
// Login form
GlassCard(
padding: const EdgeInsets.all(24),
borderRadius: AppTheme.radiusLg,
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Connexion',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 24),
TextFormField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'Nom d\'utilisateur',
prefixIcon: Icon(
Icons.person_outline,
color: AppColors.textSecondary,
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Nom d\'utilisateur requis';
}
return null;
},
autofocus: true,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(
Icons.lock_outline,
color: AppColors.textSecondary,
),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: AppColors.textSecondary,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Mot de passe requis';
}
return null;
},
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleLogin(),
),
if (authState.errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.error.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: AppColors.error,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
authState.errorMessage!,
style: TextStyle(
color: AppColors.error,
fontSize: 13,
),
),
),
],
),
),
],
const SizedBox(height: 24),
GradientButton(
label: 'Se connecter',
icon: Icons.login,
isLoading: authState.isLoading,
isExpanded: true,
onPressed: _handleLogin,
),
],
),
),
),
const SizedBox(height: 24),
Text(
'Identifiants par défaut: admin / admin',
style: TextStyle(
fontSize: 12,
color: AppColors.textDisabled,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
),
), ),
), ),
), ),
], ),
), ],
), ),
); );
} }
} }
@@ -0,0 +1,35 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Manages the list of favorite channel IDs
class FavoritesNotifier extends StateNotifier<List<String>> {
FavoritesNotifier() : super([]) {
_loadFavorites();
}
static const String _key = 'xtremflow_favorites';
Future<void> _loadFavorites() async {
final prefs = await SharedPreferences.getInstance();
final favorites = prefs.getStringList(_key) ?? [];
state = favorites;
}
Future<void> toggleFavorite(String streamId) async {
final prefs = await SharedPreferences.getInstance();
if (state.contains(streamId)) {
state = state.where((id) => id != streamId).toList();
} else {
state = [...state, streamId];
}
await prefs.setStringList(_key, state);
}
bool isFavorite(String streamId) {
return state.contains(streamId);
}
}
final favoritesProvider = StateNotifierProvider<FavoritesNotifier, List<String>>((ref) {
return FavoritesNotifier();
});
+66 -88
View File
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../providers/favorites_provider.dart';
/// Premium channel card with Netflix-style design /// Premium channel card with Netflix-style design
/// ///
@@ -9,8 +11,10 @@ import '../../../core/theme/app_colors.dart';
/// - Thumbnail/logo with fallback /// - Thumbnail/logo with fallback
/// - Live badge with pulse animation /// - Live badge with pulse animation
/// - EPG overlay /// - EPG overlay
/// - Favorite toggle
/// - Hover/tap effects /// - Hover/tap effects
class ChannelCard extends StatefulWidget { class ChannelCard extends ConsumerStatefulWidget {
final String streamId;
final String name; final String name;
final String? iconUrl; final String? iconUrl;
final String? currentProgram; final String? currentProgram;
@@ -21,6 +25,7 @@ class ChannelCard extends StatefulWidget {
const ChannelCard({ const ChannelCard({
super.key, super.key,
required this.streamId,
required this.name, required this.name,
this.iconUrl, this.iconUrl,
this.currentProgram, this.currentProgram,
@@ -31,10 +36,10 @@ class ChannelCard extends StatefulWidget {
}); });
@override @override
State<ChannelCard> createState() => _ChannelCardState(); ConsumerState<ChannelCard> createState() => _ChannelCardState();
} }
class _ChannelCardState extends State<ChannelCard> class _ChannelCardState extends ConsumerState<ChannelCard>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
bool _isHovered = false; bool _isHovered = false;
late AnimationController _pulseController; late AnimationController _pulseController;
@@ -61,9 +66,9 @@ class _ChannelCardState extends State<ChannelCard>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final favorites = ref.watch(favoritesProvider);
final isDark = theme.brightness == Brightness.dark; final isFavorite = favorites.contains(widget.streamId);
return MouseRegion( return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true), onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false), onExit: (_) => setState(() => _isHovered = false),
@@ -77,49 +82,31 @@ class _ChannelCardState extends State<ChannelCard>
transform: Matrix4.identity()..scale(_isHovered ? 1.05 : 1.0), transform: Matrix4.identity()..scale(_isHovered ? 1.05 : 1.0),
transformAlignment: Alignment.center, transformAlignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(8),
gradient: LinearGradient( color: AppColors.surface, // Clean flat dark surface
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [
AppColors.surfaceDark.withOpacity(0.95),
AppColors.surfaceVariantDark.withOpacity(0.9),
]
: [
Colors.white.withOpacity(0.95),
AppColors.surfaceVariantLight.withOpacity(0.9),
],
),
border: Border.all( border: Border.all(
color: _isHovered color: _isHovered
? AppColors.primary.withOpacity(0.5) ? AppColors.primary
: (isDark ? AppColors.borderDark : AppColors.borderLight), : AppColors.border,
width: _isHovered ? 1.5 : 1, width: _isHovered ? 2 : 1,
), ),
boxShadow: _isHovered boxShadow: _isHovered
? [ ? [
BoxShadow( BoxShadow(
color: AppColors.primary.withOpacity(0.2), color: AppColors.primary.withOpacity(0.3),
blurRadius: 20, blurRadius: 16,
offset: const Offset(0, 8),
),
]
: [
BoxShadow(
color: Colors.black.withOpacity(isDark ? 0.3 : 0.1),
blurRadius: 8,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
], ]
: [],
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(11), borderRadius: BorderRadius.circular(7), // Inner radius
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Channel logo/thumbnail // Channel logo/thumbnail
_buildChannelImage(isDark), _buildChannelImage(),
// Gradient overlay for text readability // Gradient overlay for text readability
Positioned.fill( Positioned.fill(
@@ -129,10 +116,10 @@ class _ChannelCardState extends State<ChannelCard>
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
Colors.transparent, Colors.black.withOpacity(0.2),
Colors.black.withOpacity(0.7), Colors.black.withOpacity(0.9),
], ],
stops: const [0.4, 1.0], stops: const [0.0, 1.0],
), ),
), ),
), ),
@@ -145,8 +132,24 @@ class _ChannelCardState extends State<ChannelCard>
right: 8, right: 8,
child: _buildLiveBadge(), child: _buildLiveBadge(),
), ),
// Favorite Icon
Positioned(
top: 0,
left: 0,
child: IconButton(
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
size: 20,
color: isFavorite ? AppColors.primary : Colors.white70,
),
onPressed: () {
ref.read(favoritesProvider.notifier).toggleFavorite(widget.streamId);
},
),
),
// Channel info overlay // Channel info
Positioned( Positioned(
left: 10, left: 10,
right: 10, right: 10,
@@ -159,26 +162,20 @@ class _ChannelCardState extends State<ChannelCard>
widget.name, widget.name,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 13, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
shadows: [
Shadow(
color: Colors.black54,
blurRadius: 4,
),
],
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if (widget.currentProgram != null) ...[ if (widget.currentProgram != null && widget.currentProgram!.isNotEmpty) ...[
const SizedBox(height: 2), const SizedBox(height: 4),
Text( Text(
widget.currentProgram!, widget.currentProgram!,
style: TextStyle( style: TextStyle(
color: Colors.white.withOpacity(0.8), color: Colors.white.withOpacity(0.7),
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w500,
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -195,26 +192,26 @@ class _ChannelCardState extends State<ChannelCard>
); );
} }
Widget _buildChannelImage(bool isDark) { Widget _buildChannelImage() {
if (widget.iconUrl != null && widget.iconUrl!.isNotEmpty) { if (widget.iconUrl != null && widget.iconUrl!.isNotEmpty) {
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: widget.iconUrl!, imageUrl: widget.iconUrl!,
fit: BoxFit.cover, fit: BoxFit.contain, // Contain usually looks better for channel logos
placeholder: (context, url) => _buildPlaceholder(isDark), placeholder: (context, url) => _buildPlaceholder(),
errorWidget: (context, url, error) => _buildPlaceholder(isDark), errorWidget: (context, url, error) => _buildPlaceholder(),
); );
} }
return _buildPlaceholder(isDark); return _buildPlaceholder();
} }
Widget _buildPlaceholder(bool isDark) { Widget _buildPlaceholder() {
return Container( return Container(
color: isDark ? AppColors.surfaceVariantDark : AppColors.surfaceVariantLight, color: AppColors.surfaceVariant,
child: Center( child: Center(
child: Icon( child: Icon(
Icons.live_tv_rounded, Icons.tv,
size: 36, size: 32,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, color: AppColors.textTertiary,
), ),
), ),
); );
@@ -225,40 +222,21 @@ class _ChannelCardState extends State<ChannelCard>
animation: _pulseAnimation, animation: _pulseAnimation,
builder: (context, child) { builder: (context, child) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.live.withOpacity(_pulseAnimation.value), color: AppColors.live.withOpacity(_pulseAnimation.value),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: AppColors.live.withOpacity(0.4 * _pulseAnimation.value),
blurRadius: 8,
spreadRadius: 1,
),
],
), ),
child: child, child: child,
); );
}, },
child: const Row( child: const Text(
mainAxisSize: MainAxisSize.min, 'LIVE',
children: [ style: TextStyle(
Icon( color: Colors.white,
Icons.circle, fontSize: 9,
size: 6, fontWeight: FontWeight.bold,
color: Colors.white, ),
),
SizedBox(width: 4),
Text(
'LIVE',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
],
), ),
); );
} }
+379 -357
View File
@@ -1,17 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../providers/xtream_provider.dart'; import '../providers/xtream_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../providers/favorites_provider.dart';
import '../screens/player_screen.dart'; import '../screens/player_screen.dart';
import '../../../core/models/iptv_models.dart'; import '../../../core/models/iptv_models.dart';
import '../../../core/models/playlist_config.dart'; import '../../../core/models/playlist_config.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/utils/responsive_layout.dart'; import '../../../core/utils/responsive_layout.dart';
import 'epg_widget.dart'; import 'channel_card.dart';
/// Live TV tab with category box grid navigation /// Live TV tab - Enhanced with Search, Favorites and Grid/List View
class LiveTVTab extends ConsumerStatefulWidget { class LiveTVTab extends ConsumerStatefulWidget {
final PlaylistConfig playlist; final PlaylistConfig playlist;
@@ -23,9 +23,34 @@ class LiveTVTab extends ConsumerStatefulWidget {
class _LiveTVTabState extends ConsumerState<LiveTVTab> class _LiveTVTabState extends ConsumerState<LiveTVTab>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
// State
String? _selectedCategory; String? _selectedCategory;
int _currentPage = 0; int _currentPage = 0;
static const int _itemsPerPage = 50; static const int _itemsPerPage = 100; // Increased for grid view
// UI State
bool _isGridView = true;
final TextEditingController _searchController = TextEditingController();
bool _showFavoritesOnly = false;
String _searchQuery = '';
@override
void initState() {
super.initState();
_searchController.addListener(() {
setState(() {
_searchQuery = _searchController.text.toLowerCase();
_currentPage = 0; // Reset page on search
});
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -35,391 +60,388 @@ class _LiveTVTabState extends ConsumerState<LiveTVTab>
super.build(context); super.build(context);
final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist)); final channelsAsync = ref.watch(liveChannelsByPlaylistProvider(widget.playlist));
final favorites = ref.watch(favoritesProvider);
return channelsAsync.when( return Scaffold( // Use scaffold for toolbar body structure
data: (groupedChannels) { backgroundColor: Colors.transparent,
if (groupedChannels.isEmpty) { body: Column(
return const Center( children: [
child: Text('No live channels available'), // Toolbar
); _buildToolbar(),
}
// If a category is selected, show channels list // Content
if (_selectedCategory != null && groupedChannels.containsKey(_selectedCategory)) { Expanded(
return _buildChannelsList(groupedChannels[_selectedCategory]!); child: channelsAsync.when(
} data: (groupedChannels) {
// 1. Flatten for global search/filter if needed
final allChannels = groupedChannels.values.expand((element) => element).toList();
List<Channel> displayedChannels = [];
bool isGlobalMode = _searchQuery.isNotEmpty || _showFavoritesOnly;
// Otherwise show category grid if (isGlobalMode) {
return _buildCategoryGrid(groupedChannels); // Global Filter Mode
}, displayedChannels = allChannels.where((channel) {
loading: () => const Center(child: CircularProgressIndicator()), final matchesSearch = _searchQuery.isEmpty ||
error: (error, stack) => Center( channel.name.toLowerCase().contains(_searchQuery);
child: Column( final matchesFavorite = !_showFavoritesOnly ||
mainAxisAlignment: MainAxisAlignment.center, favorites.contains(channel.streamId);
children: [ return matchesSearch && matchesFavorite;
const Icon(Icons.error_outline, size: 48, color: Colors.red), }).toList();
const SizedBox(height: 16), } else if (_selectedCategory != null) {
Text('Error loading channels: $error'), // Category Mode
], displayedChannels = groupedChannels[_selectedCategory] ?? [];
), }
// If no global mode and no category selected, show Category Grid
if (!isGlobalMode && _selectedCategory == null) {
if (groupedChannels.isEmpty) {
return const Center(child: Text('No channels found'));
}
return _buildCategoryGrid(groupedChannels);
}
// Show Channels (Grid or List)
if (displayedChannels.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.tv_off, size: 64, color: AppColors.textTertiary),
const SizedBox(height: 16),
Text(
_showFavoritesOnly ? 'No favorites found' : 'No channels found',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
);
}
return _buildChannelsView(displayedChannels, isGlobalMode);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center(child: Text('Error: $e')),
),
),
],
),
);
}
Widget _buildToolbar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
children: [
// Search Bar
Expanded(
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search channels...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
)
: null,
isDense: true,
filled: true,
fillColor: AppColors.surfaceVariant,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
),
const SizedBox(width: 12),
// Favorites Toggle
IconButton(
tooltip: 'Favorites Only',
icon: Icon(
_showFavoritesOnly ? Icons.favorite : Icons.favorite_border,
color: _showFavoritesOnly ? AppColors.primary : AppColors.textSecondary,
),
onPressed: () {
setState(() {
_showFavoritesOnly = !_showFavoritesOnly;
_selectedCategory = null; // Reset category when toggling favorites
_currentPage = 0;
});
},
),
// View Toggle
IconButton(
tooltip: _isGridView ? 'List View' : 'Grid View',
icon: Icon(
_isGridView ? Icons.view_list : Icons.grid_view,
color: AppColors.textSecondary,
),
onPressed: () => setState(() => _isGridView = !_isGridView),
),
],
), ),
); );
} }
/// Build the category grid view with box cards
Widget _buildCategoryGrid(Map<String, List<Channel>> groupedChannels) { Widget _buildCategoryGrid(Map<String, List<Channel>> groupedChannels) {
// Get filter settings
final settings = ref.watch(iptvSettingsProvider); final settings = ref.watch(iptvSettingsProvider);
// Filter categories based on keywords
var categories = groupedChannels.keys.toList(); var categories = groupedChannels.keys.toList();
// Apply Settings Filter (Hidden categories)
if (settings.liveTvKeywords.isNotEmpty) { if (settings.liveTvKeywords.isNotEmpty) {
categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList(); categories = categories.where((cat) => settings.matchesLiveTvFilter(cat)).toList();
} }
categories.sort(); categories.sort();
if (categories.isEmpty) { final crossAxisCount = ResponsiveLayout.value(
return Center( context,
child: Column( mobile: 2,
mainAxisAlignment: MainAxisAlignment.center, tablet: 3,
children: [ desktop: 5,
const Icon(Icons.filter_list_off, size: 48, color: Colors.grey), );
const SizedBox(height: 16),
Text(
'No categories match the filter',
style: GoogleFonts.roboto(color: Colors.grey),
),
const SizedBox(height: 8),
Text(
'Filter: ${settings.liveTvCategoryFilter}',
style: GoogleFonts.roboto(fontSize: 12, color: Colors.grey.shade600),
),
],
),
);
}
// Responsive grid columns return GridView.builder(
final gridColumns = ResponsiveLayout.value( padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1.5,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final count = groupedChannels[category]!.length;
return InkWell(
onTap: () => setState(() {
_selectedCategory = category;
_currentPage = 0;
}),
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(color: AppColors.border),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.surfaceLight,
AppColors.surface,
],
),
),
child: Stack(
children: [
Positioned(
right: -10,
bottom: -10,
child: Icon(
Icons.tv,
size: 80,
color: Colors.white.withOpacity(0.03),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
category,
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'$count channels',
style: GoogleFonts.inter(
fontSize: 12,
color: AppColors.primary,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
],
),
),
);
},
);
}
Widget _buildChannelsView(List<Channel> channels, bool isGlobalMode) {
// Pagination
final totalPages = (channels.length / _itemsPerPage).ceil();
final startIndex = _currentPage * _itemsPerPage;
final endIndex = (startIndex + _itemsPerPage > channels.length)
? channels.length
: startIndex + _itemsPerPage;
final pageChannels = channels.sublist(startIndex, endIndex);
return Column(
children: [
// Breadcrumb / Back Navigation
if (!isGlobalMode && _selectedCategory != null)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: AppColors.surfaceVariant.withOpacity(0.5),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 16),
onPressed: () => setState(() => _selectedCategory = null),
tooltip: 'Back to Categories',
),
Text(
_selectedCategory!,
style: GoogleFonts.inter(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
const Spacer(),
Text(
'${channels.length} items',
style: GoogleFonts.inter(color: AppColors.textSecondary),
),
],
),
),
// Grid/List Content
Expanded(
child: _isGridView
? _buildGridView(pageChannels)
: _buildListView(pageChannels),
),
// Pagination Controls
if (totalPages > 1)
Container(
height: 50,
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _currentPage > 0 ? () => setState(() => _currentPage--) : null,
),
Text(
'Page ${_currentPage + 1} of $totalPages',
style: GoogleFonts.inter(fontWeight: FontWeight.w500),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _currentPage < totalPages - 1 ? () => setState(() => _currentPage++) : null,
),
],
),
),
],
);
}
Widget _buildGridView(List<Channel> channels) {
final columns = ResponsiveLayout.value(
context, context,
mobile: 2, mobile: 2,
tablet: 4, tablet: 4,
desktop: 6, desktop: 6,
); );
return Padding( return GridView.builder(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(16),
child: GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: columns,
crossAxisCount: gridColumns, mainAxisSpacing: 16,
crossAxisSpacing: 12, crossAxisSpacing: 16,
mainAxisSpacing: 12, childAspectRatio: 1.4, // Card ratio
childAspectRatio: 1.6,
),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final channelCount = groupedChannels[category]!.length;
return _CategoryBox(
categoryName: category,
channelCount: channelCount,
onTap: () {
setState(() {
_selectedCategory = category;
_currentPage = 0;
});
},
);
},
), ),
itemCount: channels.length,
itemBuilder: (context, index) {
final channel = channels[index];
return ChannelCard(
streamId: channel.streamId, // Now required
name: channel.name,
iconUrl: _getProxiedIconUrl(channel.streamIcon),
currentProgram: null, // ShortEPG requires async fetch
onTap: () => _playChannel(channel),
);
},
); );
} }
/// Build channels list for selected category Widget _buildListView(List<Channel> channels) {
Widget _buildChannelsList(List<Channel> channels) { return ListView.separated(
final totalPages = (channels.length / _itemsPerPage).ceil(); padding: const EdgeInsets.all(16),
final startIndex = _currentPage * _itemsPerPage; itemCount: channels.length,
final endIndex = (startIndex + _itemsPerPage > channels.length) separatorBuilder: (_, __) => const SizedBox(height: 8),
? channels.length itemBuilder: (context, index) {
: startIndex + _itemsPerPage; final channel = channels[index];
final paginatedChannels = channels.sublist(startIndex, endIndex); return SizedBox(
height: 80, // Fixed height for list item
return Column( child: ChannelCard(
children: [ streamId: channel.streamId,
// Header with back button name: channel.name,
Container( iconUrl: _getProxiedIconUrl(channel.streamIcon),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), height: 80,
decoration: BoxDecoration( width: double.infinity, // Full width
color: Theme.of(context).colorScheme.surfaceContainerHighest, onTap: () => _playChannel(channel),
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
),
),
), ),
child: Row( );
children: [ },
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
setState(() {
_selectedCategory = null;
_currentPage = 0;
});
},
),
const SizedBox(width: 8),
Expanded(
child: Text(
_selectedCategory ?? '',
style: GoogleFonts.roboto(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
Text(
'${channels.length} channels',
style: GoogleFonts.roboto(
fontSize: 14,
color: Colors.grey,
),
),
],
),
),
// Pagination controls
if (totalPages > 1)
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: _currentPage > 0
? () => setState(() => _currentPage--)
: null,
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_currentPage + 1} / $totalPages',
style: GoogleFonts.roboto(
fontWeight: FontWeight.w500,
),
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: _currentPage < totalPages - 1
? () => setState(() => _currentPage++)
: null,
),
],
),
),
// Channels list
Expanded(
child: ListView.builder(
itemCount: paginatedChannels.length,
itemBuilder: (context, index) {
final channel = paginatedChannels[index];
return _buildChannelTile(context, channel);
},
),
),
],
); );
} }
Widget _buildChannelTile(BuildContext context, Channel channel) { String? _getProxiedIconUrl(String originalUrl) {
// Accept both HTTP and HTTPS icons - use proxy for HTTP if (originalUrl.isEmpty) return null;
final bool hasIcon = channel.streamIcon.isNotEmpty; if (originalUrl.startsWith('http://')) {
String iconUrl = channel.streamIcon; return '/api/xtream/$originalUrl';
// Proxy HTTP images through our server to avoid Mixed Content blocking
if (hasIcon && channel.streamIcon.startsWith('http://')) {
iconUrl = '/api/xtream/${channel.streamIcon}';
} }
return originalUrl;
final Widget placeholder = Container( }
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.tv, color: Colors.white54),
);
return Card( void _playChannel(Channel channel) {
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), Navigator.of(context).push(
child: InkWell( MaterialPageRoute(
onTap: () { builder: (context) => PlayerScreen(
Navigator.of(context).push( streamId: channel.streamId,
MaterialPageRoute( title: channel.name,
builder: (context) => PlayerScreen( playlist: widget.playlist,
streamId: channel.streamId, streamType: StreamType.live,
title: channel.name,
playlist: widget.playlist,
streamType: StreamType.live,
),
),
);
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
// Channel icon
hasIcon
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: iconUrl,
width: 48,
height: 48,
fit: BoxFit.cover,
placeholder: (context, url) => placeholder,
errorWidget: (context, url, error) => placeholder,
),
)
: placeholder,
const SizedBox(width: 12),
// Channel info + EPG
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.roboto(
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
const SizedBox(height: 4),
// EPG info
EPGWidget(
channelId: channel.streamId,
playlist: widget.playlist,
),
],
),
),
// Play button
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.play_arrow,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
);
}
}
/// Category box card widget
class _CategoryBox extends StatelessWidget {
final String categoryName;
final int channelCount;
final VoidCallback onTap;
const _CategoryBox({
required this.categoryName,
required this.channelCount,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Theme.of(context).colorScheme.primary.withOpacity(0.15),
Theme.of(context).colorScheme.secondary.withOpacity(0.08),
],
),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.25),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.live_tv,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$channelCount',
style: GoogleFonts.roboto(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
const Spacer(),
Text(
categoryName,
style: GoogleFonts.roboto(
fontSize: 11,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
), ),
), ),
); );
+12 -10
View File
@@ -200,7 +200,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
required bool isSelected, required bool isSelected,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
final isDark = Theme.of(context).brightness == Brightness.dark; final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
@@ -210,11 +211,11 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? AppColors.primary.withOpacity(0.1) ? colorScheme.primary.withOpacity(0.1)
: (isDark ? AppColors.surfaceVariantDark : AppColors.surfaceVariantLight), : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: isSelected ? AppColors.primary : Colors.transparent, color: isSelected ? colorScheme.primary : Colors.transparent,
width: 2, width: 2,
), ),
), ),
@@ -223,8 +224,8 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
Icon( Icon(
icon, icon,
color: isSelected color: isSelected
? AppColors.primary ? colorScheme.primary
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight), : colorScheme.onSurfaceVariant,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -236,15 +237,15 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isSelected color: isSelected
? AppColors.primary ? colorScheme.primary
: (isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight), : colorScheme.onSurface,
), ),
), ),
Text( Text(
subtitle, subtitle,
style: GoogleFonts.inter( style: GoogleFonts.inter(
fontSize: 12, fontSize: 12,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, color: colorScheme.onSurfaceVariant,
), ),
), ),
], ],
@@ -253,7 +254,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
if (isSelected) if (isSelected)
Icon( Icon(
Icons.check_circle, Icons.check_circle,
color: AppColors.primary, color: colorScheme.primary,
), ),
], ],
), ),
@@ -261,6 +262,7 @@ class _SettingsTabState extends ConsumerState<SettingsTab> with SingleTickerProv
); );
} }
Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) { Widget _buildFiltersTab(BuildContext context, dynamic currentUser, IptvSettings settings) {
return ListView( return ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
+82 -80
View File
@@ -1,96 +1,98 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <meta charset="UTF-8">
<meta charset="UTF-8"> <title>IPTV Player</title>
<title>IPTV Player</title> <!-- Add HLS.js and mpegts.js dependencies -->
<style> <script src="https://cdn.jsdelivr.net/npm/hls.js@1.4.12/dist/hls.min.js"></script>
* { <script src="https://cdn.jsdelivr.net/npm/mpegts.js@1.7.3/dist/mpegts.min.js"></script>
margin: 0; <style>
padding: 0; * {
box-sizing: border-box; margin: 0;
} padding: 0;
box-sizing: border-box;
}
body { body {
background: #000; background: #000;
width: 100%; width: 100%;
height: 100vh; height: 100vh;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
#player-container { #player-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
} }
#video { #video {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: #000; background: #000;
} }
#error { #error {
display: none; display: none;
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
color: #fff; color: #fff;
text-align: center; text-align: center;
} }
#error-icon { #error-icon {
font-size: 64px; font-size: 64px;
color: #f44336; color: #f44336;
margin-bottom: 16px; margin-bottom: 16px;
} }
#loading { #loading {
display: none; display: none;
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
color: #fff; color: #fff;
text-align: center; text-align: center;
} }
.spinner { .spinner {
width: 48px; width: 48px;
height: 48px; height: 48px;
border: 4px solid #fff; border: 4px solid #fff;
border-top-color: transparent; border-top-color: transparent;
border-radius: 50%; border-radius: 50%;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
margin: 0 auto 16px; margin: 0 auto 16px;
} }
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
}
} }
}
#debug { #debug {
display: none; display: none;
/* Hidden in production */ /* Hidden in production */
position: absolute; position: absolute;
bottom: 10px; bottom: 10px;
left: 10px; left: 10px;
color: #0f0; color: #0f0;
font-size: 12px; font-size: 12px;
font-family: monospace; font-family: monospace;
background: rgba(0, 0, 0, 0.7); background: rgba(0, 0, 0, 0.7);
padding: 8px; padding: 8px;
border-radius: 4px; border-radius: 4px;
max-width: 80%; max-width: 80%;
word-break: break-all; word-break: break-all;
} }
</style> </style>
</head> </head>
<body> <body>